feat(coding-agent): hyperlink file paths in tool titles

Wrap the path shown in read/write/edit/ls tool titles in an OSC 8 file:// hyperlink when the terminal advertises hyperlink support.
The link target is always the absolute path, while the visible text stays relative/home-shortened and line annotations (e.g. :120-140) are kept out of the target.
This commit is contained in:
Marek Pazik
2026-05-29 21:06:26 +10:00
parent ce554ad3de
commit 42ce989a62
7 changed files with 66 additions and 49 deletions

View File

@@ -4,6 +4,7 @@ import { constants } from "fs";
import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
import { type Static, Type } from "typebox";
import { renderDiff } from "../../modes/interactive/components/diff.ts";
import type { Theme } from "../../modes/interactive/theme/theme.ts";
import type { ToolDefinition } from "../extensions/types.ts";
import {
applyEditsToNormalizedContent,
@@ -20,7 +21,7 @@ import {
} from "./edit-diff.ts";
import { withFileMutationQueue } from "./file-mutation-queue.ts";
import { resolveToCwd } from "./path-utils.ts";
import { invalidArgText, shortenPath, str } from "./render-utils.ts";
import { renderToolPath, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
type EditPreview = EditDiffResult | EditDiffError;
@@ -191,14 +192,8 @@ function getRenderablePreviewInput(args: RenderableEditArgs | undefined): { path
return null;
}
function formatEditCall(
args: RenderableEditArgs | undefined,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
): string {
const invalidArg = invalidArgText(theme);
const rawPath = str(args?.file_path ?? args?.path);
const path = rawPath !== null ? shortenPath(rawPath) : null;
const pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
function formatEditCall(args: RenderableEditArgs | undefined, theme: Theme, cwd: string): string {
const pathDisplay = renderToolPath(str(args?.file_path ?? args?.path), theme, cwd);
return `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
}
@@ -206,7 +201,7 @@ function formatEditResult(
args: RenderableEditArgs | undefined,
preview: EditPreview | undefined,
result: EditToolResultLike,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
theme: Theme,
isError: boolean,
): string | undefined {
const rawPath = str(args?.file_path ?? args?.path);
@@ -234,7 +229,7 @@ function formatEditResult(
function getEditHeaderBg(
preview: EditPreview | undefined,
settledError: boolean | undefined,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
theme: Theme,
): (text: string) => string {
if (preview) {
if ("error" in preview) {
@@ -251,11 +246,12 @@ function getEditHeaderBg(
function buildEditCallComponent(
component: EditCallRenderComponent,
args: RenderableEditArgs | undefined,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
theme: Theme,
cwd: string,
): EditCallRenderComponent {
component.setBgFn(getEditHeaderBg(component.preview, component.settledError, theme));
component.clear();
component.addChild(new Text(formatEditCall(args, theme), 0, 0));
component.addChild(new Text(formatEditCall(args, theme, cwd), 0, 0));
if (!component.preview) {
return component;
@@ -389,7 +385,7 @@ export function createEditToolDefinition(
});
}
return buildEditCallComponent(component, args, theme);
return buildEditCallComponent(component, args, theme, context.cwd);
},
renderResult(result, _options, theme, context) {
const callComponent = context.state.callComponent;
@@ -414,7 +410,12 @@ export function createEditToolDefinition(
changed = true;
}
if (changed) {
buildEditCallComponent(callComponent, context.args as RenderableEditArgs | undefined, theme);
buildEditCallComponent(
callComponent,
context.args as RenderableEditArgs | undefined,
theme,
context.cwd,
);
}
}

View File

@@ -5,6 +5,7 @@ import { spawn } from "child_process";
import path from "path";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import type { Theme } from "../../modes/interactive/theme/theme.ts";
import { ensureTool } from "../../utils/tools-manager.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { pathExists, resolveToCwd } from "./path-utils.ts";
@@ -55,10 +56,7 @@ export interface FindToolOptions {
operations?: FindOperations;
}
function formatFindCall(
args: { pattern: string; path?: string; limit?: number } | undefined,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
): string {
function formatFindCall(args: { pattern: string; path?: string; limit?: number } | undefined, theme: Theme): string {
const pattern = str(args?.pattern);
const rawPath = str(args?.path);
const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
@@ -81,7 +79,7 @@ function formatFindResult(
details?: FindToolDetails;
},
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
theme: Theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();

View File

@@ -6,6 +6,7 @@ import { spawn } from "child_process";
import path from "path";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import type { Theme } from "../../modes/interactive/theme/theme.ts";
import { ensureTool } from "../../utils/tools-manager.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { resolveToCwd } from "./path-utils.ts";
@@ -66,7 +67,7 @@ export interface GrepToolOptions {
function formatGrepCall(
args: { pattern: string; path?: string; glob?: string; limit?: number } | undefined,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
theme: Theme,
): string {
const pattern = str(args?.pattern);
const rawPath = str(args?.path);
@@ -90,7 +91,7 @@ function formatGrepResult(
details?: GrepToolDetails;
},
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
theme: Theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();

View File

@@ -4,9 +4,10 @@ import { Text } from "@earendil-works/pi-tui";
import nodePath from "path";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import type { Theme } from "../../modes/interactive/theme/theme.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { pathExists, resolveToCwd } from "./path-utils.ts";
import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts";
import { getTextOutput, renderToolPath, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
@@ -48,15 +49,10 @@ export interface LsToolOptions {
operations?: LsOperations;
}
function formatLsCall(
args: { path?: string; limit?: number } | undefined,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
): string {
const rawPath = str(args?.path);
const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
function formatLsCall(args: { path?: string; limit?: number } | undefined, theme: Theme, cwd: string): string {
const limit = args?.limit;
const invalidArg = invalidArgText(theme);
let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${path === null ? invalidArg : theme.fg("accent", path)}`;
const pathDisplay = renderToolPath(str(args?.path), theme, cwd, { emptyFallback: "." });
let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${pathDisplay}`;
if (limit !== undefined) {
text += theme.fg("toolOutput", ` (limit ${limit})`);
}
@@ -69,7 +65,7 @@ function formatLsResult(
details?: LsToolDetails;
},
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
theme: Theme,
showImages: boolean,
): string {
const output = getTextOutput(result, showImages).trim();
@@ -213,7 +209,7 @@ export function createLsToolDefinition(
},
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
text.setText(formatLsCall(args, theme));
text.setText(formatLsCall(args, theme, context.cwd));
return text;
},
renderResult(result, options, theme, context) {

View File

@@ -13,7 +13,7 @@ import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.ts";
import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { resolveReadPathAsync, resolveToCwd } from "./path-utils.ts";
import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.ts";
import { getTextOutput, renderToolPath, replaceTabs, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts";
@@ -71,11 +71,8 @@ function formatReadLineRange(args: ReadRenderArgs | undefined, theme: Theme): st
return theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
}
function formatReadCall(args: ReadRenderArgs | undefined, theme: Theme): string {
const rawPath = str(args?.file_path ?? args?.path);
const path = rawPath !== null ? shortenPath(rawPath) : null;
const invalidArg = invalidArgText(theme);
const pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
function formatReadCall(args: ReadRenderArgs | undefined, theme: Theme, cwd: string): string {
const pathDisplay = renderToolPath(str(args?.file_path ?? args?.path), theme, cwd);
return `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}${formatReadLineRange(args, theme)}`;
}
@@ -344,7 +341,9 @@ export function createReadToolDefinition(
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
const classification = !context.expanded ? getCompactReadClassification(args, context.cwd) : undefined;
text.setText(
classification ? formatCompactReadCall(classification, args, theme) : formatReadCall(args, theme),
classification
? formatCompactReadCall(classification, args, theme)
: formatReadCall(args, theme, context.cwd),
);
return text;
},

View File

@@ -1,7 +1,10 @@
import * as os from "node:os";
import { pathToFileURL } from "node:url";
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import { getCapabilities, getImageDimensions, imageFallback } from "@earendil-works/pi-tui";
import { getCapabilities, getImageDimensions, hyperlink, imageFallback } from "@earendil-works/pi-tui";
import type { Theme } from "../../modes/interactive/theme/theme.ts";
import { stripAnsi } from "../../utils/ansi.ts";
import { resolvePath } from "../../utils/paths.ts";
import { sanitizeBinaryOutput } from "../../utils/shell.ts";
export function shortenPath(path: unknown): string {
@@ -13,6 +16,12 @@ export function shortenPath(path: unknown): string {
return path;
}
export function linkPath(styledText: string, rawPath: string, cwd: string): string {
if (!getCapabilities().hyperlinks) return styledText;
const absolutePath = resolvePath(rawPath, cwd);
return hyperlink(styledText, pathToFileURL(absolutePath).href);
}
export function str(value: unknown): string | null {
if (typeof value === "string") return value;
if (value == null) return "";
@@ -59,6 +68,18 @@ export type ToolRenderResultLike<TDetails> = {
details: TDetails;
};
export function invalidArgText(theme: { fg: (name: any, text: string) => string }): string {
export function invalidArgText(theme: Theme): string {
return theme.fg("error", "[invalid arg]");
}
export function renderToolPath(
rawPath: string | null,
theme: Theme,
cwd: string,
options?: { emptyFallback?: string },
): string {
if (rawPath === null) return invalidArgText(theme);
const value = rawPath || options?.emptyFallback;
if (!value) return theme.fg("toolOutput", "...");
return linkPath(theme.fg("accent", shortenPath(value)), value, cwd);
}

View File

@@ -4,11 +4,11 @@ import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises";
import { dirname } from "path";
import { type Static, Type } from "typebox";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts";
import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.ts";
import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme.ts";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts";
import { withFileMutationQueue } from "./file-mutation-queue.ts";
import { resolveToCwd } from "./path-utils.ts";
import { invalidArgText, normalizeDisplayText, replaceTabs, shortenPath, str } from "./render-utils.ts";
import { normalizeDisplayText, renderToolPath, replaceTabs, str } from "./render-utils.ts";
import { wrapToolDefinition } from "./tool-definition-wrapper.ts";
const writeSchema = Type.Object({
@@ -131,14 +131,14 @@ function trimTrailingEmptyLines(lines: string[]): string[] {
function formatWriteCall(
args: { path?: string; file_path?: string; content?: string } | undefined,
options: ToolRenderResultOptions,
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
theme: Theme,
cache: WriteHighlightCache | undefined,
cwd: string,
): string {
const rawPath = str(args?.file_path ?? args?.path);
const fileContent = str(args?.content);
const path = rawPath !== null ? shortenPath(rawPath) : null;
const invalidArg = invalidArgText(theme);
let text = `${theme.fg("toolTitle", theme.bold("write"))} ${path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...")}`;
const pathDisplay = renderToolPath(rawPath, theme, cwd);
let text = `${theme.fg("toolTitle", theme.bold("write"))} ${pathDisplay}`;
if (fileContent === null) {
text += `\n\n${theme.fg("error", "[invalid content arg - expected string]")}`;
@@ -163,7 +163,7 @@ function formatWriteCall(
function formatWriteResult(
result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean },
theme: typeof import("../../modes/interactive/theme/theme.ts").theme,
theme: Theme,
): string | undefined {
if (!result.isError) {
return undefined;
@@ -243,6 +243,7 @@ export function createWriteToolDefinition(
{ expanded: context.expanded, isPartial: context.isPartial },
theme,
component.cache,
context.cwd,
),
);
return component;