Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 68 additions & 20 deletions extensions/subagents/src/ui/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 {
Expand Down Expand Up @@ -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}`) : "")
);
Expand Down Expand Up @@ -210,17 +231,18 @@ function phaseGlyph(theme: Theme, phase: ToolPhase, now: number) {
}
}

/** Command line: `<glyph> $ cmd` for bash, `<glyph> name args` otherwise. */
/** Command line: `<glyph> $ cmd` for bash, `<glyph> name args` otherwise. */
function renderToolLine(
theme: Theme,
phase: ToolPhase,
name: string,
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,
);
}
Expand All @@ -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);
}
Expand All @@ -245,6 +271,7 @@ function renderAssistantItem(
width: number,
phases: ReadonlyMap<string, ToolPhase>,
now: number,
cwd?: string,
) {
const out: string[] = [];
for (const part of item.parts) {
Expand All @@ -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,
),
);
}
}
Expand All @@ -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:
Expand All @@ -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(
Expand All @@ -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 {
Expand Down Expand Up @@ -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<string, string[]>();
if (widths.size >= MAX_CACHED_WIDTHS_PER_ITEM) {
Expand Down Expand Up @@ -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
Expand Down
69 changes: 65 additions & 4 deletions extensions/subagents/transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading