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:
commit
c940ce19d0
@ -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
|
||||
|
||||
|
||||
29
README.md
29
README.md
@ -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)
|
||||
|
||||
@ -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)");
|
||||
|
||||
@ -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, 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
|
||||
// =============================================================================
|
||||
|
||||
Loading…
Reference in New Issue
Block a user