diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3875421f3..5cdc4130c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -17,6 +17,7 @@ - Moved compaction, branch summarization, and retry spinners into the editor border alongside the working indicator. Custom editors use the same embedding opt-in for all status spinners. - Enabled strict-prefer JSON-schema sampling by default for built-in `read`, `bash`, `powershell`, `edit`, and `write` tools, without requiring `PI_EXPERIMENTAL`. Extensions can re-register tool definitions with `constrainedSampling: false`. +- Formatted Bash and PowerShell tool durations of at least one minute as minutes and seconds, with hours when needed ([#9628](https://github.com/earendil-works/pi/issues/9628)). ### Fixed diff --git a/packages/coding-agent/src/core/tools/renderers/bash.ts b/packages/coding-agent/src/core/tools/renderers/bash.ts index 01e170a82..853db078e 100644 --- a/packages/coding-agent/src/core/tools/renderers/bash.ts +++ b/packages/coding-agent/src/core/tools/renderers/bash.ts @@ -30,7 +30,15 @@ class BashResultRenderComponent extends Container { }; } function formatDuration(ms: number): string { - return `${(ms / 1000).toFixed(1)}s`; + const seconds = ms / 1000; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + + const totalSeconds = Math.floor(seconds); + const minutes = Math.floor(totalSeconds / 60); + const remainder = totalSeconds % 60; + if (minutes < 60) return `${minutes}m ${remainder}s`; + + return `${Math.floor(minutes / 60)}h ${minutes % 60}m ${remainder}s`; } function formatShellCall(args: { command?: string; timeout?: number } | undefined, prompt: string): string { const command = str(args?.command); diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 5ebec2e8a..a8d06350e 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -1,7 +1,7 @@ import { join, resolve } from "node:path"; import { Text, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui"; import { Type } from "typebox"; -import { beforeAll, describe, expect, test } from "vitest"; +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; import { getReadmePath } from "../src/config.ts"; import type { ToolDefinition } from "../src/core/extensions/types.ts"; import { type BashOperations, createBashToolDefinition } from "../src/core/tools/bash.ts"; @@ -36,6 +36,10 @@ describe("ToolExecutionComponent parity", () => { initTheme("dark"); }); + afterEach(() => { + vi.useRealTimers(); + }); + test("stacks custom call and result renderers like the old implementation", () => { const toolDefinition: ToolDefinition = { ...createBaseToolDefinition(), @@ -192,6 +196,47 @@ describe("ToolExecutionComponent parity", () => { expect(rendered).not.toContain("[Showing lines 2001-4000 of 4000. Full output:"); }); + // Issue #9628: keep short durations precise and make long shell durations readable. + test.each([ + { ms: 0, formatted: "0.0s" }, + { ms: 4_200, formatted: "4.2s" }, + { ms: 59_900, formatted: "59.9s" }, + { ms: 59_999, formatted: "60.0s" }, + { ms: 60_000, formatted: "1m 0s" }, + { ms: 90_900, formatted: "1m 30s" }, + { ms: 1_592_200, formatted: "26m 32s" }, + { ms: 3_599_999, formatted: "59m 59s" }, + { ms: 3_600_000, formatted: "1h 0m 0s" }, + { ms: 7_384_900, formatted: "2h 3m 4s" }, + ])("bash renderer formats $ms ms as $formatted while running and after completion", ({ ms, formatted }) => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const component = new ToolExecutionComponent( + "bash", + "tool-bash-duration", + { command: "long-running-command" }, + {}, + createBashToolDefinition(process.cwd(), { exposeSessionEnvironment: false }), + createFakeTui(), + process.cwd(), + ); + component.markExecutionStarted(); + component.updateResult({ content: [], isError: false }, true); + + vi.advanceTimersByTime(ms); + component.invalidate(); + const running = stripAnsi(component.render(120).join("\n")); + + component.updateResult({ content: [], isError: false }, false); + const completed = stripAnsi(component.render(120).join("\n")); + + vi.advanceTimersByTime(1_000); + component.invalidate(); + expect(stripAnsi(component.render(120).join("\n"))).toBe(completed); + expect(running).toContain(`Elapsed ${formatted}`); + expect(completed).toContain(`Took ${formatted}`); + }); + test("does not duplicate built-in headers when passed the active built-in definition", () => { const component = new ToolExecutionComponent( "read",