Skip to content
Open
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
73 changes: 73 additions & 0 deletions src/cli/renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
printToolResultExpanded,
printToolCallCompact,
printToolResultCompact,
printCompactToolRow,
printEditDiff,
printError,
printInfo,
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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();
Expand Down
33 changes: 28 additions & 5 deletions src/cli/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
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
Expand Down Expand Up @@ -239,20 +255,27 @@ export function compactArg(args: Record<string, unknown>): 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, unknown>,
): 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) ?? "";
Expand All @@ -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") : [];
Expand Down
22 changes: 19 additions & 3 deletions src/cli/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import {
printToolCall,
printToolCallCompact,
printToolResult,
printToolResultCompact,
printToolResultExpanded,
printCompactToolRow,
printEditDiff,
printSkillActivated,
printError,
Expand All @@ -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<string, Array<Record<string, unknown>>>();
let fullText = "";
let turnText = "";

Expand All @@ -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 (
Expand All @@ -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:")) {
Expand Down