@pi-archimedes/ui 2.10.4 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,6 +5,7 @@ TUI enhancements for the Pi Archimedes monorepo.
5
5
  ## Features
6
6
 
7
7
  ### Bash Tool Styling
8
+ - **Pi's tool, our row**: `bash` stays pi's own built-in tool — this package never registers it. It installs one `pi.registerToolRenderer()` resolver that decides how calls to `bash` are drawn, so pi keeps the executor, description, reachability and activation of that tool.
8
9
  - **Collapsed/Expanded Views**: Toggleable tool execution details.
9
10
  - **Live Timer**: Track tool execution duration.
10
11
  - **Status Indicators**:
@@ -14,12 +15,12 @@ TUI enhancements for the Pi Archimedes monorepo.
14
15
 
15
16
  ### Codemode Tool Styling
16
17
  - **Styling follows the `toolStyle` master switch**:
17
- - **Minimal** (default): the override is registered — the collapsed row is the `codemode` header plus the nested tool calls the script made (last five, with an expand hint when there are more) — no script dump, no summary line. A script that made no calls shows a single `<glyph> <duration>` line instead.
18
- - **Native**: the override is not registered at all — pi's native codemode rendering stands (and no built-in-takeover startup notice).
18
+ - **Minimal** (default): the resolver answers with our row — the collapsed row is the `codemode` header plus the nested tool calls the script made (last five, with an expand hint when there are more) — no script dump, no summary line. A script that made no calls shows a single `<glyph> <duration>` line instead.
19
+ - **Native**: the resolver answers `next()` — pi's own codemode rendering stands.
19
20
  - **Expanded View**: The syntax-highlighted script, every nested call (status glyph, args, duration, and cost for `models.*` calls, with a `Model calls: $total` line when several were priced), the script output without the executor's `Script completed/failed` header (image blocks show a `[Image: …]` indicator — the renderer is text-only), the full-output path when truncated, and the timing + final status.
20
21
  - **Live Timer**: In-flight calls show the `…` running glyph and update every second.
21
- - **Native Behaviour Preserved**: The override re-registers pi's own `codemode` tool (executor, dynamic description, activation semantics all intact) and replaces only the presentation — so it takes over pi's replaceable built-in `codemode` extension (pi prints a one-line startup notice, as it does for any extension that replaces a built-in).
22
- - **Graceful Fallback**: On pi versions without the `codemode` extension the override is a no-op and pi's native rendering stands in.
22
+ - **Native Behaviour Preserved**: `codemode` is pi's own built-in extension and stays that way — nothing in this package registers it, so pi keeps the QuickJS executor, the dynamic description, the per-tool prompt guidelines and `defaultActive: false`, and no "built-in extension was not loaded" startup notice appears because nothing was replaced. Only the presentation is ours (ADR 0029).
23
+ - **Graceful Fallback**: The styling is installed behind `typeof pi.registerToolRenderer === "function"` (pi 1.0.1+), so a host without that API gets pi's own rendering for `bash` and `codemode` alike; a session with no `codemode` tool is simply never asked about it.
23
24
 
24
25
  ### Editor
25
26
  - **HephaestusEditor**: Advanced text editing capabilities.
@@ -32,7 +33,9 @@ TUI enhancements for the Pi Archimedes monorepo.
32
33
  - **Theme**: Unified styling for thinking blocks.
33
34
 
34
35
  ### Startup
35
- - **Splash Logo Reveal**: Engaging animations during initialization.
36
+ - **The splash is opt-in, and off by default.** Out of the box you get **pi's own startup screen** — its header, its key hints, its loaded-resource listing. `startupSplash: true` installs the Archimedes splash in pi's header slot instead; with it off nothing is installed, so pi's `quietStartup` keeps working exactly as it does with no Archimedes loaded.
37
+ - **Splash Logo Reveal** (`startupSplash: true`): An animated logo reveal with the running Pi version and the session's model scope (`ctx.scopedModels` — the set `/scoped-models` shows), drawn in pi's own row style so it reads as one document with the listing below it.
38
+ - **Honours Pi's `quietStartup`** (when the splash is on): `false` = splash with its details, `"header"` = splash alone, `true` = Pi's own quiet startup (the splash hands the header back). With the splash off, that setting is pi's alone to act on. Nothing is scraped from Pi's output to decide any of this, and the splash makes no network request — Pi's own update check is the only one.
36
39
 
37
40
  ## Settings (`archimedes.ui`)
38
41
 
@@ -47,8 +50,9 @@ TUI enhancements for the Pi Archimedes monorepo.
47
50
  | `codeUnindent` | boolean | `true` | Strip common indentation from code blocks |
48
51
  | `labelText` | string | `"Thinking..."` | Text shown before thinking blocks |
49
52
  | `labelColor` | string | `"255,215,0"` | RGB color for the thinking label (e.g. `255,215,0`) |
50
- | `animationStyle` | "diagonal" \| "top-right" \| "bottom-left" \| "bottom-right" \| "center-out" \| "wave" \| "horizontal" \| "vertical" \| "vertical-up" | `"vertical-up"` | Startup logo animation |
51
- | `colorfulLogo` | boolean | `false` | Use pi's brand colors for the splash logo |
53
+ | `startupSplash` | boolean | `false` | Install the Archimedes startup splash (off = pi's own startup screen) |
54
+ | `animationStyle` | "diagonal" \| "top-right" \| "bottom-left" \| "bottom-right" \| "center-out" \| "wave" \| "horizontal" \| "vertical" \| "vertical-up" | `"vertical-up"` | Startup logo animation (applies when `startupSplash` is true) |
55
+ | `colorfulLogo` | boolean | `false` | Use pi's brand colors for the splash logo (applies when `startupSplash` is true) |
52
56
  | `editorSpinBorder` | boolean | `true` | Animated spinner border on editor |
53
57
  | `editorSpinSpeed` | "slow" \| "normal" \| "fast" | `"normal"` | Editor border spinner speed |
54
58
  | `editorSpinStyle` | "typing" \| "pulse" \| "rain" \| "cascade" \| "columns" \| "wave-rows" \| "diagonal-swipe" \| "sparkle" \| "pendulum" \| "marquee" | `"pendulum"` | Editor spinner style |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/ui",
3
- "version": "2.10.4",
3
+ "version": "2.11.0",
4
4
  "description": "Terminal UI enhancements, styled bash tool rendering, and visual presentation components for Pi",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -28,17 +28,17 @@
28
28
  "./config": "./src/config.ts"
29
29
  },
30
30
  "dependencies": {
31
- "@pi-archimedes/core": "2.10.4"
31
+ "@pi-archimedes/core": "2.11.0"
32
32
  },
33
33
  "devDependencies": {
34
- "@earendil-works/pi-ai": "^0.87.0",
35
- "@earendil-works/pi-coding-agent": "^0.87.0",
36
- "@earendil-works/pi-tui": "^0.87.0",
34
+ "@earendil-works/pi-ai": "1.2.0",
35
+ "@earendil-works/pi-coding-agent": "1.2.0",
36
+ "@earendil-works/pi-tui": "1.2.0",
37
37
  "typescript": "^6.0.0"
38
38
  },
39
39
  "peerDependencies": {
40
- "@earendil-works/pi-ai": ">=0.1.0",
41
- "@earendil-works/pi-coding-agent": ">=0.1.0",
42
- "@earendil-works/pi-tui": ">=0.1.0"
40
+ "@earendil-works/pi-ai": ">=1.2.0",
41
+ "@earendil-works/pi-coding-agent": ">=1.2.0",
42
+ "@earendil-works/pi-tui": ">=1.2.0"
43
43
  }
44
44
  }
package/src/bash/index.ts CHANGED
@@ -3,8 +3,6 @@ export {
3
3
  renderBashResult,
4
4
  formatBashCommand,
5
5
  formatDuration,
6
- clearActiveBashIntervals,
7
6
  TruncatedTextComponent,
8
7
  type BashRendererState,
9
8
  } from "./renderer.js";
10
- export { registerBashToolOverride } from "./tool.js";
@@ -7,7 +7,6 @@ import {
7
7
  formatBashCommand,
8
8
  renderBashCall,
9
9
  renderBashResult,
10
- clearActiveBashIntervals,
11
10
  TruncatedTextComponent,
12
11
  } from "./renderer.js";
13
12
 
@@ -316,7 +315,7 @@ describe("renderBashResult - Live timer lifecycle", () => {
316
315
  expect(context.state.endedAt).toBeDefined();
317
316
  });
318
317
 
319
- it("clears active bash intervals when clearActiveBashIntervals is called", () => {
318
+ it("stops the once-a-second repaint as soon as the result settles", () => {
320
319
  const context = {
321
320
  executionStarted: true,
322
321
  invalidate: vi.fn(),
@@ -326,14 +325,60 @@ describe("renderBashResult - Live timer lifecycle", () => {
326
325
  renderBashResult({}, { isPartial: true }, theme, context);
327
326
  expect(context.state.interval).toBeDefined();
328
327
 
329
- clearActiveBashIntervals();
328
+ renderBashResult({}, { isPartial: false }, theme, context);
330
329
  expect(context.state.interval).toBeUndefined();
331
- // Verify if the interval still triggers
332
- vi.advanceTimersByTime(1000);
330
+
331
+ vi.advanceTimersByTime(3000);
333
332
  expect(context.invalidate).not.toHaveBeenCalled();
334
333
  });
335
334
  });
336
335
 
336
+ describe("renderBashResult timing comes from the result", () => {
337
+ it("says how long the command took after a session reload, with no row state", () => {
338
+ // A row rebuilt from a stored session has never run in this process: all it
339
+ // has is the duration the result recorded.
340
+ const text = renderBashResult(
341
+ { content: [{ type: "text", text: "ok" }] },
342
+ { expanded: true, isPartial: false },
343
+ theme,
344
+ { durationMs: 4200, executionStarted: true, state: {} },
345
+ );
346
+ expect(content(text)).toContain("Took 4.2s");
347
+ });
348
+
349
+ it("shows the recorded duration, not the wall clock the row happened to span", () => {
350
+ const text = renderBashResult(
351
+ { content: [{ type: "text", text: "ok" }] },
352
+ { expanded: true, isPartial: false },
353
+ theme,
354
+ { durationMs: 1500, executionStarted: true, state: { startedAt: Date.now() - 90_000 } },
355
+ );
356
+ const out = content(text);
357
+ expect(out).toContain("Took 1.5s");
358
+ expect(out).not.toContain("1m 30s");
359
+ });
360
+
361
+ it("collapses to the recorded duration too", () => {
362
+ const text = renderBashResult(
363
+ { content: [{ type: "text", text: "ok" }] },
364
+ { expanded: false, isPartial: false },
365
+ theme,
366
+ { durationMs: 4200, state: {} },
367
+ );
368
+ expect(content(text)).toContain("[muted:4.2s]");
369
+ });
370
+
371
+ it("still counts up while the command runs", () => {
372
+ const text = renderBashResult(
373
+ { content: [{ type: "text", text: "" }] },
374
+ { expanded: true, isPartial: true },
375
+ theme,
376
+ { state: { startedAt: Date.now() - 2500 }, executionStarted: true },
377
+ );
378
+ expect(content(text)).toContain("Elapsed");
379
+ });
380
+ });
381
+
337
382
  describe("renderBashResult - Expanded view", () => {
338
383
  it("renders full command with $ prefix, output lines, duration, and ✓ Done on success", () => {
339
384
  const context = {
@@ -1,6 +1,6 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Text, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
3
- import { renderToolHeader } from "@pi-archimedes/core/tool-render";
3
+ import { renderToolHeader, toolRowDuration } from "@pi-archimedes/core/tool-render";
4
4
 
5
5
  export interface BashRendererState {
6
6
  startedAt?: number | undefined;
@@ -10,18 +10,6 @@ export interface BashRendererState {
10
10
  command?: string | undefined;
11
11
  }
12
12
 
13
- const activeStates = new Set<BashRendererState>();
14
-
15
- export function clearActiveBashIntervals(): void {
16
- for (const state of activeStates) {
17
- if (state.interval) {
18
- clearInterval(state.interval);
19
- state.interval = undefined;
20
- }
21
- }
22
- activeStates.clear();
23
- }
24
-
25
13
  /**
26
14
  * Format duration in milliseconds as:
27
15
  * - <X.X>s for under 10 seconds (e.g. "1.5s")
@@ -171,6 +159,7 @@ export function renderBashResult(
171
159
  const ctx = context as {
172
160
  executionStarted?: boolean;
173
161
  isError?: boolean;
162
+ durationMs?: number | undefined;
174
163
  state?: BashRendererState;
175
164
  args?: { command?: string; timeout?: number };
176
165
  invalidate?: () => void;
@@ -185,22 +174,22 @@ export function renderBashResult(
185
174
  state.startedAt = Date.now();
186
175
  }
187
176
 
188
- if (options.isPartial && !state.interval) {
189
- if (ctx?.invalidate) {
190
- state.interval = setInterval(() => {
191
- ctx.invalidate?.();
192
- }, 1000);
193
- activeStates.add(state);
194
- }
177
+ // While the result is partial the row repaints once a second so `Elapsed`
178
+ // moves. The interval lives on the row's own state and is cleared when the
179
+ // result settles — the same arrangement pi's rows use, and the reason there
180
+ // is no module-level registry of live rows to sweep on shutdown.
181
+ const settled = !options.isPartial || Boolean(ctx?.isError);
182
+ if (options.isPartial && !state.interval && ctx?.invalidate) {
183
+ state.interval = setInterval(() => {
184
+ ctx.invalidate?.();
185
+ }, 1000);
195
186
  }
196
-
197
- if (!options.isPartial || ctx?.isError) {
187
+ if (settled) {
198
188
  state.endedAt ??= Date.now();
199
189
  if (state.interval) {
200
190
  clearInterval(state.interval);
201
191
  state.interval = undefined;
202
192
  }
203
- activeStates.delete(state);
204
193
  }
205
194
 
206
195
  const res = result as {
@@ -219,10 +208,17 @@ export function renderBashResult(
219
208
  } | undefined;
220
209
 
221
210
  const isError = Boolean(ctx?.isError || res?.isError || result instanceof Error);
222
- const now = Date.now();
223
- const startTime = state.startedAt ?? (state.endedAt ?? now);
224
- const endTime = state.endedAt ?? now;
225
- const elapsed = Math.max(0, endTime - startTime);
211
+ // The duration the tool recorded wins over the row's own clock, so a
212
+ // reloaded session still says how long the command took.
213
+ const timing = toolRowDuration(
214
+ {
215
+ durationMs: ctx?.durationMs,
216
+ startedAt: state.startedAt,
217
+ endedAt: state.endedAt,
218
+ },
219
+ { isPartial: Boolean(options.isPartial), isError },
220
+ );
221
+ const elapsed = timing?.ms;
226
222
 
227
223
  const textComponent = getTextComponent(context);
228
224
 
@@ -241,7 +237,8 @@ export function renderBashResult(
241
237
  ? theme.fg("dim", ` (timeout: ${timeout}s)`)
242
238
  : "";
243
239
 
244
- const styledDuration = theme.fg("muted", formatDuration(elapsed));
240
+ const styledDuration =
241
+ elapsed === undefined ? "" : theme.fg("muted", formatDuration(elapsed));
245
242
 
246
243
  textComponent.setText(`${statusGlyph} ${styledDuration}${timeoutSuffix}`);
247
244
  return textComponent;
@@ -277,8 +274,9 @@ export function renderBashResult(
277
274
  );
278
275
  }
279
276
 
280
- const durationLabel = options.isPartial ? "Elapsed" : "Took";
281
- parts.push(theme.fg("muted", `${durationLabel} ${formatDuration(elapsed)}`));
277
+ if (elapsed !== undefined) {
278
+ parts.push(theme.fg("muted", `${timing!.label} ${formatDuration(elapsed)}`));
279
+ }
282
280
 
283
281
  if (!options.isPartial) {
284
282
  if (isError) {
@@ -1,9 +1,3 @@
1
- export {
2
- loadCodemodeModule,
3
- resolveCodemodeModuleFiles,
4
- findAgentPackageRoot,
5
- type CodemodeToolModule,
6
- } from "./loader.js";
7
1
  export {
8
2
  renderCodemodeCall,
9
3
  renderCodemodeResult,
@@ -11,10 +5,7 @@ export {
11
5
  formatCost,
12
6
  formatNestedCall,
13
7
  getCodemodeOutput,
14
- parseWallTimeMs,
15
- clearActiveCodemodeIntervals,
16
8
  type CodemodeNestedCall,
17
9
  type CodemodeToolDetails,
18
10
  type CodemodeRendererState,
19
11
  } from "./renderer.js";
20
- export { registerCodemodeToolOverride } from "./tool.js";
@@ -5,10 +5,8 @@ import {
5
5
  formatCost,
6
6
  formatNestedCall,
7
7
  getCodemodeOutput,
8
- parseWallTimeMs,
9
8
  renderCodemodeCall,
10
9
  renderCodemodeResult,
11
- clearActiveCodemodeIntervals,
12
10
  } from "./renderer.js";
13
11
 
14
12
  // Mock Text from @earendil-works/pi-tui while preserving real utility
@@ -170,29 +168,31 @@ describe("formatNestedCall", () => {
170
168
  });
171
169
  });
172
170
 
173
- describe("parseWallTimeMs", () => {
174
- it("parses the wall time from the result header", () => {
175
- expect(
176
- parseWallTimeMs([
177
- { type: "text", text: "Script completed\nWall time 0.4 seconds\nOutput:\n" },
178
- ]),
179
- ).toBe(400);
180
- });
181
-
182
- it("parses failed-script headers too", () => {
183
- expect(
184
- parseWallTimeMs([
185
- { type: "text", text: "Script failed\nWall time 12.5 seconds\nOutput:\n" },
186
- ]),
187
- ).toBe(12500);
171
+ describe("renderCodemodeResult timing comes from the result", () => {
172
+ it("says how long the script took after a session reload, with no row state", () => {
173
+ // A row rebuilt from a stored session has never run in this process, and the
174
+ // executor's wall-time header is stripped from the output anyway: the
175
+ // duration the result recorded is what the row has.
176
+ const out = renderCodemodeResult(
177
+ { content: [{ type: "text", text: "Script completed\nWall time 3.2 seconds\nOutput:\n" }] },
178
+ { expanded: true },
179
+ theme,
180
+ { durationMs: 3200, executionStarted: true, state: {} },
181
+ );
182
+ expect(text(out)).toContain("Took 3.2s");
188
183
  });
189
184
 
190
- it("returns undefined without a header or for non-text first blocks", () => {
191
- expect(parseWallTimeMs([{ type: "text", text: "plain output" }])).toBe(
192
- undefined,
185
+ it("shows the recorded duration, not the wall clock the row happened to span", () => {
186
+ const out = renderCodemodeResult(
187
+ { content: [] },
188
+ { expanded: true },
189
+ theme,
190
+ { durationMs: 250, executionStarted: true, state: { startedAt: Date.now() - 90_000 } },
193
191
  );
194
- expect(parseWallTimeMs([{ type: "image" }])).toBe(undefined);
195
- expect(parseWallTimeMs([])).toBe(undefined);
192
+ const outText = text(out);
193
+ expect(outText).toContain("Took 250ms");
194
+ expect(outText).not.toContain("1.5m");
195
+ expect(outText).not.toContain("90.0s");
196
196
  });
197
197
  });
198
198
 
@@ -286,7 +286,7 @@ describe("renderCodemodeCall", () => {
286
286
  });
287
287
 
288
288
  describe("renderCodemodeResult - Collapsed view (Minimal)", () => {
289
- it("renders the success glyph and the wall time from the header", () => {
289
+ it("renders the success glyph and the duration the script recorded", () => {
290
290
  const out = renderCodemodeResult(
291
291
  {
292
292
  content: [
@@ -299,7 +299,7 @@ describe("renderCodemodeResult - Collapsed view (Minimal)", () => {
299
299
  },
300
300
  { expanded: false },
301
301
  theme,
302
- {},
302
+ { durationMs: 400 },
303
303
  );
304
304
  expect(text(out)).toBe("[success:✓] [muted:400ms]");
305
305
  });
@@ -375,7 +375,7 @@ describe("renderCodemodeResult - Collapsed view (Minimal)", () => {
375
375
  },
376
376
  { expanded: false },
377
377
  theme,
378
- {},
378
+ { durationMs: 1000 },
379
379
  );
380
380
  expect(text(out)).toBe("[success:✓] [muted:1.0s]");
381
381
  });
@@ -406,7 +406,7 @@ describe("renderCodemodeResult - Collapsed view (Minimal)", () => {
406
406
  },
407
407
  { expanded: false },
408
408
  theme,
409
- { isError: true },
409
+ { isError: true, durationMs: 200 },
410
410
  );
411
411
  expect(text(out)).toBe("[error:✗] [muted:200ms]");
412
412
  });
@@ -521,7 +521,7 @@ describe("renderCodemodeResult - Live timer lifecycle", () => {
521
521
  expect(context.state.endedAt).toBeDefined();
522
522
  });
523
523
 
524
- it("clears active codemode intervals when clearActiveCodemodeIntervals is called", () => {
524
+ it("stops the once-a-second repaint as soon as the script settles", () => {
525
525
  const context = {
526
526
  executionStarted: true,
527
527
  invalidate: vi.fn(),
@@ -531,9 +531,10 @@ describe("renderCodemodeResult - Live timer lifecycle", () => {
531
531
  renderCodemodeResult({}, { isPartial: true }, theme, context);
532
532
  expect(context.state.interval).toBeDefined();
533
533
 
534
- clearActiveCodemodeIntervals();
534
+ renderCodemodeResult({}, { isPartial: false }, theme, context);
535
535
  expect(context.state.interval).toBeUndefined();
536
- vi.advanceTimersByTime(1000);
536
+
537
+ vi.advanceTimersByTime(3000);
537
538
  expect(context.invalidate).not.toHaveBeenCalled();
538
539
  });
539
540
 
@@ -616,7 +617,7 @@ describe("renderCodemodeResult - Expanded view", () => {
616
617
  },
617
618
  { expanded: true },
618
619
  theme,
619
- {},
620
+ { durationMs: 300 },
620
621
  );
621
622
  const outText = text(out);
622
623
 
@@ -645,7 +646,7 @@ describe("renderCodemodeResult - Expanded view", () => {
645
646
  },
646
647
  { expanded: true },
647
648
  theme,
648
- { isError: true },
649
+ { isError: true, durationMs: 100 },
649
650
  );
650
651
  const outText = text(out);
651
652
 
@@ -7,7 +7,7 @@ import {
7
7
  Text,
8
8
  type Component,
9
9
  } from "@earendil-works/pi-tui";
10
- import { renderToolHeader } from "@pi-archimedes/core/tool-render";
10
+ import { renderToolHeader, toolRowDuration } from "@pi-archimedes/core/tool-render";
11
11
 
12
12
  /** Nested-call args preview budget, collapsed (the native renderer uses 80). */
13
13
  const COLLAPSED_CALL_ARGS_CHARS = 80;
@@ -42,23 +42,6 @@ export interface CodemodeRendererState {
42
42
  interval?: NodeJS.Timeout | undefined;
43
43
  }
44
44
 
45
- const activeStates = new Set<CodemodeRendererState>();
46
-
47
- /**
48
- * Clear live-timer intervals of in-flight codemode rows. Called on
49
- * session_shutdown so a cancelled script does not keep invalidating a
50
- * component that no longer exists.
51
- */
52
- export function clearActiveCodemodeIntervals(): void {
53
- for (const state of activeStates) {
54
- if (state.interval) {
55
- clearInterval(state.interval);
56
- state.interval = undefined;
57
- }
58
- }
59
- activeStates.clear();
60
- }
61
-
62
45
  /**
63
46
  * Format a duration in milliseconds: `<X>ms` under a second, `<X.X>s`
64
47
  * otherwise (the native codemode renderer's format).
@@ -157,16 +140,17 @@ export function getCodemodeOutput(
157
140
  }
158
141
 
159
142
  /**
160
- * Track the script's wall clock for the collapsed row: start on the first
161
- * render after execution began, end when the result settles (or errors).
162
- * Mirrors the bash renderer's timer lifecycle.
143
+ * Keep the row's own clock for the live `Elapsed` count: start on the first
144
+ * render after execution began, stop the repaint when the result settles (or
145
+ * errors). The interval lives on the row's own state — pi's rows do the same —
146
+ * and there is no module-level registry of live rows to sweep on shutdown.
163
147
  *
164
148
  * `isPartial` means "still in flight": the CALL renderer passes `true`
165
149
  * (a call is in flight until its result settles) — passing `false` there
166
- * would record `endedAt` at call-render time and freeze the elapsed timer
167
- * for the rest of the script. The interval is (re)created only while the
168
- * result has not settled yet (`endedAt === undefined`), so a re-render of a
169
- * settled call never spawns a permanent interval.
150
+ * would record `endedAt` at call-render time and freeze the count for the rest
151
+ * of the script. The interval is (re)created only while the result has not
152
+ * settled yet (`endedAt === undefined`), so a re-render of a settled call never
153
+ * spawns a permanent interval.
170
154
  */
171
155
  function updateTiming(
172
156
  ctx: {
@@ -183,41 +167,22 @@ function updateTiming(
183
167
  if ((ctx?.executionStarted || isPartial) && state.startedAt === undefined) {
184
168
  state.startedAt = Date.now();
185
169
  }
186
- if (isPartial && !state.interval && state.endedAt === undefined) {
187
- if (ctx?.invalidate) {
188
- state.interval = setInterval(() => {
189
- ctx.invalidate?.();
190
- }, 1000);
191
- activeStates.add(state);
192
- }
170
+
171
+ const settled = !isPartial || ctx?.isError === true;
172
+ if (isPartial && !state.interval && state.endedAt === undefined && ctx?.invalidate) {
173
+ state.interval = setInterval(() => {
174
+ ctx.invalidate?.();
175
+ }, 1000);
193
176
  }
194
- if (!isPartial || ctx?.isError) {
177
+ if (settled) {
195
178
  state.endedAt ??= Date.now();
196
179
  if (state.interval) {
197
180
  clearInterval(state.interval);
198
181
  state.interval = undefined;
199
182
  }
200
- activeStates.delete(state);
201
183
  }
202
184
  }
203
185
 
204
- /**
205
- * The script's wall time in ms, parsed from the result header when present
206
- * (more accurate than the renderer's timer, which spans TUI updates).
207
- */
208
- export function parseWallTimeMs(
209
- content: Array<{ type: string; text?: string }>,
210
- ): number | undefined {
211
- const first = content[0];
212
- if (first?.type !== "text" || typeof first.text !== "string") return undefined;
213
- const match = first.text.match(
214
- /^Script (completed|failed)\nWall time ([\d.]+) seconds/,
215
- );
216
- if (!match) return undefined;
217
- const seconds = Number.parseFloat(match[2]!);
218
- return Number.isFinite(seconds) ? seconds * 1000 : undefined;
219
- }
220
-
221
186
  /**
222
187
  * Render the codemode tool call: the bold `codemode` header. Collapsed, the
223
188
  * header stands alone — the script (syntax-highlighted) shows when expanded.
@@ -294,6 +259,7 @@ export function renderCodemodeResult(
294
259
  const ctx = context as {
295
260
  executionStarted?: boolean;
296
261
  isError?: boolean;
262
+ durationMs?: number | undefined;
297
263
  state?: CodemodeRendererState;
298
264
  invalidate?: () => void;
299
265
  } | undefined;
@@ -308,14 +274,17 @@ export function renderCodemodeResult(
308
274
  const calls = res?.details?.calls ?? [];
309
275
  const isError = Boolean(ctx?.isError);
310
276
 
311
- const state: CodemodeRendererState = ctx?.state ?? {};
312
- const now = Date.now();
313
- const wallTime = parseWallTimeMs(content);
314
- const timerMs =
315
- state.startedAt !== undefined
316
- ? Math.max(0, (state.endedAt ?? now) - state.startedAt)
317
- : undefined;
318
- const durationMs = wallTime ?? timerMs;
277
+ // The duration the script recorded wins over the row's own clock, so a
278
+ // reloaded session still says how long the script took.
279
+ const timing = toolRowDuration(
280
+ {
281
+ durationMs: ctx?.durationMs,
282
+ startedAt: ctx?.state?.startedAt,
283
+ endedAt: ctx?.state?.endedAt,
284
+ },
285
+ { isPartial: Boolean(options.isPartial), isError },
286
+ );
287
+ const durationMs = timing?.ms;
319
288
 
320
289
  const container = new Container();
321
290
 
@@ -378,9 +347,8 @@ export function renderCodemodeResult(
378
347
  }
379
348
 
380
349
  if (durationMs !== undefined) {
381
- const label = options.isPartial ? "Elapsed" : "Took";
382
350
  parts.push(
383
- theme.fg("muted", `${label} ${formatCodemodeDuration(durationMs)}`),
351
+ theme.fg("muted", `${timing!.label} ${formatCodemodeDuration(durationMs)}`),
384
352
  );
385
353
  }
386
354