diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3dd0ddd47..cfe30b837 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -14,6 +14,7 @@ - Added `reachesTools` to tool definitions, so tools that reach other tools (such as `codemode` and `tool_search`) declare which tools a call may reach; the MCP extension uses it to wait for the servers a call needs and to decide whether a replacement `codemode` or `tool_search` tool can reach MCP tools - Added `aliases` to tool namespaces; codemode's `describeNamespace()` and the `namespace` option of `searchTools()` match them, and MCP namespaces use the server name - Added per-key OpenRouter model lists: with an OpenRouter key, Pi lists only the chat models that key may use, with the key's context size, max output, and prices, including guardrails and regional endpoints set as the base URL in `models.json`. The list is saved and used offline ([#10353](https://github.com/earendil-works/pi/issues/10353)) +- Added elapsed time while a codemode script runs and its total time once finished to the codemode tool row, like bash rows ([#10708](https://github.com/earendil-works/pi/issues/10708)) ### Changed diff --git a/packages/coding-agent/src/core/tools/render-utils.ts b/packages/coding-agent/src/core/tools/render-utils.ts index 77655f736..6255beb44 100644 --- a/packages/coding-agent/src/core/tools/render-utils.ts +++ b/packages/coding-agent/src/core/tools/render-utils.ts @@ -6,6 +6,7 @@ import type { Theme } from "../../modes/interactive/theme/theme.ts"; import { stripAnsi } from "../../utils/ansi.ts"; import { resolvePath } from "../../utils/paths.ts"; import { sanitizeBinaryOutput } from "../../utils/shell.ts"; +import type { ToolRenderContext } from "../extensions/types.ts"; export function shortenPath(path: unknown): string { if (typeof path !== "string") return ""; @@ -95,6 +96,60 @@ export function formatToolCallWithArgs(title: string, args: unknown, theme: Them return `${header} ${theme.fg("muted", preview)}`; } +/** Duration as `4.2s` below a minute, then `1m 30s` and `2h 3m 4s`. */ +export function formatToolDuration(ms: number): string { + 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`; +} + +/** Renderer state of {@link startToolTimer} and {@link toolTimerText}, kept in `ToolRenderContext.state`. */ +interface ToolTimerState { + startedAt?: number; + endedAt?: number; + interval?: ReturnType; +} + +/** Start the row's clock once execution starts. Call from `renderCall`. */ +export function startToolTimer(context: ToolRenderContext): void { + const state = context.state; + if (context.executionStarted && state.startedAt === undefined) { + state.startedAt = Date.now(); + state.endedAt = undefined; + } +} + +/** + * The row's timing line, from `renderResult`: `Elapsed 4.2s` while the result is partial, redrawn + * every second, and `Took 4.2s` once it is final. Undefined when the row has no start time and no + * recorded duration. + */ +export function toolTimerText(context: ToolRenderContext, isPartial: boolean): string | undefined { + const state = context.state; + if (state.startedAt !== undefined && isPartial && !state.interval) { + state.interval = setInterval(() => context.invalidate(), 1000); + } + if (!isPartial || context.isError) { + state.endedAt ??= Date.now(); + if (state.interval) { + clearInterval(state.interval); + state.interval = undefined; + } + } + // A final result's recorded duration wins: it is monotonic and survives reloads. The renderer's own clock is the + // fallback for live progress and for results stored without one. + if (!isPartial && context.durationMs !== undefined) return `Took ${formatToolDuration(context.durationMs)}`; + if (state.startedAt === undefined) return undefined; + const endTime = state.endedAt ?? Date.now(); + return `${isPartial ? "Elapsed" : "Took"} ${formatToolDuration(endTime - state.startedAt)}`; +} + export function invalidArgText(theme: Theme): string { return theme.fg("error", "[invalid arg]"); } diff --git a/packages/coding-agent/src/core/tools/renderers/bash.ts b/packages/coding-agent/src/core/tools/renderers/bash.ts index 1c47224b1..4631b6b2c 100644 --- a/packages/coding-agent/src/core/tools/renderers/bash.ts +++ b/packages/coding-agent/src/core/tools/renderers/bash.ts @@ -12,22 +12,11 @@ import { VisualLinePreview } from "../../../modes/interactive/components/visual- import { theme } from "../../../modes/interactive/theme/theme.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../../extensions/types.ts"; import type { BashToolDetails } from "../bash.ts"; -import { getTextOutput, invalidArgText, str } from "../render-utils.ts"; +import { getTextOutput, invalidArgText, startToolTimer, str, toolTimerText } from "../render-utils.ts"; import { DEFAULT_MAX_BYTES, formatSize } from "../truncate.ts"; const BASH_PREVIEW_LINES = 5; export const BASH_UPDATE_THROTTLE_MS = 100; -function formatDuration(ms: number): string { - 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); const timeout = args?.timeout as number | undefined; @@ -43,9 +32,7 @@ function rebuildBashResultRenderComponent( }, options: ToolRenderResultOptions, showImages: boolean, - startedAt: number | undefined, - endedAt: number | undefined, - durationMs: number | undefined, + timerText: string | undefined, ): void { component.clear(); @@ -99,52 +86,22 @@ function rebuildBashResultRenderComponent( component.addChild(new Text(`\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, 0, 0)); } - // A final result's recorded duration wins: it is monotonic and survives reloads. The renderer's own clock is the - // fallback for live progress and for results stored without one. - if (!options.isPartial && durationMs !== undefined) { - component.addChild(new Text(`\n${theme.fg("muted", `Took ${formatDuration(durationMs)}`)}`, 0, 0)); - } else if (startedAt !== undefined) { - const label = options.isPartial ? "Elapsed" : "Took"; - const endTime = endedAt ?? Date.now(); - component.addChild(new Text(`\n${theme.fg("muted", `${label} ${formatDuration(endTime - startedAt)}`)}`, 0, 0)); - } + if (timerText) component.addChild(new Text(`\n${theme.fg("muted", timerText)}`, 0, 0)); } /** Shell renderers are shared by bash and powershell, which differ only in the prompt they display. */ export function createShellRenderers(prompt: string): Pick, "renderCall" | "renderResult"> { return { renderCall(args, _theme, context) { - const state = context.state; - if (context.executionStarted && state.startedAt === undefined) { - state.startedAt = Date.now(); - state.endedAt = undefined; - } + startToolTimer(context); const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); text.setText(formatShellCall(args as { command?: string; timeout?: number } | undefined, prompt)); return text; }, renderResult(result, options, _theme, context) { - const state = context.state; - if (state.startedAt !== undefined && options.isPartial && !state.interval) { - state.interval = setInterval(() => context.invalidate(), 1000); - } - if (!options.isPartial || context.isError) { - state.endedAt ??= Date.now(); - if (state.interval) { - clearInterval(state.interval); - state.interval = undefined; - } - } + const timerText = toolTimerText(context, options.isPartial); const component = (context.lastComponent as Container | undefined) ?? new Container(); - rebuildBashResultRenderComponent( - component, - result as any, - options, - context.showImages, - state.startedAt, - state.endedAt, - context.durationMs, - ); + rebuildBashResultRenderComponent(component, result as any, options, context.showImages, timerText); component.invalidate(); return component; }, diff --git a/packages/coding-agent/src/extensions/codemode/execute.ts b/packages/coding-agent/src/extensions/codemode/execute.ts index 68f896a3b..fb0c67e34 100644 --- a/packages/coding-agent/src/extensions/codemode/execute.ts +++ b/packages/coding-agent/src/extensions/codemode/execute.ts @@ -458,6 +458,8 @@ export async function executeCodemode( const snapshot = (): CodemodeToolDetails => ({ calls: calls.map((call) => ({ ...call })) }); const publish = () => onUpdate?.({ content: [], details: snapshot() }); + // Show the script as running before its first nested call, so the row's timer starts. + publish(); const callable = ctx ? getCodemodeCallableTools(ctx.tools) : []; // ALL_TOOLS entries carry the declaration. diff --git a/packages/coding-agent/src/extensions/codemode/renderer.ts b/packages/coding-agent/src/extensions/codemode/renderer.ts index 651e8d7a9..8d2937351 100644 --- a/packages/coding-agent/src/extensions/codemode/renderer.ts +++ b/packages/coding-agent/src/extensions/codemode/renderer.ts @@ -3,13 +3,13 @@ * * The call shows the script; the result lists the nested tool calls with their status as they * run and the cost of its model calls, followed by the script output without the "Script completed" - * header. Nested calls are not - * separate tool rows because they never reach the model as tool calls. + * header, and the time the script has been running (`Elapsed`) or took (`Took`), like bash rows. + * Nested calls are not separate tool rows because they never reach the model as tool calls. */ import { Container, Spacer, Text } from "@earendil-works/pi-tui"; import type { ToolDefinition } from "../../core/extensions/types.ts"; -import { getTextOutput, replaceTabs, str } from "../../core/tools/render-utils.ts"; +import { getTextOutput, replaceTabs, startToolTimer, str, toolTimerText } from "../../core/tools/render-utils.ts"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; import { VisualLinePreview } from "../../modes/interactive/components/visual-truncate.ts"; import { highlightCode, type Theme } from "../../modes/interactive/theme/theme.ts"; @@ -67,6 +67,7 @@ export const codemodeRenderers: Pick< "renderCall" | "renderResult" > = { renderCall(args, theme, context) { + startToolTimer(context); // The code includes the `// @options:` line, so options show as part of the script. const code = str((args as { code?: unknown } | undefined)?.code); const title = theme.fg("toolTitle", theme.bold("codemode")); @@ -93,6 +94,7 @@ export const codemodeRenderers: Pick< return component; }, renderResult(result, options, theme, context) { + const timerText = toolTimerText(context, options.isPartial); const component = (context.lastComponent as Container | undefined) ?? new Container(); component.clear(); const calls = result.details?.calls ?? []; @@ -145,6 +147,10 @@ export const codemodeRenderers: Pick< if (fullOutputPath) component.addChild(new Text(theme.fg("muted", `Full output: ${fullOutputPath}`), 0, 0)); } } + if (timerText) { + component.addChild(new Spacer(1)); + component.addChild(new Text(theme.fg("muted", timerText), 0, 0)); + } return component; }, }; diff --git a/packages/coding-agent/test/codemode-renderer.test.ts b/packages/coding-agent/test/codemode-renderer.test.ts index dfda70145..7c802da71 100644 --- a/packages/coding-agent/test/codemode-renderer.test.ts +++ b/packages/coding-agent/test/codemode-renderer.test.ts @@ -1,10 +1,11 @@ import { stripVTControlCharacters } from "node:util"; import type { AgentToolResult } from "@earendil-works/pi-agent-core"; -import type { Component } from "@earendil-works/pi-tui"; -import { beforeAll, describe, expect, it } from "vitest"; +import type { Component, TUI } from "@earendil-works/pi-tui"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { ToolRenderContext } from "../src/core/extensions/types.ts"; import { codemodeRenderers } from "../src/extensions/codemode/renderer.ts"; import type { CodemodeToolDetails } from "../src/extensions/codemode/tool.ts"; +import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.ts"; import { initTheme, theme } from "../src/modes/interactive/theme/theme.ts"; function render( @@ -44,6 +45,7 @@ function render( describe("codemode renderer", () => { beforeAll(() => initTheme("dark")); + afterEach(() => vi.useRealTimers()); it("hides the script header and shows the output", () => { const text = render({ @@ -108,4 +110,21 @@ describe("codemode renderer", () => { expect(lines[5]).toMatch(/^\.\.\. \(15 more lines,/); expect(lines[6]).toBe("Full output: /tmp/out.txt"); }); + + // #10708 + it("shows the elapsed time while the script runs and the total once it finished", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const tui = { requestRender: () => {} } as unknown as TUI; + const component = new ToolExecutionComponent("codemode", "call", { code: "" }, {}, codemodeRenderers, tui, "/"); + const text = () => stripVTControlCharacters(component.render(120).join("\n")); + component.markExecutionStarted(); + component.updateResult({ content: [], details: { calls: [] }, isError: false }, true); + vi.advanceTimersByTime(3_000); + expect(text()).toContain("Elapsed 3.0s"); + + component.updateResult({ content: [], details: { calls: [] }, isError: false, durationMs: 5_100 }, false); + expect(text()).toContain("Took 5.1s"); + expect(text()).not.toContain("Elapsed"); + }); });