diff --git a/extensions/subagents/src/ui/transcript.ts b/extensions/subagents/src/ui/transcript.ts index c56dab97..c976df7b 100644 --- a/extensions/subagents/src/ui/transcript.ts +++ b/extensions/subagents/src/ui/transcript.ts @@ -79,7 +79,11 @@ function parsedArgs(preview: string) { } /** Turn common tool arguments into a useful, bounded summary without retaining raw args. */ -export function summarizeToolArgs(name: string, argsPreview?: string) { +export function summarizeToolArgs( + name: string, + argsPreview?: string, + cwd?: string, +) { if (!argsPreview) return undefined; const fallback = compactPreview(argsPreview); @@ -88,20 +92,33 @@ export function summarizeToolArgs(name: string, argsPreview?: string) { const args = parsedArgs(fallback); if (!args) return fallback; + const path = (field: string) => { + const value = stringField(args, field); + return value ? relativeToCwd(value, cwd) : undefined; + }; + const tool = name.toLowerCase(); if (tool === "bash") return stringField(args, "command") ?? fallback; if (tool === "read" || tool === "write" || tool === "edit") { - return stringField(args, "path") ?? fallback; + return path("path") ?? fallback; } if (tool === "rg" || tool === "fd") { const pattern = stringField(args, "pattern"); - const path = stringField(args, "path"); - if (pattern && path) return `${pattern} · ${path}`; - return pattern ?? path ?? fallback; + const searchPath = path("path"); + if (pattern && searchPath) return `${pattern} · ${searchPath}`; + return pattern ?? searchPath ?? fallback; } return fallback; } +/** Absolute paths inside the child's own checkout read as noise; relativize. */ +function relativeToCwd(path: string, cwd?: string) { + if (!cwd) return path; + if (path === cwd) return "."; + const prefix = cwd.endsWith("/") ? cwd : `${cwd}/`; + return path.startsWith(prefix) ? path.slice(prefix.length) : path; +} + function transcriptMarkdownTheme() { const theme = getMarkdownTheme(); return { @@ -169,14 +186,18 @@ function renderThinking(theme: Theme, text: string, width: number) { return out; } -function renderToolBody(theme: Theme, name: string, argsPreview?: string) { +function renderToolBody( + theme: Theme, + name: string, + argsPreview?: string, + cwd?: string, +) { const toolName = sanitizeText(name); - const preview = summarizeToolArgs(toolName, argsPreview); + const preview = summarizeToolArgs(toolName, argsPreview, cwd); // The `$` form only earns its prompt when there is a command to show; a bare // `$ ` would read as an empty shell line. if (toolName === "bash" && preview) return theme.fg("dim", `$ ${preview}`); return ( - theme.fg("dim", "→ ") + theme.fg("toolTitle", toolName) + (preview ? theme.fg("dim", ` ${preview}`) : "") ); @@ -210,7 +231,7 @@ function phaseGlyph(theme: Theme, phase: ToolPhase, now: number) { } } -/** Command line: ` $ cmd` for bash, ` → name args` otherwise. */ +/** Command line: ` $ cmd` for bash, ` name args` otherwise. */ function renderToolLine( theme: Theme, phase: ToolPhase, @@ -218,9 +239,10 @@ function renderToolLine( argsPreview: string | undefined, width: number, now: number, + cwd?: string, ) { return truncateToWidth( - `${phaseGlyph(theme, phase, now)} ${renderToolBody(theme, name, argsPreview)}`, + `${phaseGlyph(theme, phase, now)} ${renderToolBody(theme, name, argsPreview, cwd)}`, width, ); } @@ -231,10 +253,14 @@ function renderOutputLine( isError: boolean, outputPreview: string, width: number, + cwd?: string, ) { - const preview = outputPreview || "(no output)"; + // Tool output echoes the absolute search path back (fd/rg print what they + // were given); inside the child's own checkout the relative form is enough. + const text = cwd ? outputPreview.split(`${cwd}/`).join("") : outputPreview; + const preview = text || "(no output)"; const content = isError - ? theme.fg(outputPreview ? "error" : "dim", preview) + ? theme.fg(text ? "error" : "dim", preview) : theme.fg("dim", preview); return truncateToWidth(` ${content}`, width); } @@ -245,6 +271,7 @@ function renderAssistantItem( width: number, phases: ReadonlyMap, now: number, + cwd?: string, ) { const out: string[] = []; for (const part of item.parts) { @@ -265,7 +292,15 @@ function renderAssistantItem( // command twice and make the block reflow when the tool settles. if (phase === "live") continue; out.push( - renderToolLine(theme, phase, part.name, part.argsPreview, width, now), + renderToolLine( + theme, + phase, + part.name, + part.argsPreview, + width, + now, + cwd, + ), ); } } @@ -278,6 +313,7 @@ function renderToolResultItem( width: number, paired: boolean, now: number, + cwd?: string, ) { const preview = firstOutputPreview(item.outputPreview); // An orphan result (its call is not the previous item) still needs a glyph: @@ -293,11 +329,11 @@ function renderToolResultItem( now, ), ...(preview - ? [renderOutputLine(theme, item.isError, preview, width)] + ? [renderOutputLine(theme, item.isError, preview, width, cwd)] : []), ]; } - return [renderOutputLine(theme, item.isError, preview, width)]; + return [renderOutputLine(theme, item.isError, preview, width, cwd)]; } function isPairedToolResult( @@ -321,12 +357,13 @@ function renderTranscriptItem( width: number, context: ItemContext, now: number, + cwd?: string, ) { if (item.kind === "user") return renderUserText(theme, item.text, width); if (item.kind === "assistant") { - return renderAssistantItem(theme, item, width, context.phases, now); + return renderAssistantItem(theme, item, width, context.phases, now, cwd); } - return renderToolResultItem(theme, item, width, context.paired, now); + return renderToolResultItem(theme, item, width, context.paired, now, cwd); } interface ItemContext { @@ -413,7 +450,8 @@ export class TranscriptRenderer { const key = `${width}|${context.token}`; const cached = this.itemCache.get(item)?.get(key); const lines = - cached ?? renderTranscriptItem(theme, item, width, context, now); + cached ?? + renderTranscriptItem(theme, item, width, context, now, snap.cwd); if (!cached) { const widths = this.itemCache.get(item) ?? new Map(); if (widths.size >= MAX_CACHED_WIDTHS_PER_ITEM) { @@ -456,11 +494,21 @@ export class TranscriptRenderer { : "ok" : "live"; out.push( - renderToolLine(theme, phase, tool.name, tool.argsPreview, width, now), + renderToolLine( + theme, + phase, + tool.name, + tool.argsPreview, + width, + now, + snap.cwd, + ), ); const preview = firstOutputPreview(tool.outputPreview); if (preview) - out.push(renderOutputLine(theme, !!tool.isError, preview, width)); + out.push( + renderOutputLine(theme, !!tool.isError, preview, width, snap.cwd), + ); } // Queued steering/follow-up messages: show them immediately so Enter diff --git a/extensions/subagents/transcript.test.ts b/extensions/subagents/transcript.test.ts index 31e9dc5f..7978cb8b 100644 --- a/extensions/subagents/transcript.test.ts +++ b/extensions/subagents/transcript.test.ts @@ -145,7 +145,68 @@ test("tool calls summarize known bounded JSON arguments and safely fall back", ( assert.equal(summarizeToolArgs("bash", '{"command":'), '{"command":'); }); -test("bash tool calls use shell prompts while other tools keep arrow form", () => { +test("tool argument summaries relativize paths inside the child cwd", () => { + const cwd = "/repo"; + assert.equal( + summarizeToolArgs("read", '{"path":"/repo/src/a.ts"}', cwd), + "src/a.ts", + ); + assert.equal( + summarizeToolArgs("rg", '{"pattern":"foo","path":"/repo/ext"}', cwd), + "foo · ext", + ); + assert.equal(summarizeToolArgs("read", '{"path":"/repo"}', cwd), "."); + // Paths outside the checkout stay absolute. + assert.equal( + summarizeToolArgs("read", '{"path":"/elsewhere/a.ts"}', cwd), + "/elsewhere/a.ts", + ); + // A shared prefix that is not a path boundary does not relativize. + assert.equal( + summarizeToolArgs("read", '{"path":"/repo-other/a.ts"}', cwd), + "/repo-other/a.ts", + ); +}); + +test("tool call and output lines drop the child cwd prefix", () => { + const cwd = process.cwd(); + const lines = buildTranscriptLines( + snapshot({ + transcript: [ + { + kind: "assistant", + parts: [ + { + type: "toolCall", + toolId: "fd-1", + name: "fd", + argsPreview: JSON.stringify({ + pattern: "*.mjs", + path: `${cwd}/scripts`, + }), + }, + ], + }, + { + kind: "toolResult", + toolId: "fd-1", + name: "fd", + isError: false, + outputPreview: `${cwd}/scripts/benchmark-arm-selection.mjs`, + }, + ], + }), + 80, + theme, + ); + + assert.deepEqual(lines, [ + "✓ fd *.mjs · scripts", + " scripts/benchmark-arm-selection.mjs", + ]); +}); + +test("bash tool calls use shell prompts while other tools go bare", () => { const rendered = plain( buildTranscriptLines( snapshot({ @@ -175,7 +236,7 @@ test("bash tool calls use shell prompts while other tools keep arrow form", () = ); assert.match(rendered, /^· \$ git status --porcelain/m); - assert.match(rendered, /· → read src\/index\.ts/); + assert.match(rendered, /· read src\/index\.ts/); }); test("adjacent tool results form one block with a success glyph", () => { @@ -237,9 +298,9 @@ test("tool errors and empty results use status glyphs", () => { ); // Orphan results (no call above them) keep a glyph of their own. - assert.match(rendered, /✗ → bash/); + assert.match(rendered, /✗ bash/); assert.match(rendered, /command failed/); - assert.match(rendered, /✓ → bash/); + assert.match(rendered, /✓ bash/); }); test("a running tool becomes settled without reflowing", () => {