Add clickable OSC8 editor links for CLI search results
This commit is contained in:
parent
4909aea28d
commit
636631225e
@ -18,6 +18,11 @@
|
||||
Measures precision@k, recall, MRR, and F1 across BM25, vector, hybrid,
|
||||
and full pipeline backends. Ships with an example fixture against
|
||||
the eval-docs test collection.
|
||||
- CLI search output now emits clickable OSC 8 terminal hyperlinks when
|
||||
stdout is a TTY. Links resolve `qmd://` paths to absolute filesystem
|
||||
paths and open in editors via URI templates (default:
|
||||
`vscode://file/{path}:{line}:{col}`). Configure with `QMD_EDITOR_URI`
|
||||
or `editor_uri` in the YAML config.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@ -1858,6 +1858,57 @@ type OutputRow = {
|
||||
explain?: HybridQueryExplain;
|
||||
};
|
||||
|
||||
const DEFAULT_EDITOR_URI_TEMPLATE = "vscode://file/{path}:{line}:{col}";
|
||||
|
||||
function encodePathForEditorUri(absolutePath: string): string {
|
||||
return encodeURI(absolutePath)
|
||||
.replace(/\?/g, "%3F")
|
||||
.replace(/#/g, "%23");
|
||||
}
|
||||
|
||||
function getEditorUriTemplate(): string {
|
||||
const envTemplate = process.env.QMD_EDITOR_URI?.trim();
|
||||
if (envTemplate) return envTemplate;
|
||||
|
||||
try {
|
||||
const config = loadConfig() as {
|
||||
editor_uri?: string;
|
||||
editor_uri_template?: string;
|
||||
editorUri?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
const configTemplate = (
|
||||
config.editor_uri
|
||||
|| config.editor_uri_template
|
||||
|| config.editorUri
|
||||
|| (typeof config["editor-uri"] === "string" ? config["editor-uri"] : undefined)
|
||||
)?.trim();
|
||||
|
||||
if (configTemplate) return configTemplate;
|
||||
} catch {
|
||||
// Ignore config parsing issues and use default template.
|
||||
}
|
||||
|
||||
return DEFAULT_EDITOR_URI_TEMPLATE;
|
||||
}
|
||||
|
||||
export function buildEditorUri(template: string, absolutePath: string, line: number, col: number): string {
|
||||
const safeLine = Number.isFinite(line) && line > 0 ? Math.floor(line) : 1;
|
||||
const safeCol = Number.isFinite(col) && col > 0 ? Math.floor(col) : 1;
|
||||
const encodedPath = encodePathForEditorUri(absolutePath);
|
||||
|
||||
return template
|
||||
.replace(/\{path\}/g, encodedPath)
|
||||
.replace(/\{line\}/g, String(safeLine))
|
||||
.replace(/\{col\}/g, String(safeCol))
|
||||
.replace(/\{column\}/g, String(safeCol));
|
||||
}
|
||||
|
||||
function termLink(text: string, url: string): string {
|
||||
if (!process.stdout.isTTY) return text;
|
||||
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
|
||||
}
|
||||
|
||||
function outputResults(results: OutputRow[], query: string, opts: OutputOptions): void {
|
||||
const filtered = results.filter(r => r.score >= opts.minScore).slice(0, opts.limit);
|
||||
|
||||
@ -1899,6 +1950,9 @@ function outputResults(results: OutputRow[], query: string, opts: OutputOptions)
|
||||
console.log(`#${docid},${row.score.toFixed(2)},${toQmdPath(row.displayPath)}${ctx}`);
|
||||
}
|
||||
} else if (opts.format === "cli") {
|
||||
const editorUriTemplate = getEditorUriTemplate();
|
||||
const linkDb = getDb();
|
||||
|
||||
for (let i = 0; i < filtered.length; i++) {
|
||||
const row = filtered[i];
|
||||
if (!row) continue;
|
||||
@ -1906,13 +1960,27 @@ function outputResults(results: OutputRow[], query: string, opts: OutputOptions)
|
||||
const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : undefined);
|
||||
|
||||
// Line 1: filepath with docid
|
||||
const path = toQmdPath(row.displayPath);
|
||||
const virtualPath = row.file.startsWith("qmd://") ? row.file : toQmdPath(row.displayPath);
|
||||
const parsed = parseVirtualPath(virtualPath);
|
||||
const absolutePath = resolveVirtualPath(linkDb, virtualPath);
|
||||
|
||||
const legacyPath = toQmdPath(row.displayPath);
|
||||
const displayPath = parsed?.path || row.displayPath;
|
||||
|
||||
// Only show :line if we actually found a term match in the snippet body (exclude header line).
|
||||
const snippetBody = snippet.split("\n").slice(1).join("\n").toLowerCase();
|
||||
const hasMatch = query.toLowerCase().split(/\s+/).some(t => t.length > 0 && snippetBody.includes(t));
|
||||
const lineInfo = hasMatch ? `:${line}` : "";
|
||||
const docidStr = docid ? ` ${c.dim}#${docid}${c.reset}` : "";
|
||||
console.log(`${c.cyan}${path}${c.dim}${lineInfo}${c.reset}${docidStr}`);
|
||||
|
||||
if (process.stdout.isTTY && absolutePath && parsed?.path) {
|
||||
const linkLine = hasMatch ? line : 1;
|
||||
const linkTarget = buildEditorUri(editorUriTemplate, absolutePath, linkLine, 1);
|
||||
const clickable = termLink(`${displayPath}${lineInfo}`, linkTarget);
|
||||
console.log(`${c.cyan}${clickable}${c.reset}${docidStr}`);
|
||||
} else {
|
||||
console.log(`${c.cyan}${legacyPath}${c.dim}${lineInfo}${c.reset}${docidStr}`);
|
||||
}
|
||||
|
||||
// Line 2: Title (if available)
|
||||
if (row.title) {
|
||||
@ -2664,6 +2732,7 @@ function showHelp(): void {
|
||||
console.log("");
|
||||
console.log("Global options:");
|
||||
console.log(" --index <name> - Use a named index (default: index)");
|
||||
console.log(" QMD_EDITOR_URI - Editor link template for clickable TTY search output");
|
||||
console.log("");
|
||||
console.log("Search options:");
|
||||
console.log(" -n <num> - Max results (default 5, or 20 for --files/--json)");
|
||||
|
||||
@ -38,6 +38,8 @@ export interface Collection {
|
||||
*/
|
||||
export interface CollectionConfig {
|
||||
global_context?: string; // Context applied to all collections
|
||||
editor_uri?: string; // Editor URI template for terminal hyperlinks
|
||||
editor_uri_template?: string; // Alias for editor_uri
|
||||
collections: Record<string, Collection>; // Collection name -> config
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { spawn } from "child_process";
|
||||
import { setTimeout as sleep } from "timers/promises";
|
||||
import { buildEditorUri } from "../src/cli/qmd.ts";
|
||||
|
||||
// Test fixtures directory and database path
|
||||
let testDir: string;
|
||||
@ -1187,6 +1188,30 @@ describe("search output formats", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("editor URI templates", () => {
|
||||
test("buildEditorUri expands path, line, and col placeholders", () => {
|
||||
const uri = buildEditorUri(
|
||||
"vscode://file/{path}:{line}:{col}",
|
||||
"/tmp/my notes/readme.md",
|
||||
42,
|
||||
1,
|
||||
);
|
||||
|
||||
expect(uri).toBe("vscode://file//tmp/my%20notes/readme.md:42:1");
|
||||
});
|
||||
|
||||
test("buildEditorUri supports {column} alias", () => {
|
||||
const uri = buildEditorUri(
|
||||
"cursor://file/{path}:{line}:{column}",
|
||||
"/tmp/docs/api.md",
|
||||
7,
|
||||
3,
|
||||
);
|
||||
|
||||
expect(uri).toBe("cursor://file//tmp/docs/api.md:7:3");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Get Command Path Normalization Tests
|
||||
// =============================================================================
|
||||
|
||||
Loading…
Reference in New Issue
Block a user