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.
This commit is contained in:
can1357
2026-06-07 05:37:10 +02:00
parent c8f920e744
commit 52f7defeab
11 changed files with 280 additions and 106 deletions
+1
View File
@@ -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
+11 -5
View File
@@ -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;
@@ -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)]);
}
+5 -1
View File
@@ -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<typeof astEditSchema, AstEditToolDetails> {
@@ -272,6 +275,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
...(cappedParseErrors.length > 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
+5 -1
View File
@@ -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<typeof astGrepSchema, AstGrepToolDetails> {
@@ -206,6 +209,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
...(cappedParseErrors.length > 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") {
@@ -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<number, string>();
// 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);
+20 -6
View File
@@ -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,
});
+18 -3
View File
@@ -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<typeof searchSchema, SearchToolDeta
const details: SearchToolDetails = {
scopePath,
searchPath,
cwd: this.session.cwd,
matchCount: 0,
fileCount: 0,
files: [],
@@ -1129,6 +1134,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
const details: SearchToolDetails = {
scopePath,
searchPath,
cwd: this.session.cwd,
matchCount: selectedMatches.length,
fileCount: fileList.length,
files: fileList,
@@ -1215,10 +1221,11 @@ const URL_HEADER_PREFIX_RE = /^#+\s+/;
function renderSearchDisplayLines(
lines: readonly string[],
searchBase: string | undefined,
headerBase: string | undefined,
fileScope: string | undefined,
uiTheme: Theme,
): RenderedSearchLine[] {
const contexts = classifyGroupedLines(lines, searchBase);
const contexts = classifyGroupedLines(lines, headerBase, fileScope);
// `classifyGroupedLines` can't resolve internal URLs (TUI-only), so track the
// resolved URL target here and use it for the body lines that follow.
let urlFile: string | undefined;
@@ -1439,7 +1446,15 @@ export const searchToolRenderer = {
const allLines = textContent.split("\n");
// Resolve hyperlinks once over the whole output so a nested directory stack
// reconstructs correctly across blank-line group boundaries.
const renderedLines = renderSearchDisplayLines(allLines, details?.searchPath, uiTheme);
// Header/match display paths are cwd-relative, so resolve them against cwd
// (falling back to searchPath for legacy results that predate `cwd`); the
// scoped file's absolute path seeds body lines in single-file searches.
const renderedLines = renderSearchDisplayLines(
allLines,
details?.cwd ?? details?.searchPath,
details?.searchPath,
uiTheme,
);
const matchGroups = groupLineIndicesByBlank(allLines).map(indices => indices.map(i => renderedLines[i]!));
const extraLines: string[] = [];
+23 -7
View File
@@ -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<typeof writeSchema, WriteToolDetails
}`;
return {
content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${outputPath}` }],
details: {},
details: { resolvedPath: resolvedArchivePath.absolutePath },
};
}
@@ -532,7 +535,10 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
}
invalidateFsScanAfterWrite(resolvedSqlitePath.absolutePath);
return toolResult<WriteToolDetails>({}).text(resultText).sourcePath(resolvedSqlitePath.absolutePath).done();
return toolResult<WriteToolDetails>({ 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<typeof writeSchema, WriteToolDetails
if (!diagnostics) {
return {
content: [{ type: "text", text: resultText }],
details: {},
details: { resolvedPath: absolutePath },
};
}
return {
content: [{ type: "text", text: resultText }],
details: {
resolvedPath: absolutePath,
diagnostics,
meta: outputMeta()
.diagnostics(diagnostics.summary, diagnostics.messages ?? [])
@@ -871,7 +878,10 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
if (stripped) {
resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`;
}
return { content: [{ type: "text", text: resultText }], details: {} };
return {
content: [{ type: "text", text: resultText }],
details: { resolvedPath: absolutePath },
};
}
const diagnostics = await this.#writethrough(absolutePath, cleanContent, signal, undefined, batchRequest);
@@ -888,13 +898,14 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
if (!diagnostics) {
return {
content: [{ type: "text", text: resultText }],
details: { madeExecutable: madeExecutable || undefined },
details: { resolvedPath: absolutePath, madeExecutable: madeExecutable || undefined },
};
}
return {
content: [{ type: "text", text: resultText }],
details: {
resolvedPath: absolutePath,
diagnostics,
madeExecutable: madeExecutable || undefined,
meta: outputMeta()
@@ -1050,7 +1061,12 @@ export const writeToolRenderer = {
const fileContent = args?.content || "";
const lang = getLanguageFromPath(rawPath);
const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang));
const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
// The header shows the cwd-relative path but links to the absolute path the
// write resolved to (args.path may be relative, which would yield a broken
// `file://` URI). Falls back to plain text when the result lacks a path.
const linkTarget = result.details?.resolvedPath;
const styledPath = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
const pathDisplay = filePath && linkTarget ? fileHyperlink(linkTarget, styledPath) : styledPath;
if (result.isError) {
const errorText = result.content?.find(c => c.type === "text")?.text ?? "";
@@ -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 });
});
});
@@ -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");
});
});