From f2d56ec972f20755a32d15b759f6c6b67789a62b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 03:14:47 +0000 Subject: [PATCH] feat(renderer): collapse compact tool call+result into a single line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compact tools (read, glob, grep, ls, todo) were showing two terminal lines per tool call — one ○ call line at dispatch time and a separate ✓ result line — instead of the single combined line described in the issue: `✓ read src/foo.ts (42 lines)`. Fix: defer display of compact tools to result time and emit one line that includes both the file path / pattern from the call args and the result count. `summariseResult` gains an optional `args` parameter so the path/pattern is inserted between the tool name and the count. `printCompactToolRow(name, args, result)` encapsulates the combined line. Runner queues call args per compact tool and passes them to `printCompactToolRow` at result time (same ordering contract as the existing `pendingEdits` queue for edit diffs). `think` is unchanged — it already achieves single-line behavior (call prints "◦ thinking…", result is silent). Closes #89 Co-Authored-By: Claude Sonnet 4.6 --- src/cli/renderer.test.ts | 73 ++++++++++++++++++++++++++++++++++++++++ src/cli/renderer.ts | 33 +++++++++++++++--- src/cli/runner.ts | 22 ++++++++++-- 3 files changed, 120 insertions(+), 8 deletions(-) diff --git a/src/cli/renderer.test.ts b/src/cli/renderer.test.ts index 6494ba4..312aac2 100644 --- a/src/cli/renderer.test.ts +++ b/src/cli/renderer.test.ts @@ -12,6 +12,7 @@ import { printToolResultExpanded, printToolCallCompact, printToolResultCompact, + printCompactToolRow, printEditDiff, printError, printInfo, @@ -316,6 +317,55 @@ describe("summariseResult", () => { expect(result.length).toBeLessThan(120); }); }); + + describe("with args (combined call+result line)", () => { + it("read includes file_path in output", () => { + const result = stripAnsi( + summariseResult("read", "1\tline1\n2\tline2", { file_path: "src/foo.ts" }), + ); + expect(result).toContain("src/foo.ts"); + expect(result).toContain("2 lines"); + }); + + it("glob includes pattern in output", () => { + const result = stripAnsi(summariseResult("glob", "a.ts\nb.ts\nc.ts", { pattern: "**/*.ts" })); + expect(result).toContain("**/*.ts"); + expect(result).toContain("3 files"); + }); + + it("grep includes pattern in output", () => { + const result = stripAnsi( + summariseResult("grep", "match1\nmatch2", { pattern: "render", path: "src/" }), + ); + // path takes precedence over pattern in compactArg ordering + expect(result).toContain("src/"); + expect(result).toContain("2 matches"); + }); + + it("ls includes path in output", () => { + const result = stripAnsi( + summariseResult("ls", "a.ts (5 bytes)\nb.ts (10 bytes)", { path: "src/" }), + ); + expect(result).toContain("src/"); + expect(result).toContain("2 entries"); + }); + + it("todo_write omits args (no file path exists)", () => { + const result = stripAnsi( + summariseResult("todo_write", "[x] 1. Done\n[ ] 2. Pending", { + items: [{ content: "x" }], + }), + ); + expect(result).toContain("2 tasks"); + expect(result).not.toContain("[{"); + }); + + it("without args is unchanged from original behaviour", () => { + const withArgs = stripAnsi(summariseResult("read", "1\tline", {})); + const withoutArgs = stripAnsi(summariseResult("read", "1\tline")); + expect(withArgs).toBe(withoutArgs); + }); + }); }); describe("MarkdownStreamRenderer", () => { @@ -621,6 +671,29 @@ describe("print functions write to stderr/stdout", () => { expect(output).not.toContain("✗"); }); + it("printCompactToolRow writes a single ✓ line with arg and result", () => { + printCompactToolRow("read", { file_path: "src/foo.ts" }, "1\tline1\n2\tline2\n3\tline3"); + const output = stripAnsi(stderr()); + expect(output).toContain("✓"); + expect(output).toContain("src/foo.ts"); + expect(output).toContain("3 lines"); + }); + + it("printCompactToolRow expands on error instead of showing a misleading ✓", () => { + printCompactToolRow("read", { file_path: "missing.ts" }, "Error: ENOENT: no such file"); + const output = stripAnsi(stderr()); + expect(output).toContain("✗"); + expect(output).not.toContain("✓"); + expect(output).toContain("ENOENT"); + }); + + it("printCompactToolRow includes pattern for glob", () => { + printCompactToolRow("glob", { pattern: "**/*.ts" }, "a.ts\nb.ts"); + const output = stripAnsi(stderr()); + expect(output).toContain("**/*.ts"); + expect(output).toContain("2 files"); + }); + it("printEditDiff writes + and - lines to stderr", () => { printEditDiff("old content\n", "new content\n", "src/foo.ts"); const output = stderr(); diff --git a/src/cli/renderer.ts b/src/cli/renderer.ts index ea404e5..405195d 100644 --- a/src/cli/renderer.ts +++ b/src/cli/renderer.ts @@ -177,6 +177,22 @@ export function printToolResultCompact(name: string, result: string): void { process.stderr.write(chalk.dim(` ✓ ${summary}`) + "\n"); } +// Single combined line for compact tools — emits one ✓ line that includes both +// the call argument (file path / pattern) and the result summary. Runner calls +// this at result time instead of the two-step printToolCallCompact + printToolResultCompact +// so the terminal shows one line per tool instead of two. +export function printCompactToolRow( + name: string, + args: Record, + result: string, +): void { + if (result.trim().startsWith("Error:")) { + printToolResultExpanded(name, result); + return; + } + process.stderr.write(chalk.dim(` ✓ ${summariseResult(name, result, args)}`) + "\n"); +} + export function printEditDiff(oldStr: string, newStr: string, filePath: string): void { const patch = Diff.createPatch(filePath, oldStr, newStr, "", "", { context: 3 }); const lines = patch.split("\n").slice(4); // skip file header @@ -239,20 +255,27 @@ export function compactArg(args: Record): string { return typeof val === "string" ? chalk.dim(val) : chalk.dim(JSON.stringify(args).slice(0, 80)); } -export function summariseResult(name: string, result: string): string { +export function summariseResult( + name: string, + result: string, + args?: Record, +): string { const trimmed = result.trim(); + // Only include clean path/pattern args (skip complex values like items arrays) + const rawArg = args ? (args.file_path ?? args.path ?? args.pattern) : null; + const argPart = typeof rawArg === "string" ? `${chalk.dim(rawArg)} ` : ""; if (name === "read") { const lines = trimmed.split("\n").length; - return `${name.padEnd(6)}${chalk.dim(`(${lines} lines)`)}`; + return `${name.padEnd(6)}${argPart}${chalk.dim(`(${lines} lines)`)}`; } if (name === "glob") { const files = trimmed ? trimmed.split("\n").length : 0; - return `${name.padEnd(6)}${chalk.dim(`${files} file${files === 1 ? "" : "s"}`)}`; + return `${name.padEnd(6)}${argPart}${chalk.dim(`${files} file${files === 1 ? "" : "s"}`)}`; } if (name === "grep") { const matches = trimmed ? trimmed.split("\n").length : 0; - return `${name.padEnd(6)}${chalk.dim(`${matches} match${matches === 1 ? "" : "es"}`)}`; + return `${name.padEnd(6)}${argPart}${chalk.dim(`${matches} match${matches === 1 ? "" : "es"}`)}`; } if (name === "bash") { const preview = trimmed.split("\n")[0]?.slice(0, 80) ?? ""; @@ -266,7 +289,7 @@ export function summariseResult(name: string, result: string): string { } if (name === "ls") { const entries = trimmed && trimmed !== "(empty directory)" ? trimmed.split("\n").length : 0; - return `ls ${chalk.dim(`${entries} entr${entries === 1 ? "y" : "ies"}`)}`; + return `ls ${argPart}${chalk.dim(`${entries} entr${entries === 1 ? "y" : "ies"}`)}`; } if (name === "todo_write" || name === "todo_read") { const lines = trimmed ? trimmed.split("\n") : []; diff --git a/src/cli/runner.ts b/src/cli/runner.ts index 8b0923a..b6b2ec6 100644 --- a/src/cli/runner.ts +++ b/src/cli/runner.ts @@ -7,8 +7,8 @@ import { printToolCall, printToolCallCompact, printToolResult, - printToolResultCompact, printToolResultExpanded, + printCompactToolRow, printEditDiff, printSkillActivated, printError, @@ -25,6 +25,10 @@ export async function runAgentTurn( spinner.start(); const mdRenderer = new MarkdownStreamRenderer(); const pendingEdits: { file_path: string; old_string: string; new_string: string }[] = []; + // Queues call-time args per compact tool so the combined result line can include + // the file path / pattern. Parallel calls of the same tool stay ordered because + // tool_result events arrive in the same order as the corresponding tool_calls. + const pendingCompactArgs = new Map>>(); let fullText = ""; let turnText = ""; @@ -48,7 +52,14 @@ export async function runAgentTurn( ...(event.thoughtSignature ? { thoughtSignature: event.thoughtSignature } : {}), }); if (COMPACT_TOOLS.has(event.name)) { - printToolCallCompact(event.name, event.args); + if (event.name === "think") { + printToolCallCompact(event.name, event.args); // "◦ thinking…" at call time + } else { + // Defer display to result time so call + result collapse to one line + const stack = pendingCompactArgs.get(event.name) ?? []; + stack.push(event.args); + pendingCompactArgs.set(event.name, stack); + } } else { printToolCall(event.name, event.args); if ( @@ -71,7 +82,12 @@ export async function runAgentTurn( spinner.stop(); void session.log({ type: "tool_result", name: event.name, result: event.result }); if (COMPACT_TOOLS.has(event.name)) { - printToolResultCompact(event.name, event.result); + if (event.name !== "think") { + const stack = pendingCompactArgs.get(event.name) ?? []; + const args = stack.shift() ?? {}; + printCompactToolRow(event.name, args, event.result); + } + // think: silent — "◦ thinking…" printed at call time is the whole display } else if (event.name === "edit") { const edit = pendingEdits.shift(); // always shift to keep array in sync if (event.result.startsWith("Error:")) {