diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 2812b6619..25aa1e17b 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -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, + ); } } diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index d833fcb2c..550dacef9 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -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(); diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 010500df5..1c4286c7f 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -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(); diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts index 8da367a1c..5c78c625f 100644 --- a/packages/coding-agent/src/core/tools/ls.ts +++ b/packages/coding-agent/src/core/tools/ls.ts @@ -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) { diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 8f39b1eac..2d0dd8381 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -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; }, diff --git a/packages/coding-agent/src/core/tools/render-utils.ts b/packages/coding-agent/src/core/tools/render-utils.ts index 1614ed647..48a5b0af0 100644 --- a/packages/coding-agent/src/core/tools/render-utils.ts +++ b/packages/coding-agent/src/core/tools/render-utils.ts @@ -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 = { 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); +} diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index 3eaa17402..a1b755ad7 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -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; diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index 22059c494..d7012c1dd 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -1,3 +1,5 @@ +import { execSync } from "node:child_process"; + export type ImageProtocol = "kitty" | "iterm2" | null; export interface TerminalCapabilities { @@ -39,19 +41,42 @@ export function setCellDimensions(dims: CellDimensions): void { cellDimensions = dims; } -export function detectCapabilities(): TerminalCapabilities { +/** + * Checks whether the attached tmux client forwards OSC 8 hyperlinks to the + * outer terminal. tmux only re-emits them when its `client_termfeatures` lists + * `hyperlinks`, and strips them otherwise. On any error fallbacks `false`. + */ +function probeTmuxHyperlinks(): boolean { + try { + const termfeatures = execSync("tmux display-message -p '#{client_termfeatures}'", { + encoding: "utf8", + timeout: 250, + stdio: ["ignore", "pipe", "ignore"], + }); + return termfeatures + .split(",") + .map((feature) => feature.trim()) + .includes("hyperlinks"); + } catch { + return false; + } +} + +export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeTmuxHyperlinks): TerminalCapabilities { const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || ""; const terminalEmulator = process.env.TERMINAL_EMULATOR?.toLowerCase() || ""; const term = process.env.TERM?.toLowerCase() || ""; const colorTerm = process.env.COLORTERM?.toLowerCase() || ""; const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit"; - // tmux and screen swallow OSC 8 by default (passthrough is opt-in and wraps - // sequences differently). Force hyperlinks off whenever we detect them, even - // when the outer terminal would otherwise support OSC 8. Image protocols are - // also unreliable under tmux/screen, so leave `images: null` for safety. - const inTmuxOrScreen = !!process.env.TMUX || term.startsWith("tmux") || term.startsWith("screen"); - if (inTmuxOrScreen) { + // Emit OSC 8 hyperlinks only when tmux confirms it forwards. + // Image protocols are unreliable under tmux, so leave `images: null`. + if (process.env.TMUX || term.startsWith("tmux")) { + return { images: null, trueColor: hasTrueColorHint, hyperlinks: tmuxForwardsHyperlink() }; + } + + // screen does not forward OSC 8 hyperlinks, so keep them off there. + if (term.startsWith("screen")) { return { images: null, trueColor: hasTrueColorHint, hyperlinks: false }; } diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts index b179c329f..4e9a3fc97 100644 --- a/packages/tui/test/terminal-image.test.ts +++ b/packages/tui/test/terminal-image.test.ts @@ -207,19 +207,30 @@ describe("detectCapabilities", () => { }); }); - it("forces hyperlinks: false under tmux even if outer terminal supports OSC 8", () => { + it("enables hyperlinks under tmux when the client forwards them", () => { withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => { - const caps = detectCapabilities(); + const caps = detectCapabilities(() => true); + assert.strictEqual(caps.hyperlinks, true); + assert.strictEqual(caps.images, null); + }); + }); + + it("disables hyperlinks under tmux when the client does not forward them", () => { + withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => { + const caps = detectCapabilities(() => false); assert.strictEqual(caps.hyperlinks, false); assert.strictEqual(caps.images, null); }); }); - it("forces hyperlinks: false when TERM starts with 'tmux'", () => { + it("checks tmux capability when TERM starts with 'tmux'", () => { withEnv({ TERM: "tmux-256color", TERM_PROGRAM: "iterm.app" }, () => { - const caps = detectCapabilities(); - assert.strictEqual(caps.hyperlinks, false); + const caps = detectCapabilities(() => true); + assert.strictEqual(caps.hyperlinks, true); assert.strictEqual(caps.images, null); + + const caps2 = detectCapabilities(() => false); + assert.strictEqual(caps2.hyperlinks, false); }); }); @@ -294,7 +305,7 @@ describe("detectCapabilities", () => { it("does not inherit Windows Terminal truecolor through tmux", () => { withEnv({ WT_SESSION: "session", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => { - const caps = detectCapabilities(); + const caps = detectCapabilities(() => false); assert.strictEqual(caps.trueColor, false); assert.strictEqual(caps.hyperlinks, false); assert.strictEqual(caps.images, null); @@ -303,7 +314,7 @@ describe("detectCapabilities", () => { it("trusts explicit truecolor hints through tmux", () => { withEnv({ COLORTERM: "truecolor", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => { - const caps = detectCapabilities(); + const caps = detectCapabilities(() => false); assert.strictEqual(caps.trueColor, true); assert.strictEqual(caps.hyperlinks, false); assert.strictEqual(caps.images, null);