Merge pull request #508 from danmackinlay/dm/issue-507-osc8-editor-links

feat: Add clickable OSC 8 editor links in CLI search output
This commit is contained in:
Tobias Lütke 2026-04-05 17:59:47 -04:00
commit c940ce19d0
5 changed files with 146 additions and 5 deletions

View File

@ -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

View File

@ -664,7 +664,13 @@ qmd get <file>[:line] # Get document, optionally starting at line
### Output Format
Default output is colorized CLI format (respects `NO_COLOR` env):
Default output is colorized CLI format (respects `NO_COLOR` env).
When stdout is a TTY, result paths are emitted as clickable terminal hyperlinks (OSC 8). Clicking a path opens the file in your editor using an editor URI template.
When stdout is not a TTY (for example piped to another command or redirected to a file), QMD emits plain text paths with no escape sequences.
TTY example:
```
docs/guide.md:42 #a1b2c3
@ -686,6 +692,27 @@ Discussion about code quality and craftsmanship
in the development process.
```
Configure the editor link target with `QMD_EDITOR_URI` (or `editor_uri` in config):
```sh
# VS Code (default)
export QMD_EDITOR_URI="vscode://file/{path}:{line}:{col}"
# Cursor
export QMD_EDITOR_URI="cursor://file/{path}:{line}:{col}"
# Zed
export QMD_EDITOR_URI="zed://file/{path}:{line}:{col}"
# Sublime Text
export QMD_EDITOR_URI="subl://open?url=file://{path}&line={line}"
```
Template placeholders:
- `{path}` absolute filesystem path (URI-encoded)
- `{line}` 1-based line number
- `{col}` or `{column}` 1-based column number
- **Path**: Collection-relative path (e.g., `docs/guide.md`)
- **Docid**: Short hash identifier (e.g., `#a1b2c3`) - use with `qmd get #a1b2c3`
- **Title**: Extracted from document (first heading or filename)

View File

@ -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));
}
export function termLink(text: string, url: string, isTTY: boolean = !!process.stdout.isTTY): string {
if (!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)");

View File

@ -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
}

View File

@ -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, termLink } from "../src/cli/qmd.ts";
// Test fixtures directory and database path
let testDir: string;
@ -1174,19 +1175,56 @@ describe("search output formats", () => {
expect(stdout).not.toMatch(/\/home\//);
});
test("search default CLI format includes qmd:// path, docid, and context", async () => {
test("search default CLI format includes plain qmd:// path, docid, and context in non-TTY mode", async () => {
const { stdout, exitCode } = await runQmd(["search", "test", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir });
expect(exitCode).toBe(0);
// First line should have qmd:// path and docid
// runQmd uses piped stdio, so stdout is non-TTY and should not contain OSC 8 links.
expect(stdout).toMatch(new RegExp(`^qmd://${collName}/.*#[a-f0-9]{6}`, "m"));
expect(stdout).toContain("Context: Test fixtures for QMD");
expect(stdout).not.toContain("\x1b]8;;");
// Ensure no full filesystem paths
expect(stdout).not.toMatch(/\/Users\//);
expect(stdout).not.toMatch(/\/home\//);
});
});
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");
});
test("termLink returns plain text when stdout is not a TTY", () => {
const linked = termLink("docs/api.md:12", "vscode://file//tmp/docs/api.md:12:1", false);
expect(linked).toBe("docs/api.md:12");
});
test("termLink emits OSC 8 hyperlinks when stdout is a TTY", () => {
const linked = termLink("docs/api.md:12", "vscode://file//tmp/docs/api.md:12:1", true);
expect(linked).toBe("\x1b]8;;vscode://file//tmp/docs/api.md:12:1\x07docs/api.md:12\x1b]8;;\x07");
});
});
// =============================================================================
// Get Command Path Normalization Tests
// =============================================================================