diff --git a/apps/cli/src/ui/view/transcript/tool-execution.ts b/apps/cli/src/ui/view/transcript/tool-execution.ts index 8bf878e32..9949060e1 100644 --- a/apps/cli/src/ui/view/transcript/tool-execution.ts +++ b/apps/cli/src/ui/view/transcript/tool-execution.ts @@ -222,13 +222,21 @@ function readNumberDetail(details: unknown, ...keys: string[]): number | undefin return readNumberArg(details, ...keys); } +/** 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); +} + /** * 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 * 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)); @@ -241,7 +249,8 @@ function formatStepCallTitle(toolName: string, args: unknown, cwd: string): stri switch (toolName) { case "run_command": - argument = command; + argument = + command === undefined ? undefined : formatStepCommandPreview(command, width - visibleWidth(title) - 2); break; case "find_files": argument = pattern; @@ -724,15 +733,27 @@ 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") { + // 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 = + this.expanded && 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]!]; } - 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. @@ -794,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 9f1e10ca3..9aac03e7a 100644 --- a/apps/cli/test/step-tool-row-contract.test.ts +++ b/apps/cli/test/step-tool-row-contract.test.ts @@ -68,6 +68,74 @@ 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); + 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"); + + 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("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")).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([ ["list_directory", { path: "src/one\ntwo\tdir" }], ["read_file", { path: "src/one\rtwo.txt", start_line: 1, end_line: 2 }],