feat(tui): added OSC 8 hyperlink support for file paths in tool output

This commit is contained in:
roboomp
2026-05-21 05:01:23 +00:00
parent e82b6d70ab
commit fb0fcdfa7c
12 changed files with 480 additions and 116 deletions
+53 -35
View File
@@ -9,7 +9,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { computeLineHash, HL_BODY_SEP } from "../hashline/hash";
import type { Theme } from "../modes/theme/theme";
import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import { resolveFileDisplayMode } from "../utils/file-display-mode";
import type { ToolSession } from ".";
import { createFileRecorder, formatResultPath } from "./file-recorder";
@@ -155,6 +155,9 @@ export interface AstEditToolDetails {
/** Pre-formatted text for the user-visible TUI render. Mirrors `result.text` lines but uses
* a `│` gutter (no model-only hashline anchors). The TUI uses this directly so it never parses model-facing text. */
displayContent?: string;
/** 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;
}
export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolDetails> {
@@ -233,17 +236,18 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
changesByFile.get(relativePath)!.push(change);
}
const baseDetails: AstEditToolDetails = {
totalReplacements: result.totalReplacements,
filesTouched: result.filesTouched,
filesSearched: result.filesSearched,
applied: result.applied,
limitReached: result.limitReached,
...(cappedParseErrors.length > 0 ? { parseErrors: cappedParseErrors, parseErrorsTotal } : {}),
scopePath,
files: fileList,
fileReplacements: [],
};
const baseDetails: AstEditToolDetails = {
totalReplacements: result.totalReplacements,
filesTouched: result.filesTouched,
filesSearched: result.filesSearched,
applied: result.applied,
limitReached: result.limitReached,
...(cappedParseErrors.length > 0 ? { parseErrors: cappedParseErrors, parseErrorsTotal } : {}),
scopePath,
searchPath: resolvedSearchPath,
files: fileList,
fileReplacements: [],
};
if (result.totalReplacements === 0) {
const parseMessage = cappedParseErrors.length
@@ -480,30 +484,44 @@ export const astEditToolRenderer = {
uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors, details.parseErrorsTotal)),
);
}
return createCachedComponent(
() => options.expanded,
width => {
const changeLines = renderTreeList(
{
items: changeGroups,
expanded: options.expanded,
maxCollapsed: changeGroups.length,
maxCollapsedLines: COLLAPSED_CHANGE_LIMIT,
itemType: "change",
renderItem: group =>
group.map(line => {
if (line.startsWith("## ")) return uiTheme.fg("dim", line);
if (line.startsWith("# ")) return uiTheme.fg("accent", line);
if (line.startsWith("+")) return uiTheme.fg("toolDiffAdded", line);
if (line.startsWith("-")) return uiTheme.fg("toolDiffRemoved", line);
return uiTheme.fg("toolOutput", line);
}),
return createCachedComponent(
() => options.expanded,
width => {
const searchBase = details?.searchPath;
const changeLines = renderTreeList(
{
items: changeGroups,
expanded: options.expanded,
maxCollapsed: changeGroups.length,
maxCollapsedLines: COLLAPSED_CHANGE_LIMIT,
itemType: "change",
renderItem: group => {
let contextDir = searchBase ?? "";
return group.map(line => {
if (line.startsWith("# ")) {
const dirPart = line.slice(2).trimEnd().replace(/\/$/, "");
if (searchBase) {
contextDir = dirPart === "." ? searchBase : path.join(searchBase, dirPart);
}
return uiTheme.fg("accent", line);
}
if (line.startsWith("## ")) {
const fileName = line.slice(3).trimEnd();
const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined;
const styled = uiTheme.fg("dim", line);
return absPath ? fileHyperlink(absPath, styled) : styled;
}
if (line.startsWith("+")) return uiTheme.fg("toolDiffAdded", line);
if (line.startsWith("-")) return uiTheme.fg("toolDiffRemoved", line);
return uiTheme.fg("toolOutput", line);
});
},
uiTheme,
);
return [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
);
},
uiTheme,
);
return [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
);
},
mergeCallAndResult: true,
};
+51 -33
View File
@@ -8,7 +8,7 @@ import * as z from "zod/v4";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import type { Theme } from "../modes/theme/theme";
import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" };
import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import { resolveFileDisplayMode } from "../utils/file-display-mode";
import type { ToolSession } from ".";
import { createFileRecorder, formatResultPath } from "./file-recorder";
@@ -113,6 +113,9 @@ export interface AstGrepToolDetails {
/** Pre-formatted text for the user-visible TUI render. Mirrors `result.text` lines but uses
* a `│` gutter and `*` to mark match lines. The TUI uses this directly so it never parses model-facing text. */
displayContent?: string;
/** Absolute base directory used during search. Used by the renderer to resolve
* display-relative paths to absolute paths for OSC 8 hyperlinks. */
searchPath?: string;
}
export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolDetails> {
@@ -190,16 +193,17 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
matchesByFile.get(relativePath)!.push(match);
}
const baseDetails: AstGrepToolDetails = {
matchCount: result.totalMatches,
fileCount: result.filesWithMatches,
filesSearched: result.filesSearched,
limitReached: result.limitReached,
...(cappedParseErrors.length > 0 ? { parseErrors: cappedParseErrors, parseErrorsTotal } : {}),
scopePath,
files: fileList,
fileMatches: [],
};
const baseDetails: AstGrepToolDetails = {
matchCount: result.totalMatches,
fileCount: result.filesWithMatches,
filesSearched: result.filesSearched,
limitReached: result.limitReached,
...(cappedParseErrors.length > 0 ? { parseErrors: cappedParseErrors, parseErrorsTotal } : {}),
scopePath,
searchPath: resolvedSearchPath,
files: fileList,
fileMatches: [],
};
if (result.matches.length === 0) {
const noMatchMessage = cappedParseErrors.length
@@ -363,29 +367,43 @@ export const astGrepToolRenderer = {
);
}
return createCachedComponent(
() => options.expanded,
width => {
const matchLines = renderTreeList(
{
items: matchGroups,
expanded: options.expanded,
maxCollapsed: matchGroups.length,
maxCollapsedLines: COLLAPSED_MATCH_LIMIT,
itemType: "match",
renderItem: group =>
group.map(line => {
if (line.startsWith("## ")) return uiTheme.fg("dim", line);
if (line.startsWith("# ")) return uiTheme.fg("accent", line);
if (line.startsWith(" meta:")) return uiTheme.fg("dim", line);
return uiTheme.fg("toolOutput", line);
}),
return createCachedComponent(
() => options.expanded,
width => {
const searchBase = details?.searchPath;
const matchLines = renderTreeList(
{
items: matchGroups,
expanded: options.expanded,
maxCollapsed: matchGroups.length,
maxCollapsedLines: COLLAPSED_MATCH_LIMIT,
itemType: "match",
renderItem: group => {
let contextDir = searchBase ?? "";
return group.map(line => {
if (line.startsWith("# ")) {
const dirPart = line.slice(2).trimEnd().replace(/\/$/, "");
if (searchBase) {
contextDir = dirPart === "." ? searchBase : path.join(searchBase, dirPart);
}
return uiTheme.fg("accent", line);
}
if (line.startsWith("## ")) {
const fileName = line.slice(3).trimEnd();
const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined;
const styled = uiTheme.fg("dim", line);
return absPath ? fileHyperlink(absPath, styled) : styled;
}
if (line.startsWith(" meta:")) return uiTheme.fg("dim", line);
return uiTheme.fg("toolOutput", line);
});
},
uiTheme,
);
return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
);
},
uiTheme,
);
return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
);
},
mergeCallAndResult: true,
};
+27 -16
View File
@@ -11,7 +11,7 @@ import { InternalUrlRouter } from "../internal-urls";
import type { Theme } from "../modes/theme/theme";
import findDescription from "../prompts/tools/find.md" with { type: "text" };
import { type TruncationResult, truncateHead } from "../session/streaming-output";
import { Ellipsis, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import { Ellipsis, fileHyperlink, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import type { ToolSession } from ".";
import { applyListLimit } from "./list-limit";
import { formatFullOutputReference, type OutputMeta } from "./output-meta";
@@ -84,6 +84,9 @@ export interface FindToolDetails {
files?: string[];
truncated?: boolean;
error?: string;
/** Working directory at search time. Used by the renderer to resolve relative
* file paths to absolute paths for OSC 8 hyperlinks. */
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. */
@@ -216,11 +219,12 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
const notice = opts?.notice;
const forceTruncated = opts?.forceTruncated ?? false;
if (files.length === 0) {
const details: FindToolDetails = {
const details: FindToolDetails = {
scopePath,
fileCount: 0,
files: [],
truncated: forceTruncated,
cwd: this.session.cwd,
missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
};
const parts = ["No files found matching pattern"];
@@ -246,6 +250,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
truncated: Boolean(forceTruncated || limitMeta.resultLimit || truncation.truncated),
resultLimitReached: limitMeta.resultLimit?.reached,
truncation: truncation.truncated ? truncation : undefined,
cwd: this.session.cwd,
missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
};
@@ -510,20 +515,26 @@ export const findToolRenderer = {
}
if (missingNote) extraLines.push(missingNote);
return createCachedComponent(
() => options.expanded,
width => {
const fileLines = renderFileList(
{
files: files.map(entry => ({ path: entry, isDirectory: entry.endsWith("/") })),
expanded: options.expanded,
maxCollapsed: COLLAPSED_LIST_LIMIT,
},
uiTheme,
);
return [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
);
return createCachedComponent(
() => options.expanded,
width => {
const cwd = details?.cwd;
const fileLines = renderFileList(
{
files: files.map(entry => ({
path: entry,
isDirectory: entry.endsWith("/"),
absPath: cwd && !entry.endsWith("/") ? path.resolve(cwd, entry) : undefined,
})),
expanded: options.expanded,
maxCollapsed: COLLAPSED_LIST_LIMIT,
hyperlinkFn: fileHyperlink,
},
uiTheme,
);
return [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
);
},
mergeCallAndResult: true,
};
+6 -2
View File
@@ -27,7 +27,7 @@ import {
truncateHeadBytes,
truncateLine,
} from "../session/streaming-output";
import { renderCodeCell, renderMarkdownCell, renderStatusLine } from "../tui";
import { fileHyperlink, renderCodeCell, renderMarkdownCell, renderStatusLine } from "../tui";
import { CachedOutputBlock } from "../tui/output-block";
import { resolveFileDisplayMode } from "../utils/file-display-mode";
import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading";
@@ -2261,7 +2261,11 @@ export const readToolRenderer = {
}
const suffix = details?.suffixResolution;
const displayPath = suffix ? shortenPath(suffix.to) : filePath;
const plainDisplayPath = suffix ? shortenPath(suffix.to) : filePath;
// resolvedPath is the absolute fs path for regular files and fs-backed internal URLs (local://, skill://, etc.)
const displayPath = details?.resolvedPath
? fileHyperlink(details.resolvedPath, plainDisplayPath)
: plainDisplayPath;
const correction = suffix ? ` ${uiTheme.fg("dim", `(corrected from ${shortenPath(suffix.from)})`)}` : "";
let title = displayPath ? `Read ${displayPath}${correction}` : "Read";
if (args?.offset !== undefined || args?.limit !== undefined) {
+45 -25
View File
@@ -10,7 +10,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import type { Theme } from "../modes/theme/theme";
import searchDescription from "../prompts/tools/search.md" with { type: "text" };
import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output";
import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
import { resolveFileDisplayMode } from "../utils/file-display-mode";
import type { ToolSession } from ".";
import { createFileRecorder, formatResultPath } from "./file-recorder";
@@ -103,6 +103,9 @@ export interface SearchToolDetails {
* `result.text` lines but uses a `│` gutter and `*` to mark match lines (vs space for
* context). The TUI uses this directly so it never parses model-facing hashline anchors. */
displayContent?: string;
/** Absolute base directory used during search. Used by the renderer to resolve
* display-relative paths to absolute paths for OSC 8 hyperlinks. */
searchPath?: 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. */
@@ -317,8 +320,9 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
const missingPathsNote =
missingPaths.length > 0 ? `Skipped missing paths: ${missingPaths.join(", ")}` : undefined;
if (selectedMatches.length === 0) {
const details: SearchToolDetails = {
const details: SearchToolDetails = {
scopePath,
searchPath,
matchCount: 0,
fileCount: 0,
files: [],
@@ -423,8 +427,9 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
const truncated = Boolean(
fileLimitReached || perFileLimitReached || result.limitReached || truncation.truncated || linesTruncated,
);
const details: SearchToolDetails = {
const details: SearchToolDetails = {
scopePath,
searchPath,
matchCount: selectedMatches.length,
fileCount: fileList.length,
files: fileList,
@@ -576,29 +581,44 @@ export const searchToolRenderer = {
}
if (missingNote) extraLines.push(missingNote);
return createCachedComponent(
() => options.expanded,
width => {
const collapsedMatchLineBudget = Math.max(COLLAPSED_TEXT_LIMIT - extraLines.length, 0);
const matchLines = renderTreeList(
{
items: matchGroups,
expanded: options.expanded,
maxCollapsed: matchGroups.length,
maxCollapsedLines: collapsedMatchLineBudget,
itemType: "match",
renderItem: group =>
group.map(line => {
if (line.startsWith("## ")) return uiTheme.fg("dim", line);
if (line.startsWith("# ")) return uiTheme.fg("accent", line);
return uiTheme.fg("toolOutput", line);
}),
return createCachedComponent(
() => options.expanded,
width => {
const collapsedMatchLineBudget = Math.max(COLLAPSED_TEXT_LIMIT - extraLines.length, 0);
const searchBase = details?.searchPath;
const matchLines = renderTreeList(
{
items: matchGroups,
expanded: options.expanded,
maxCollapsed: matchGroups.length,
maxCollapsedLines: collapsedMatchLineBudget,
itemType: "match",
renderItem: group => {
// Track directory context within a group for ## file headers.
let contextDir = searchBase ?? "";
return group.map(line => {
if (line.startsWith("# ")) {
const dirPart = line.slice(2).trimEnd().replace(/\/$/, "");
if (searchBase) {
contextDir = dirPart === "." ? searchBase : path.join(searchBase, dirPart);
}
return uiTheme.fg("accent", line);
}
if (line.startsWith("## ")) {
const fileName = line.slice(3).trimEnd();
const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined;
const styled = uiTheme.fg("dim", line);
return absPath ? fileHyperlink(absPath, styled) : styled;
}
return uiTheme.fg("toolOutput", line);
});
},
uiTheme,
);
return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
);
},
uiTheme,
);
return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
},
);
},
mergeCallAndResult: true,
};