From 52f7defeabf0b0450b3500a401cf1ecba6cef88b Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 7 Jun 2026 05:36:14 +0200 Subject: [PATCH] fix(coding-agent-tools): fixed OSC8 links to use resolved file paths from session context - Fixed edit/read/search/ast-edit/ast-grep outputs to resolve OSC8 links from session cwd. - Fixed grouped-file output classification to honor headerBase and fileScope for parent path resolution. - Fixed read and write renderers to use resolved source/resolved paths as hyperlink targets. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/edit/renderer.ts | 16 +- .../src/modes/utils/ui-helpers.ts | 14 -- packages/coding-agent/src/tools/ast-edit.ts | 6 +- packages/coding-agent/src/tools/ast-grep.ts | 6 +- .../src/tools/grouped-file-output.ts | 19 +- packages/coding-agent/src/tools/read.ts | 26 ++- packages/coding-agent/src/tools/search.ts | 21 +- packages/coding-agent/src/tools/write.ts | 30 ++- .../test/tools/read-link-probe.test.ts | 65 ------- .../test/tools/tool-output-hyperlinks.test.ts | 182 ++++++++++++++++++ 11 files changed, 280 insertions(+), 106 deletions(-) delete mode 100644 packages/coding-agent/test/tools/read-link-probe.test.ts create mode 100644 packages/coding-agent/test/tools/tool-output-hyperlinks.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 377ac3606..f859113b1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -21,6 +21,7 @@ ### Fixed +- Fixed tool-output file paths not being clickable OSC 8 `file://` hyperlinks in several renderers. `read` titles for plain text and image files (the common case) emitted no link at all because the renderer only linked when a `resolvedPath` was recorded — which the ordinary file/image read paths never set, keeping the absolute path only in `meta.source`; the renderer now falls back to that source path. `write` headers were never wrapped in a hyperlink and now link to the absolute path written (file, archive entry, SQLite, and conflict resolutions). `edit`/`apply_patch` headers wrapped the model-supplied (often cwd-relative) argument path, producing a root-anchored `file:///rel/path` URI; they now link the absolute `details.path` instead. Finally, `search`, `ast_grep`, and `ast_edit` produced doubled link targets (`/proj/src/src/file.ts`) for searches scoped to a subdirectory, because the renderer resolved the cwd-relative display paths against the scope directory rather than cwd — the scoped-search base is now the session cwd (with the scoped file's absolute path still seeding single-file body lines). - Fixed `omp dry-balance --bench` to recover from 401 token failures by re-minting the failing OAuth credential in place before switching accounts - Fixed the bash tool corrupting commands that embed multi-byte UTF-8 (e.g. `✓`/`×` inside a `grep -E` pattern) ahead of a trailing `| head`/`| tail`. The `bash.stripTrailingHeadTail` rewrite cut at char-offset positions reported by `brush-parser` while slicing the command by byte offset, so the trailing-pipe strip landed mid-pattern and dropped the closing quote — turning `… |✓|×|XCTAssert" | tail -80` into `… |✓|×-80` and making execution fail with `pi-natives:command: unterminated double quote`. Fixed in `pi_shell::fixup` (`@oh-my-pi/pi-natives`). - Fixed `omp dry-balance --bench` to recover from 401 token failures by re-minting the failing OAuth credential in place before switching accounts diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index 6f880249a..0ad9cb82d 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -185,10 +185,14 @@ function getOperationTitle(op: Operation | undefined): string { function formatEditPathDisplay( rawPath: string, uiTheme: Theme, - options?: { rename?: string; firstChangedLine?: number }, + options?: { rename?: string; firstChangedLine?: number; linkPath?: string; renameLinkPath?: string }, ): string { + // `rawPath`/`rename` are shown (cwd-relative) but the OSC 8 link targets the + // absolute path when known — a relative `rawPath` would yield a `file:///rel` + // URI that resolves against filesystem root instead of cwd. + const linkTarget = options?.linkPath || rawPath; let pathDisplay = rawPath - ? fileHyperlink(rawPath, uiTheme.fg("accent", shortenPath(rawPath))) + ? fileHyperlink(linkTarget, uiTheme.fg("accent", shortenPath(rawPath))) : uiTheme.fg("toolOutput", "…"); if (options?.firstChangedLine) { @@ -196,7 +200,8 @@ function formatEditPathDisplay( } if (options?.rename) { - pathDisplay += ` ${uiTheme.fg("dim", "→")} ${fileHyperlink(options.rename, uiTheme.fg("accent", shortenPath(options.rename)))}`; + const renameTarget = options.renameLinkPath || options.rename; + pathDisplay += ` ${uiTheme.fg("dim", "→")} ${fileHyperlink(renameTarget, uiTheme.fg("accent", shortenPath(options.rename)))}`; } return pathDisplay; @@ -205,7 +210,7 @@ function formatEditPathDisplay( function formatEditDescription( rawPath: string, uiTheme: Theme, - options?: { rename?: string; firstChangedLine?: number }, + options?: { rename?: string; firstChangedLine?: number; linkPath?: string; renameLinkPath?: string }, ): { language: string; description: string } { const language = getLanguageFromPath(rawPath) ?? "text"; const icon = uiTheme.fg("muted", uiTheme.getLangIcon(language)); @@ -539,7 +544,8 @@ function renderSingleFileResult( const firstChangedLine = (editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) || (details && !isError ? details.firstChangedLine : undefined); - const { description } = formatEditDescription(rawPath, uiTheme, { rename, firstChangedLine }); + const linkPath = details && "path" in details ? details.path : undefined; + const { description } = formatEditDescription(rawPath, uiTheme, { rename, firstChangedLine, linkPath }); // Change stats ride inline on the header bar next to the path. const previewDiff = editDiffPreview && !("error" in editDiffPreview) ? editDiffPreview.diff : undefined; diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 140a43d4b..ba8f8069c 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -74,9 +74,6 @@ export class UiHelpers { * we update the previous status line instead of appending new ones to avoid log spam. */ showStatus(message: string, options?: { dim?: boolean }): void { - if (this.ctx.isBackgrounded) { - return; - } const children = this.ctx.chatContainer.children; const last = children.length > 0 ? children[children.length - 1] : undefined; const secondLast = children.length > 1 ? children[children.length - 2] : undefined; @@ -534,9 +531,6 @@ export class UiHelpers { } clearEditor(): void { - if (this.ctx.isBackgrounded) { - return; - } this.ctx.editor.setText(""); this.ctx.pendingImages = []; this.ctx.pendingImageLinks = []; @@ -545,18 +539,10 @@ export class UiHelpers { } showError(errorMessage: string): void { - if (this.ctx.isBackgrounded) { - process.stderr.write(`Error: ${errorMessage}\n`); - return; - } this.ctx.present([new Spacer(1), new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0)]); } showWarning(warningMessage: string): void { - if (this.ctx.isBackgrounded) { - process.stderr.write(`Warning: ${warningMessage}\n`); - return; - } this.ctx.present([new Spacer(1), new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0)]); } diff --git a/packages/coding-agent/src/tools/ast-edit.ts b/packages/coding-agent/src/tools/ast-edit.ts index c9f8bd530..eac7b6e92 100644 --- a/packages/coding-agent/src/tools/ast-edit.ts +++ b/packages/coding-agent/src/tools/ast-edit.ts @@ -159,6 +159,9 @@ export interface AstEditToolDetails { /** Absolute base directory used during the edit. Used by the renderer to resolve * display-relative paths to absolute paths for OSC 8 hyperlinks. */ searchPath?: string; + /** Session cwd at edit time. Display header paths are cwd-relative, so the + * renderer resolves them against this; `searchPath` is the scope target. */ + cwd?: string; } export class AstEditTool implements AgentTool { @@ -272,6 +275,7 @@ export class AstEditTool implements AgentTool 0 ? { parseErrors: cappedParseErrors, parseErrorsTotal } : {}), scopePath, searchPath: resolvedSearchPath, + cwd: this.session.cwd, files: fileList, fileReplacements: [], }; @@ -560,7 +564,7 @@ export const astEditToolRenderer = { const allLines = textContent.split("\n"); // Resolve hyperlinks over the whole output so nested directory headers // reconstruct across the blank-line groups the tree list collapses by. - const contexts = classifyGroupedLines(allLines, details?.searchPath); + const contexts = classifyGroupedLines(allLines, details?.cwd ?? details?.searchPath, details?.searchPath); const styledLines = allLines.map((line, index) => { const ctx = contexts[index]!; // Swap the inner code-frame gutter `│` for a space so it does not nest a diff --git a/packages/coding-agent/src/tools/ast-grep.ts b/packages/coding-agent/src/tools/ast-grep.ts index 9f2124240..364397035 100644 --- a/packages/coding-agent/src/tools/ast-grep.ts +++ b/packages/coding-agent/src/tools/ast-grep.ts @@ -117,6 +117,9 @@ export interface AstGrepToolDetails { /** Absolute base directory used during search. Used by the renderer to resolve * display-relative paths to absolute paths for OSC 8 hyperlinks. */ searchPath?: string; + /** Session cwd at search time. Display header/match paths are cwd-relative, so + * the renderer resolves them against this; `searchPath` is the scope target. */ + cwd?: string; } export class AstGrepTool implements AgentTool { @@ -206,6 +209,7 @@ export class AstGrepTool implements AgentTool 0 ? { parseErrors: cappedParseErrors, parseErrorsTotal } : {}), scopePath, searchPath: resolvedSearchPath, + cwd: this.session.cwd, files: fileList, fileMatches: [], }; @@ -395,7 +399,7 @@ export const astGrepToolRenderer = { const allLines = textContent.split("\n"); // Resolve hyperlinks over the whole output so nested directory headers // reconstruct across the blank-line groups the tree list collapses by. - const contexts = classifyGroupedLines(allLines, details?.searchPath); + const contexts = classifyGroupedLines(allLines, details?.cwd ?? details?.searchPath, details?.searchPath); const styledLines = allLines.map((line, index) => { const ctx = contexts[index]!; if (ctx.kind === "dir") { diff --git a/packages/coding-agent/src/tools/grouped-file-output.ts b/packages/coding-agent/src/tools/grouped-file-output.ts index 174c0772a..3027693a1 100644 --- a/packages/coding-agent/src/tools/grouped-file-output.ts +++ b/packages/coding-agent/src/tools/grouped-file-output.ts @@ -237,12 +237,23 @@ function resolveGroupedPath(parent: string | undefined, name: string): string | * each header and body line can be linked back to its absolute filesystem path. * Reconstruction is stack-based (not per-blank-group) so nested directory headers * resolve correctly across the whole output. + * + * `headerBase` is the directory the displayed (folded) header paths are relative + * to — for grep/ast tools that is the session cwd, since display paths are + * formatted relative to cwd regardless of the (sub)directory the search was + * scoped to. `fileScope` is the initial owning file for body lines that appear + * before any header (single-file scopes have no `#` headers); it defaults to + * `headerBase` and should be passed the scoped file's absolute path. */ -export function classifyGroupedLines(lines: readonly string[], searchBase: string | undefined): GroupedLineContext[] { +export function classifyGroupedLines( + lines: readonly string[], + headerBase: string | undefined, + fileScope: string | undefined = headerBase, +): GroupedLineContext[] { const result: GroupedLineContext[] = []; const dirAtDepth = new Map(); - // Body lines before any header (single-file scopes) link to the search root. - let currentFile = searchBase; + // Body lines before any header (single-file scopes) link to the scoped file. + let currentFile = fileScope; const clearDeeper = (depth: number) => { for (const key of dirAtDepth.keys()) { @@ -264,7 +275,7 @@ export function classifyGroupedLines(lines: readonly string[], searchBase: strin result.push({ kind: "file", depth, isUrl: true }); continue; } - const parent = depth > 1 ? dirAtDepth.get(depth - 1) : searchBase; + const parent = depth > 1 ? dirAtDepth.get(depth - 1) : headerBase; if (rest.endsWith("/")) { const name = rest.slice(0, -1).replace(HEADER_SUFFIX_RE, ""); const abs = resolveGroupedPath(parent, name); diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 0ab70e42a..48df2758b 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -2337,10 +2337,20 @@ function firstReadSelectorLine(sel: string | undefined): number | undefined { } } +/** Absolute fs path the read result actually resolved to, used as the OSC 8 link + * target when the structured `resolvedPath` isn't set (the common plain-file and + * image reads only record the path in `meta.source`). URL/internal sources are + * not fs paths, so only `type: "path"` qualifies. */ +function readSourceFsPath(details: ReadToolDetails | undefined): string | undefined { + const source = details?.meta?.source; + return source?.type === "path" ? source.value : undefined; +} + function formatReadPathLink( rawPath: string, options: { resolvedPath?: string; + sourcePath?: string; suffixResolution?: { from: string; to: string }; offset?: number; fallbackLabel?: string; @@ -2352,7 +2362,7 @@ function formatReadPathLink( const plainDisplayPath = options.suffixResolution ? shortenPath(options.suffixResolution.to) : shortenPath(basePath || options.resolvedPath || options.fallbackLabel || rawPath); - const target = options.resolvedPath ?? tryResolveInternalUrlSync(basePath); + const target = options.resolvedPath ?? options.sourcePath ?? tryResolveInternalUrlSync(basePath); const line = firstReadSelectorLine(split.sel) ?? options.offset; const linkOptions = line !== undefined ? { line } : undefined; const displayPath = target ? fileHyperlink(target, plainDisplayPath, linkOptions) : plainDisplayPath; @@ -2403,7 +2413,9 @@ export const readToolRenderer = { const rawErrorText = result.content?.find(c => c.type === "text")?.text ?? ""; const errorText = (rawErrorText || "Unknown error").replace(/^Error:\s*/, ""); const rawPath = args?.file_path || args?.path || ""; - const filePath = formatReadPathLink(rawPath, { offset: args?.offset }) || shortenPath(rawPath); + const filePath = + formatReadPathLink(rawPath, { offset: args?.offset, sourcePath: readSourceFsPath(result.details) }) || + shortenPath(rawPath); let title = filePath ? `Read ${filePath}` : "Read"; if (args?.offset !== undefined || args?.limit !== undefined) { const startLine = args.offset ?? 1; @@ -2454,6 +2466,7 @@ export const readToolRenderer = { const suffix = details?.suffixResolution; const displayPath = formatReadPathLink(rawPath, { resolvedPath: details?.resolvedPath, + sourcePath: readSourceFsPath(details), suffixResolution: suffix, fallbackLabel: "image", }); @@ -2486,12 +2499,13 @@ export const readToolRenderer = { } const suffix = details?.suffixResolution; - // resolvedPath is the absolute fs path for fs-backed reads (regular files plus - // local:// / memory:// / skill:// / artifact:// resources). Fall back to a sync - // resolver for fs-backed internal URLs so the title is clickable even before the - // result lands or if the handler didn't populate resolvedPath. + // resolvedPath is the absolute fs path when a read resolved/corrected the + // input (suffix match, internal URL, archive/sqlite/notebook); plain file + // reads only record the absolute path in meta.source, so fall back to that + // (and then to a sync internal-URL resolver) to keep the title clickable. const displayPath = formatReadPathLink(rawPath, { resolvedPath: details?.resolvedPath, + sourcePath: readSourceFsPath(details), suffixResolution: suffix, offset: args?.offset, }); diff --git a/packages/coding-agent/src/tools/search.ts b/packages/coding-agent/src/tools/search.ts index 56d99ada8..b5e4431ce 100644 --- a/packages/coding-agent/src/tools/search.ts +++ b/packages/coding-agent/src/tools/search.ts @@ -621,6 +621,10 @@ export interface SearchToolDetails { /** Absolute base directory used during search. Used by the renderer to resolve * display-relative paths to absolute paths for OSC 8 hyperlinks. */ searchPath?: string; + /** Session cwd at search time. The renderer resolves the display-relative + * (cwd-relative) header/match paths against this for OSC 8 hyperlinks; + * `searchPath` is the scope label target, not the display-path base. */ + cwd?: string; /** User-supplied paths whose base directory was missing on disk. The tool * skipped these and continued with the surviving entries; surfaced as a * non-fatal warning in the renderer and in the model-facing text. */ @@ -1003,6 +1007,7 @@ export class SearchTool implements AgentTool indices.map(i => renderedLines[i]!)); const extraLines: string[] = []; diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index e39e75533..2a401d8af 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -18,7 +18,7 @@ import { getDiagnosticsLedger } from "../lsp/diagnostics-ledger"; import { getLanguageFromPath, highlightCode, type Theme } from "../modes/theme/theme"; import writeDescription from "../prompts/tools/write.md" with { type: "text" }; import type { ToolSession } from "../sdk"; -import { framedBlock, renderStatusLine } from "../tui"; +import { fileHyperlink, framedBlock, renderStatusLine } from "../tui"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import { truncateForPrompt } from "./approval"; import { parseArchivePathCandidates } from "./archive-reader"; @@ -77,6 +77,9 @@ export interface WriteToolDetails { meta?: OutputMeta; /** Set when the file was auto-chmod'd because content begins with a `#!` shebang. */ madeExecutable?: boolean; + /** Absolute filesystem path the write resolved to. Used by the renderer to wrap + * the (possibly cwd-relative) header path in an OSC 8 `file://` hyperlink. */ + resolvedPath?: string; } /** @@ -416,7 +419,7 @@ export class WriteTool implements AgentTool({}).text(resultText).sourcePath(resolvedSqlitePath.absolutePath).done(); + return toolResult({ resolvedPath: resolvedSqlitePath.absolutePath }) + .text(resultText) + .sourcePath(resolvedSqlitePath.absolutePath) + .done(); } catch (error) { if (isEnoent(error)) { throw new ToolError(`SQLite database '${displayPath}' not found`); @@ -593,12 +599,13 @@ export class WriteTool implements AgentTool c.type === "text")?.text ?? ""; diff --git a/packages/coding-agent/test/tools/read-link-probe.test.ts b/packages/coding-agent/test/tools/read-link-probe.test.ts deleted file mode 100644 index 6b4bc2847..000000000 --- a/packages/coding-agent/test/tools/read-link-probe.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, it } from "bun:test"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; -import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read"; -import { getThemeByName, initTheme } from "../../src/modes/theme/theme"; -import { readToolRenderer } from "../../src/tools/read"; - -const TINY_PNG_BASE64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; - -function extractLinkUris(text: string): string[] { - return [...text.matchAll(/\x1b\]8;[^;]*;([^\x1b]+)\x1b\\/g)].map(match => match[1]!); -} - -function createTestToolSession(cwd: string): ToolSession { - return { - cwd, - hasUI: false, - getSessionFile: () => null, - getSessionSpawns: () => "*", - settings: Settings.isolated(), - }; -} - -describe("probe real read render", () => { - it("text and image link emission", async () => { - await initTheme(); - resetSettingsForTest(); - await Settings.init({ inMemory: true }); - settings.override("tui.hyperlinks", "always"); - const theme = await getThemeByName("dark"); - - const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "probe-")); - const textPath = path.join(testDir, "v4-task.txt"); - fs.writeFileSync(textPath, "hello world\nsecond line\n"); - const imgPath = path.join(testDir, "v4-task.png"); - fs.writeFileSync(imgPath, Buffer.from(TINY_PNG_BASE64, "base64")); - - const tool = new ReadTool(createTestToolSession(testDir)); - const textRes = await tool.execute("t", { path: textPath }); - const imgRes = await tool.execute("i", { path: imgPath }); - - const textComp = readToolRenderer.renderResult( - { content: textRes.content, details: textRes.details, isError: textRes.isError }, - { expanded: false, isPartial: false }, - theme!, - { path: textPath }, - ); - const imgComp = readToolRenderer.renderResult( - { content: imgRes.content, details: imgRes.details, isError: imgRes.isError }, - { expanded: false, isPartial: false }, - theme!, - { path: imgPath }, - ); - console.log("TEXT URIS:", JSON.stringify(extractLinkUris(textComp.render(200).join("\n")))); - console.log("IMAGE URIS:", JSON.stringify(extractLinkUris(imgComp.render(200).join("\n")))); - - settings.clearOverride("tui.hyperlinks"); - resetSettingsForTest(); - fs.rmSync(testDir, { recursive: true, force: true }); - }); -}); diff --git a/packages/coding-agent/test/tools/tool-output-hyperlinks.test.ts b/packages/coding-agent/test/tools/tool-output-hyperlinks.test.ts new file mode 100644 index 000000000..c631385f9 --- /dev/null +++ b/packages/coding-agent/test/tools/tool-output-hyperlinks.test.ts @@ -0,0 +1,182 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { astGrepToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/ast-grep"; +import { ReadTool, readToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/read"; +import { searchToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/search"; +import { WriteTool, writeToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/write"; +import { editToolRenderer } from "@oh-my-pi/pi-coding-agent/edit/renderer"; +import { getThemeByName, initTheme } from "../../src/modes/theme/theme"; + +// 1x1 PNG so the read tool takes its image branch. +const TINY_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + +function extractLinkUris(text: string): string[] { + return [...text.matchAll(/\x1b\]8;[^;]*;([^\x1b]+)\x1b\\/g)].map(match => match[1]!); +} + +function createTestToolSession(cwd: string): ToolSession { + return { + cwd, + hasUI: false, + getSessionFile: () => null, + getSessionSpawns: () => "*", + settings: Settings.isolated(), + }; +} + +beforeAll(async () => { + await initTheme(false); + resetSettingsForTest(); + await Settings.init({ inMemory: true }); +}); + +afterEach(() => { + settings.clearOverride("tui.hyperlinks"); +}); + +afterAll(() => { + resetSettingsForTest(); +}); + +describe("tool output OSC 8 file:// hyperlinks", () => { + it("links plain text and image read titles to the resolved filesystem path", async () => { + settings.override("tui.hyperlinks", "always"); + const theme = (await getThemeByName("dark"))!; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-link-read-")); + try { + const textPath = path.join(dir, "task.txt"); + fs.writeFileSync(textPath, "hello\nworld\n"); + const imgPath = path.join(dir, "task.png"); + fs.writeFileSync(imgPath, Buffer.from(TINY_PNG_BASE64, "base64")); + + const tool = new ReadTool(createTestToolSession(dir)); + const textRes = await tool.execute("t", { path: textPath }); + const imgRes = await tool.execute("i", { path: imgPath }); + + const textRender = readToolRenderer + .renderResult( + { content: textRes.content, details: textRes.details, isError: textRes.isError }, + { expanded: false, isPartial: false }, + theme, + { path: textPath }, + ) + .render(200) + .join("\n"); + const imgRender = readToolRenderer + .renderResult( + { content: imgRes.content, details: imgRes.details, isError: imgRes.isError }, + { expanded: false, isPartial: false }, + theme, + { path: imgPath }, + ) + .render(200) + .join("\n"); + + expect(extractLinkUris(textRender)).toContain(`file://${textPath}`); + expect(extractLinkUris(imgRender)).toContain(`file://${imgPath}`); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("links the write header to the absolute path it wrote", async () => { + settings.override("tui.hyperlinks", "always"); + const theme = (await getThemeByName("dark"))!; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-link-write-")); + try { + const filePath = path.join(dir, "out.ts"); + const tool = new WriteTool(createTestToolSession(dir)); + const res = await tool.execute("w", { path: filePath, content: "export const x = 1;\n" }); + const rendered = writeToolRenderer + .renderResult( + { content: res.content, details: res.details, isError: res.isError }, + { expanded: false, isPartial: false }, + theme, + { path: filePath }, + ) + .render(200) + .join("\n"); + expect(extractLinkUris(rendered)).toContain(`file://${filePath}`); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("resolves scoped search links against cwd, not the (sub)scope path", async () => { + settings.override("tui.hyperlinks", "always"); + const theme = (await getThemeByName("dark"))!; + // Scoped search: scope dir (`searchPath`) is below cwd, and the grouped + // display paths are cwd-relative. Resolving against searchPath would double + // the `src` prefix (`/proj/src/src/...`). + const result = { + content: [{ type: "text", text: "" }], + details: { + matchCount: 1, + fileCount: 1, + cwd: "/tmp/omp-project", + searchPath: "/tmp/omp-project/src", + scopePath: "src", + displayContent: ["# src/", "## interactive-mode.ts#abcd", "*12│const needle = true;"].join("\n"), + }, + }; + const rendered = searchToolRenderer + .renderResult(result as never, { expanded: true, isPartial: false }, theme, { pattern: "needle" }) + .render(240) + .join("\n"); + const uris = extractLinkUris(rendered); + expect(uris).toContain("file:///tmp/omp-project/src/interactive-mode.ts"); + expect(uris).toContain("file:///tmp/omp-project/src/interactive-mode.ts?line=12"); + expect(uris.some(uri => uri.includes("/src/src/"))).toBe(false); + }); + + it("resolves scoped ast-grep links against cwd, not the (sub)scope path", async () => { + settings.override("tui.hyperlinks", "always"); + const theme = (await getThemeByName("dark"))!; + const result = { + content: [{ type: "text", text: "" }], + details: { + matchCount: 1, + fileCount: 1, + filesSearched: 1, + limitReached: false, + cwd: "/tmp/omp-project", + searchPath: "/tmp/omp-project/src", + scopePath: "src", + displayContent: ["# src/", "## interactive-mode.ts", " *12│const needle = true;"].join("\n"), + }, + }; + const rendered = astGrepToolRenderer + .renderResult(result as never, { expanded: true, isPartial: false }, theme, { pat: "needle" }) + .render(240) + .join("\n"); + const uris = extractLinkUris(rendered); + expect(uris).toContain("file:///tmp/omp-project/src/interactive-mode.ts"); + expect(uris.some(uri => uri.includes("/src/src/"))).toBe(false); + }); + + it("links the edit header to the absolute details.path even when the arg path is relative", async () => { + settings.override("tui.hyperlinks", "always"); + const theme = (await getThemeByName("dark"))!; + const rendered = editToolRenderer + .renderResult( + { + content: [{ type: "text", text: "Updated src/a.ts" }], + details: { diff: "+1|// x", op: "update", path: "/tmp/omp-project/src/a.ts" }, + }, + { expanded: false, isPartial: false, renderContext: { editMode: "hashline" } }, + theme, + { path: "src/a.ts" }, + ) + .render(200) + .join("\n"); + const uris = extractLinkUris(rendered); + expect(uris).toContain("file:///tmp/omp-project/src/a.ts"); + // A relative arg path must not leak into a root-anchored `file:///src/a.ts`. + expect(uris).not.toContain("file:///src/a.ts"); + }); +});