diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a10d2a..bdbf4c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/cli/qmd.ts b/src/cli/qmd.ts index 0f74e6c..875682f 100755 --- a/src/cli/qmd.ts +++ b/src/cli/qmd.ts @@ -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 - 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 - Max results (default 5, or 20 for --files/--json)"); diff --git a/src/collections.ts b/src/collections.ts index 257f144..b59b094 100644 --- a/src/collections.ts +++ b/src/collections.ts @@ -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; // Collection name -> config } diff --git a/test/cli.test.ts b/test/cli.test.ts index 7d6f526..30f093a 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -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 // =============================================================================