mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): show elapsed and total time on codemode rows
The codemode row shows "Elapsed" while the script runs and "Took" once it finishes, like bash rows. The timer logic moves from the bash renderer to render-utils, and scripts send an initial update so the timer starts before the first nested call. closes #10708
This commit is contained in:
parent
11f9911450
commit
014873d7d5
6 changed files with 94 additions and 54 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof setInterval>;
|
||||
}
|
||||
|
||||
/** Start the row's clock once execution starts. Call from `renderCall`. */
|
||||
export function startToolTimer(context: ToolRenderContext<ToolTimerState>): 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<ToolTimerState>, 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]");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ToolDefinition<any, any>, "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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue