From 1824f4558aaaa22c2702ea51432829cb3a04ef14 Mon Sep 17 00:00:00 2001 From: ZouR-Ma <2605315944@qq.com> Date: Tue, 20 Oct 2026 19:02:54 +0800 Subject: [PATCH 1/2] fix(cli): abbreviate run_command titles in Step tool rows The Step presentation reused the native bash call renderer, whose multi-line `$ (timeout ...)` echo was moved into the result body. Long inline scripts and heredocs therefore filled the transcript with the full command. Preview the command in the run_command title (line breaks folded, first 50 characters plus an ellipsis) and drop the echo rows while collapsed. The full command is still shown in the body on expand when the title shortened it. --- .../src/ui/view/transcript/tool-execution.ts | 41 ++++++++++++++++--- apps/cli/test/step-tool-row-contract.test.ts | 39 ++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/ui/view/transcript/tool-execution.ts b/apps/cli/src/ui/view/transcript/tool-execution.ts index 8bf878e3..54d1bb9e 100644 --- a/apps/cli/src/ui/view/transcript/tool-execution.ts +++ b/apps/cli/src/ui/view/transcript/tool-execution.ts @@ -33,6 +33,8 @@ import type { StepToolSpinnerState } from "./step-spinner.ts"; const FALLBACK_PREVIEW_LINES = 10; const STEP_COLLAPSED_LINES = 5; const STEP_COLLAPSED_DIFF_LINES = 8; +/** Maximum command characters shown in a `run_command(...)` Step title. */ +const STEP_COMMAND_PREVIEW_CHARS = 50; /** The legacy Step names whose visible headers differ from Pi's native names. */ const STEP_TOOL_NAMES = new Set([ @@ -222,6 +224,22 @@ function readNumberDetail(details: unknown, ...keys: string[]): number | undefin return readNumberArg(details, ...keys); } +/** + * One-line command preview for the Step title. Inline scripts and heredocs + * would otherwise fill the transcript with the command itself; line breaks + * (with their indentation) fold to one space and the text is cut to the first + * STEP_COMMAND_PREVIEW_CHARS code points. + */ +function formatStepCommandPreview(command: string): string { + const singleLine = command + .replace(/\s*[\r\n]+\s*/gu, " ") + .replace(/\t/gu, " ") + .trim(); + const chars = Array.from(singleLine); + if (chars.length <= STEP_COMMAND_PREVIEW_CHARS) return singleLine; + return `${chars.slice(0, STEP_COMMAND_PREVIEW_CHARS).join("")}…`; +} + /** * Build the old Step-shaped call title while leaving Pi's renderer in charge * of the actual call/result body. Pi's shell renderers intentionally use their @@ -241,7 +259,7 @@ function formatStepCallTitle(toolName: string, args: unknown, cwd: string): stri switch (toolName) { case "run_command": - argument = command; + argument = command === undefined ? undefined : formatStepCommandPreview(command); break; case "find_files": argument = pattern; @@ -724,11 +742,22 @@ export class ToolExecutionComponent extends Container { } if (callLines.length === 0) callLines = [theme.fg("toolTitle", theme.bold(this.toolName))]; - // Some native call renderers (notably `write`) include a preview below - // their header. Treat those rows as payload so the Step connector remains - // stable and the preview participates in the same collapse budget as a - // result renderer's output. - if (callLines.length > 1) { + if (this.toolName === "run_command") { + // The native shell call rows only echo the command that the Step title + // already previews. Show that echo on expand, and only when the preview + // had to shorten or reflow the command; it is rendered at body width so + // the connector does not clip it. + const command = readStringArg(this.args, "command"); + const showCommand = this.expanded && command !== undefined && formatStepCommandPreview(command) !== command; + const commandRows = + showCommand && this.stepCallComponent ? trimRenderedRows(this.stepCallComponent.render(bodyWidth)) : []; + resultLines = [...commandRows, ...resultLines]; + callLines = [callLines[0]!]; + } else if (callLines.length > 1) { + // Some native call renderers (notably `write`) include a preview below + // their header. Treat those rows as payload so the Step connector remains + // stable and the preview participates in the same collapse budget as a + // result renderer's output. resultLines = [...callLines.slice(1), ...resultLines]; callLines = [callLines[0]!]; } diff --git a/apps/cli/test/step-tool-row-contract.test.ts b/apps/cli/test/step-tool-row-contract.test.ts index 9f1e10ca..f3b62b9d 100644 --- a/apps/cli/test/step-tool-row-contract.test.ts +++ b/apps/cli/test/step-tool-row-contract.test.ts @@ -68,6 +68,45 @@ describe("Step projected rows", () => { expect(args.command).toBe(command); }); + it("previews long run_command scripts in the title and shows the full command only on expand", () => { + const script = [ + 'playwriter -e "', + " if (!state.r24) {", + " state.page = context.pages().find((p) => p.url().includes('grafana'));", + " const marker = 'FULL_COMMAND_TAIL';", + " }", + '" -s 3 2>&1 | tail -12', + ].join("\n"); + const { component } = createHarness("run_command", { command: script, timeout: 120 }); + component.setArgsComplete(); + component.markExecutionStarted(); + component.updateResult({ content: [{ type: "text", text: "RESULT_ROW" }], isError: false }, false); + + const collapsed = expectPhysicalRows(component, 200).filter((row) => row.length > 0); + const preview = Array.from(script.replace(/\s*[\r\n]+\s*/gu, " ")) + .slice(0, 50) + .join(""); + expect(collapsed[0]).toBe(`● run_command(${preview}…)`); + expect(collapsed.join("\n")).not.toContain("FULL_COMMAND_TAIL"); + expect(collapsed.join("\n")).not.toContain("timeout 120s"); + expect(collapsed[1]).toBe(" └ RESULT_ROW"); + + component.setExpanded(true); + const expanded = expectPhysicalRows(component, 200).join("\n"); + expect(expanded).toContain("FULL_COMMAND_TAIL"); + expect(expanded).toContain('$ playwriter -e "'); + expect(expanded).toContain("RESULT_ROW"); + }); + + it("keeps short single-line run_command titles verbatim without an echo row", () => { + const { component } = createHarness("run_command", { command: "pnpm test" }); + component.updateResult({ content: [{ type: "text", text: "ok" }], isError: false }, false); + component.setExpanded(true); + const rows = expectPhysicalRows(component, 80).filter((row) => row.length > 0); + expect(rows[0]).toBe("● run_command(pnpm test)"); + expect(rows.join("\n")).not.toContain("$ pnpm test"); + }); + it.each([ ["list_directory", { path: "src/one\ntwo\tdir" }], ["read_file", { path: "src/one\rtwo.txt", start_line: 1, end_line: 2 }], From 1ccfa0819d5ed7dc90c2cac19b695f0d8a67e6b2 Mon Sep 17 00:00:00 2001 From: ZouR-Ma <2605315944@qq.com> Date: Wed, 21 Oct 2026 10:57:21 +0800 Subject: [PATCH 2/2] fix(cli): size command previews to terminal width --- .../src/ui/view/transcript/tool-execution.ts | 51 +++++++++---------- apps/cli/test/step-tool-row-contract.test.ts | 41 ++++++++++++--- 2 files changed, 59 insertions(+), 33 deletions(-) diff --git a/apps/cli/src/ui/view/transcript/tool-execution.ts b/apps/cli/src/ui/view/transcript/tool-execution.ts index 54d1bb9e..9949060e 100644 --- a/apps/cli/src/ui/view/transcript/tool-execution.ts +++ b/apps/cli/src/ui/view/transcript/tool-execution.ts @@ -33,8 +33,6 @@ import type { StepToolSpinnerState } from "./step-spinner.ts"; const FALLBACK_PREVIEW_LINES = 10; const STEP_COLLAPSED_LINES = 5; const STEP_COLLAPSED_DIFF_LINES = 8; -/** Maximum command characters shown in a `run_command(...)` Step title. */ -const STEP_COMMAND_PREVIEW_CHARS = 50; /** The legacy Step names whose visible headers differ from Pi's native names. */ const STEP_TOOL_NAMES = new Set([ @@ -224,20 +222,12 @@ function readNumberDetail(details: unknown, ...keys: string[]): number | undefin return readNumberArg(details, ...keys); } -/** - * One-line command preview for the Step title. Inline scripts and heredocs - * would otherwise fill the transcript with the command itself; line breaks - * (with their indentation) fold to one space and the text is cut to the first - * STEP_COMMAND_PREVIEW_CHARS code points. - */ -function formatStepCommandPreview(command: string): string { - const singleLine = command - .replace(/\s*[\r\n]+\s*/gu, " ") - .replace(/\t/gu, " ") - .trim(); - const chars = Array.from(singleLine); - if (chars.length <= STEP_COMMAND_PREVIEW_CHARS) return singleLine; - return `${chars.slice(0, STEP_COMMAND_PREVIEW_CHARS).join("")}…`; +/** Keep command details out of the compact title; expanded rows retain the original command. */ +function formatStepCommandPreview(command: string, width: number): string { + const lines = command.split(/\r\n|[\r\n]/u); + const firstLine = replaceTabs(lines[0] ?? ""); + const preview = lines.length > 1 ? `${firstLine} …` : firstLine; + return truncateToWidth(preview, Math.max(0, width), "…", false); } /** @@ -246,7 +236,7 @@ function formatStepCommandPreview(command: string): string { * own labels (`$ command`, `edit path`, ...); the Step model-facing contract * uses the names below, so changing the title here is presentation-only. */ -function formatStepCallTitle(toolName: string, args: unknown, cwd: string): string | undefined { +function formatStepCallTitle(toolName: string, args: unknown, cwd: string, width: number): string | undefined { if (!STEP_TOOL_NAMES.has(toolName)) return undefined; const title = theme.fg("toolTitle", theme.bold(toolName)); @@ -259,7 +249,8 @@ function formatStepCallTitle(toolName: string, args: unknown, cwd: string): stri switch (toolName) { case "run_command": - argument = command === undefined ? undefined : formatStepCommandPreview(command); + argument = + command === undefined ? undefined : formatStepCommandPreview(command, width - visibleWidth(title) - 2); break; case "find_files": argument = pattern; @@ -743,14 +734,10 @@ export class ToolExecutionComponent extends Container { if (callLines.length === 0) callLines = [theme.fg("toolTitle", theme.bold(this.toolName))]; if (this.toolName === "run_command") { - // The native shell call rows only echo the command that the Step title - // already previews. Show that echo on expand, and only when the preview - // had to shorten or reflow the command; it is rendered at body width so - // the connector does not clip it. - const command = readStringArg(this.args, "command"); - const showCommand = this.expanded && command !== undefined && formatStepCommandPreview(command) !== command; + // Render the full command at body width only on expansion, separately + // from its compact header so native command rows never leak into output. const commandRows = - showCommand && this.stepCallComponent ? trimRenderedRows(this.stepCallComponent.render(bodyWidth)) : []; + this.expanded && this.stepCallComponent ? trimRenderedRows(this.stepCallComponent.render(bodyWidth)) : []; resultLines = [...commandRows, ...resultLines]; callLines = [callLines[0]!]; } else if (callLines.length > 1) { @@ -761,7 +748,12 @@ export class ToolExecutionComponent extends Container { resultLines = [...callLines.slice(1), ...resultLines]; callLines = [callLines[0]!]; } - const stepTitle = formatStepCallTitle(this.toolName, this.args, this.cwd); + const stepTitle = formatStepCallTitle( + this.toolName, + this.args, + this.cwd, + safeWidth - 2 - visibleWidth(this.stepElapsedSuffix()), + ); if (stepTitle !== undefined) { // Keep the native renderer's body/state, but use the exact Step tool // contract in the visible invocation row. @@ -823,7 +815,12 @@ export class ToolExecutionComponent extends Container { if (this.toolName === "edit" || this.toolName === "edit_file") { lines = lines.map((line) => stripToolShellBackground(removeLeadingVisibleSpaces(line, 1))); } - const stepTitle = formatStepCallTitle(this.toolName, this.args, this.cwd); + const stepTitle = formatStepCallTitle( + this.toolName, + this.args, + this.cwd, + safeWidth - 2 - visibleWidth(this.stepElapsedSuffix()), + ); if (stepTitle !== undefined && lines.length > 0) { lines[0] = stepTitle; } diff --git a/apps/cli/test/step-tool-row-contract.test.ts b/apps/cli/test/step-tool-row-contract.test.ts index f3b62b9d..9aac03e7 100644 --- a/apps/cli/test/step-tool-row-contract.test.ts +++ b/apps/cli/test/step-tool-row-contract.test.ts @@ -83,10 +83,7 @@ describe("Step projected rows", () => { component.updateResult({ content: [{ type: "text", text: "RESULT_ROW" }], isError: false }, false); const collapsed = expectPhysicalRows(component, 200).filter((row) => row.length > 0); - const preview = Array.from(script.replace(/\s*[\r\n]+\s*/gu, " ")) - .slice(0, 50) - .join(""); - expect(collapsed[0]).toBe(`● run_command(${preview}…)`); + expect(collapsed[0]).toBe('● run_command(playwriter -e " …)'); expect(collapsed.join("\n")).not.toContain("FULL_COMMAND_TAIL"); expect(collapsed.join("\n")).not.toContain("timeout 120s"); expect(collapsed[1]).toBe(" └ RESULT_ROW"); @@ -98,13 +95,45 @@ describe("Step projected rows", () => { expect(expanded).toContain("RESULT_ROW"); }); - it("keeps short single-line run_command titles verbatim without an echo row", () => { + it("shows the full short command on expand and removes it on collapse", () => { const { component } = createHarness("run_command", { command: "pnpm test" }); component.updateResult({ content: [{ type: "text", text: "ok" }], isError: false }, false); component.setExpanded(true); const rows = expectPhysicalRows(component, 80).filter((row) => row.length > 0); expect(rows[0]).toBe("● run_command(pnpm test)"); - expect(rows.join("\n")).not.toContain("$ pnpm test"); + expect(rows.join("\n")).toContain("$ pnpm test"); + component.setExpanded(false); + expect(expectPhysicalRows(component, 80).join("\n")).not.toContain("$ pnpm test"); + }); + + it.each([40, 80, 200])("sizes command previews to width %s while retaining elapsed time", (width) => { + const command = `echo ${"界面测试".repeat(40)} TAIL_MARKER`; + const spinner: StepToolSpinnerState = { frame: "⠋", elapsedSeconds: () => 12 }; + const { component } = createHarness("run_command", { command }, width, 44, spinner); + const header = expectPhysicalRows(component, width)[0]!; + expect(header).toMatch(/^⠋ run_command\(/u); + expect(header).toMatch(/…\) · 12s$/u); + expect(visibleWidth(header)).toBeGreaterThan(width - 3); + component.setExpanded(true); + const expanded = expectPhysicalRows(component, width).join("\n"); + expect(expanded).toContain("TAIL_MARKER"); + component.setExpanded(false); + expect(expectPhysicalRows(component, width)[0]).toBe(header); + }); + + it("does not limit a fitting single-line command to 50 characters", () => { + const command = `echo ${"x".repeat(80)}`; + const { component } = createHarness("run_command", { command }); + expect(expectPhysicalRows(component, 200)[0]).toBe(`⠋ run_command(${command})`); + }); + + it.each(["\n", "\r\n", "\r"])("previews only the first command line for %j separators", (separator) => { + const command = `echo first${separator}echo HIDDEN_COMMAND`; + const args = { command }; + const { component } = createHarness("run_command", args); + expect(expectPhysicalRows(component, 80)[0]).toBe("⠋ run_command(echo first …)"); + expect(expectPhysicalRows(component, 80).join("\n")).not.toContain("HIDDEN_COMMAND"); + expect(args.command).toBe(command); }); it.each([