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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user