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
44 changes: 35 additions & 9 deletions apps/cli/src/ui/view/transcript/tool-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand Down
68 changes: 68 additions & 0 deletions apps/cli/test/step-tool-row-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
Expand Down
Loading