pi-spark 0.14.5 → 0.14.7

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
@@ -6,6 +6,20 @@
6
6
 
7
7
  ![Overview](./assets/screenshot-overview.png)
8
8
 
9
+ ## Install
10
+
11
+ Install from npm:
12
+
13
+ ```bash
14
+ pi install npm:pi-spark
15
+ ```
16
+
17
+ Install from git:
18
+
19
+ ```bash
20
+ pi install git:github.com/zlliang/pi-spark
21
+ ```
22
+
9
23
  ## Features
10
24
 
11
25
  ### Compact TUI: editor, footer, and fullscreen
@@ -131,18 +145,22 @@ Each preset must set all three fields.
131
145
  | --- | --- | --- |
132
146
  | `provider` | string | Provider ID, e.g., `anthropic`. |
133
147
  | `model` | string | Model ID, e.g., `claude-opus-4-8`. |
134
- | `thinkingLevel` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | Thinking level for the preset. |
148
+ | `thinkingLevel` | `ModelThinkingLevel` | Thinking level for the preset. |
135
149
 
136
150
  #### `RecapConfig`
137
151
 
138
- All fields are optional, including `thinkingLevel`.
152
+ All fields are optional, including `thinkingLevel`. If the recap model configuration is incomplete, pi-spark falls back to the session's main model.
139
153
 
140
154
  | Field | Value | Description |
141
155
  | --- | --- | --- |
142
- | `idle` | number (ms) or duration string | How long the session must stay idle before a recap is generated. Accepts a millisecond number or a [vercel/ms](https://github.com/vercel/ms) string (e.g., `"3m"`); minimum 5000 ms, defaults to 3 minutes. |
156
+ | `idle` | number (ms) or duration string | How long the session must stay idle before a recap is generated. Accepts a millisecond number or a [vercel/ms](https://github.com/vercel/ms) string (e.g., `"5m"`); minimum 5000 ms, defaults to 5 minutes. |
143
157
  | `provider` | string | Provider ID for the recap model. |
144
158
  | `model` | string | Model ID for the recap model. |
145
- | `thinkingLevel` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | Thinking level for the recap model. |
159
+ | `thinkingLevel` | `ModelThinkingLevel` | Thinking level for the recap model. |
160
+
161
+ #### `ModelThinkingLevel`
162
+
163
+ Valid values: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`.
146
164
 
147
165
  ### Turn off the features you don't like
148
166
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-spark",
3
- "version": "0.14.5",
3
+ "version": "0.14.7",
4
4
  "description": "Pi package that polishes your daily experience and keeps you at the frontier of agentic workflows.",
5
5
  "keywords": [
6
6
  "pi-coding-agent",
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
 
5
- import { sparkConfigSchema } from "./schema";
5
+ import { featureSchemas } from "./schema";
6
6
 
7
7
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
8
  import type { SparkConfig } from "./schema";
@@ -10,18 +10,6 @@ import type { SparkConfig } from "./schema";
10
10
  type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
11
11
  type JsonObject = { [key: string]: JsonValue };
12
12
 
13
- /** All features disabled; used as the fallback when `spark.json` fails validation. */
14
- const DISABLED_CONFIG: SparkConfig = Object.freeze({
15
- credits: false,
16
- editor: false,
17
- footer: false,
18
- fullscreen: false,
19
- pi: false,
20
- presets: false,
21
- recap: false,
22
- web: false,
23
- });
24
-
25
13
  const cache = new Map<string, SparkConfig>();
26
14
 
27
15
  /** Load and validate spark.json once per session lifecycle; later calls return the cached result. */
@@ -30,23 +18,44 @@ export function loadConfig(ctx: ExtensionContext, fileName: string = "spark.json
30
18
  const cached = cache.get(key);
31
19
  if (cached) return cached;
32
20
 
33
- const rawConfig = loadMergedJson(getConfigPaths(ctx.cwd, fileName)) ?? {};
34
- const result = sparkConfigSchema.safeParse(rawConfig);
21
+ const rawValue = loadMergedJson(getConfigPaths(ctx.cwd, fileName)) ?? {};
22
+ const raw = isPlainObject(rawValue) ? rawValue : {};
23
+
24
+ // Validate each feature independently so a single invalid field disables only that feature
25
+ // (falling back to its enabled defaults) instead of taking down the whole config.
26
+ const config = {} as Record<keyof SparkConfig, unknown>;
27
+ const errors: string[] = [];
28
+
29
+ for (const field of Object.keys(featureSchemas) as (keyof SparkConfig)[]) {
30
+ const value = raw[field];
31
+
32
+ if (value === undefined) {
33
+ config[field] = {};
34
+ continue;
35
+ }
35
36
 
36
- if (!result.success) {
37
- const message = result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
38
- ctx.ui.notify(`Invalid spark config: ${message}`, "error");
37
+ if (value === false) {
38
+ config[field] = false;
39
+ continue;
40
+ }
39
41
 
40
- const config = DISABLED_CONFIG;
41
- cache.set(key, config);
42
+ const result = featureSchemas[field].safeParse(value);
43
+ if (result.success) {
44
+ config[field] = result.data;
45
+ continue;
46
+ }
42
47
 
43
- return config;
48
+ config[field] = {};
49
+ const detail = result.error.issues.map((issue) => `${[field, ...issue.path].join(".")}: ${issue.message}`).join("; ");
50
+ errors.push(detail);
44
51
  }
45
52
 
46
- const config = result.data;
47
- cache.set(key, config);
53
+ if (errors.length > 0) {
54
+ ctx.ui.notify(`Invalid spark config, using defaults for: ${errors.join("; ")}`, "error");
55
+ }
48
56
 
49
- return config;
57
+ cache.set(key, config as SparkConfig);
58
+ return config as SparkConfig;
50
59
  }
51
60
 
52
61
  function getConfigPaths(cwd: string, fileName: string): [globalPath: string, projectPath: string] {
@@ -9,22 +9,23 @@ import { presetsConfigSchema } from "../features/presets/config";
9
9
  import { recapConfigSchema } from "../features/recap/config";
10
10
  import { webConfigSchema } from "../features/web/config";
11
11
 
12
- const disabled = z.literal(false);
13
-
14
12
  /**
15
- * Each feature field is `{ ... } | false`: `false` disables the feature, an object configures
16
- * it, and an omitted field falls back to defaults (`{}`, enabled).
13
+ * Raw option shape for each feature. The enable/disable/default policy lives in `loadConfig`:
14
+ * an omitted field falls back to `{}` (enabled with defaults), `false` disables the feature, and
15
+ * any other value is validated against the feature schema.
17
16
  */
18
- export const sparkConfigSchema = z.object({
19
- credits: creditsConfigSchema.or(disabled).default({}),
20
- editor: editorConfigSchema.or(disabled).default({}),
21
- footer: footerConfigSchema.or(disabled).default({}),
22
- fullscreen: fullscreenConfigSchema.or(disabled).default({}),
23
- pi: piConfigSchema.or(disabled).default({}),
24
- presets: presetsConfigSchema.or(disabled).default({}),
25
- recap: recapConfigSchema.or(disabled).default({}),
26
- web: webConfigSchema.or(disabled).default({}),
27
- });
17
+ export const featureSchemas = {
18
+ credits: creditsConfigSchema,
19
+ editor: editorConfigSchema,
20
+ footer: footerConfigSchema,
21
+ fullscreen: fullscreenConfigSchema,
22
+ pi: piConfigSchema,
23
+ presets: presetsConfigSchema,
24
+ recap: recapConfigSchema,
25
+ web: webConfigSchema,
26
+ } as const;
28
27
 
29
28
  /** Resolved config for every feature; `false` means the feature is disabled. */
30
- export type SparkConfig = z.infer<typeof sparkConfigSchema>;
29
+ export type SparkConfig = {
30
+ [K in keyof typeof featureSchemas]: z.infer<(typeof featureSchemas)[K]> | false;
31
+ };
@@ -2,8 +2,8 @@ import { CreditsManager } from "./manager";
2
2
  import { loadConfig } from "../../config";
3
3
  import { isUsage } from "../../utils/usage";
4
4
 
5
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
5
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
7
 
8
8
  function hasCost(message: AgentMessage): boolean {
9
9
  const usage = (message as { usage?: unknown }).usage;
@@ -44,6 +44,7 @@ export function registerCredits(pi: ExtensionAPI): void {
44
44
  });
45
45
 
46
46
  pi.on("session_shutdown", () => {
47
+ creditsManager?.cancel();
47
48
  creditsManager = undefined;
48
49
  });
49
50
  }
@@ -36,25 +36,29 @@ export class CreditsManager {
36
36
  });
37
37
  }
38
38
 
39
+ cancel(): void {
40
+ this.inflight?.abort();
41
+ this.inflight = undefined;
42
+ }
43
+
39
44
  private async fetch(ctx: ExtensionContext, provider: CreditsProvider, signal: AbortSignal): Promise<void> {
40
45
  try {
41
46
  const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider.id);
47
+ if (signal.aborted) return;
42
48
  if (!apiKey) {
43
49
  ctx.ui.setStatus(STATUS_KEY, undefined);
44
50
  return;
45
51
  }
46
52
 
47
- const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal];
48
- if (ctx.signal) signals.push(ctx.signal);
49
-
50
- const credits = await provider.fetch(ctx, apiKey, AbortSignal.any(signals));
53
+ const signals = AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal]);
54
+ const credits = await provider.fetch(ctx, apiKey, signals);
51
55
 
52
56
  // The active model may have changed while the request was in flight.
53
57
  if (ctx.model?.provider !== provider.id) return;
54
58
 
55
59
  ctx.ui.setStatus(STATUS_KEY, renderCredits(ctx.ui.theme, provider.label, credits));
56
60
  } catch (error) {
57
- if (signal.aborted || ctx.signal?.aborted) return;
61
+ if (signal.aborted) return;
58
62
  if (ctx.model?.provider !== provider.id) return;
59
63
 
60
64
  const message = error instanceof Error ? error.message : String(error);
@@ -78,7 +78,7 @@ class Editor extends CustomEditor {
78
78
  const modelBeforeText = this.slots.modelBefore;
79
79
  const modelText = formatModel(this.ctx.model?.provider, this.ctx.model?.id, this.pi.getThinkingLevel());
80
80
 
81
- return theme.fg("dim", [modelBeforeText, modelText].filter(Boolean).join(" "));
81
+ return theme.fg("dim", [modelBeforeText, modelText].filter(Boolean).join(" · "));
82
82
  }
83
83
  }
84
84
 
@@ -39,7 +39,7 @@ class FooterComponent implements Component {
39
39
  const branch = this.footerData.getGitBranch();
40
40
  const sessionName = this.ctx.sessionManager.getSessionName();
41
41
 
42
- return this.theme.fg("dim", [cwdText, branch, sessionName].filter(Boolean).join(" "));
42
+ return this.theme.fg("dim", [cwdText, branch, sessionName].filter(Boolean).join(" · "));
43
43
  }
44
44
 
45
45
  private getRight(): string {
@@ -47,7 +47,7 @@ class FooterComponent implements Component {
47
47
  const styledCostText = this.getStyledCostText();
48
48
  const styledContextUsageText = this.getStyledContextUsageText();
49
49
 
50
- return [statusesText, styledCostText, styledContextUsageText].filter(Boolean).join(this.theme.fg("dim", " "));
50
+ return [statusesText, styledCostText, styledContextUsageText].filter(Boolean).join(this.theme.fg("dim", " · "));
51
51
  }
52
52
 
53
53
  /** Get extension statuses, sorted by key alphabetically. */
@@ -58,7 +58,7 @@ class FooterComponent implements Component {
58
58
  return Array.from(extensionStatuses.entries())
59
59
  .sort(([a], [b]) => a.localeCompare(b))
60
60
  .map(([, text]) => sanitizeText(text))
61
- .join(this.theme.fg("dim", " "));
61
+ .join(this.theme.fg("dim", " · "));
62
62
  }
63
63
 
64
64
  private getStyledCostText(): string {
@@ -1,4 +1,4 @@
1
- import { keyText, truncateHead } from "@earendil-works/pi-coding-agent";
1
+ import { keyHint, truncateHead } from "@earendil-works/pi-coding-agent";
2
2
  import { Container, Spacer, Text } from "@earendil-works/pi-tui";
3
3
  import { filter, parse } from "liqe";
4
4
  import { Type } from "typebox";
@@ -92,7 +92,7 @@ export const modelsAction = defineAction({
92
92
  addRows(models.slice(0, maxRows).map((model) => toModelRow(model)));
93
93
 
94
94
  const hiddenRows = models.length - maxRows;
95
- if (hiddenRows > 0) container.addChild(new Text(theme.fg("dim", `... (${hiddenRows} more, ${keyText("app.tools.expand")} to expand)`), 0, 0));
95
+ if (hiddenRows > 0) container.addChild(new Text(theme.fg("dim", `... (${hiddenRows} more, `) + keyHint("app.tools.expand", "to expand") + theme.fg("dim", ")"), 0, 0));
96
96
  container.addChild(new Spacer(1));
97
97
  }
98
98
 
@@ -22,23 +22,13 @@ export const nameAction = defineAction({
22
22
  "(e.g., \"Refactor auth module\", \"Debug flaky CI pipeline\"). Do not use " +
23
23
  "surrounding quotes, trailing punctuation, or generic prefixes like \"Chat about\".",
24
24
  }),
25
- reason: Type.Optional(Type.String({
26
- maxLength: 240,
27
- description:
28
- "For \"name\": Explain briefly why the session was named or renamed, such as a long pasted " +
29
- "prompt, an ambiguous first message, or a topic shift. Write one user-facing " +
30
- "sentence (e.g., \"The focus shifted from debugging to README updates.\").",
31
- })),
32
25
  },
33
26
  required: ["name"],
34
27
  promptGuidelines: [
35
28
  "Use the pi tool's \"name\" action to give the current session a concise, recognizable name, especially after a long, vague, or pasted opening prompt, or after a substantial shift in the conversation's focus.",
36
29
  ],
37
30
  renderParams(args, theme) {
38
- const name = theme.fg("muted", sanitizeText(args.name ?? ""));
39
- const reason = sanitizeText(args.reason ?? "");
40
-
41
- return [reason ? name + theme.fg("muted", "\n\n" + reason) : name];
31
+ return [theme.fg("muted", sanitizeText(args.name ?? ""))];
42
32
  },
43
33
  renderResult() {
44
34
  // "name" has no success UI; errors are rendered by the registry's fallback.
@@ -31,14 +31,16 @@ export const whoamiAction = defineAction({
31
31
  return container;
32
32
  }
33
33
 
34
+ const formatLine = (label: string, value: string) => `${label.padEnd(7)} ${value}`;
35
+
34
36
  if (details.sessionName) {
35
- container.addChild(new Text(theme.fg("muted", `session ${details.sessionName}`), 0, 0));
37
+ container.addChild(new Text(theme.fg("muted", formatLine("session", details.sessionName)), 0, 0));
36
38
  }
37
39
 
38
40
  if (details.model) {
39
41
  const row = toModelRow(details.model, details.thinkingLevel);
40
42
  const cells = [row.label, row.cost, row.context].join(" ");
41
- container.addChild(new Text(theme.fg("muted", `model ${cells}`), 0, 0));
43
+ container.addChild(new Text(theme.fg("muted", formatLine("model", cells)), 0, 0));
42
44
  }
43
45
 
44
46
  return container;
@@ -14,9 +14,9 @@ export function registerPresets(pi: ExtensionAPI): void {
14
14
  type: "string",
15
15
  });
16
16
 
17
- pi.on("session_start", async (_event, ctx) => {
17
+ pi.on("session_start", async (event, ctx) => {
18
18
  const config = loadConfig(ctx).presets;
19
- const presetFlag = pi.getFlag("preset");
19
+ const presetFlag = event.reason === "startup" ? pi.getFlag("preset") : undefined;
20
20
 
21
21
  if (!config || Object.keys(config).length === 0) {
22
22
  if (presetFlag) ctx.ui.notify("No presets defined in spark.json", "warning");
@@ -1,5 +1,5 @@
1
- import { DynamicBorder } from "@earendil-works/pi-coding-agent";
2
- import { Container, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
1
+ import { keyHint, rawKeyHint, DynamicBorder } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Container, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
3
3
 
4
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import type { PresetManager } from "./manager";
@@ -20,12 +20,11 @@ export async function showPresetSelector(ctx: ExtensionContext, presetManager: P
20
20
  }));
21
21
 
22
22
  const container = new Container();
23
-
24
23
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
25
- container.addChild(new Spacer(1));
26
- container.addChild(new Text(theme.bold("Select preset"), 0, 0));
27
- container.addChild(new Text(theme.fg("dim", "↑↓ navigate · enter select · esc cancel"), 0, 0));
28
- container.addChild(new Spacer(1));
24
+
25
+ const box = new Box(1, 1);
26
+ box.addChild(new Text(theme.bold(theme.fg("accent", "Select preset")), 0, 0));
27
+ box.addChild(new Spacer(1));
29
28
 
30
29
  const selectList = new SelectList(items, 10, {
31
30
  selectedPrefix: (text) => theme.fg("accent", text),
@@ -36,9 +35,17 @@ export async function showPresetSelector(ctx: ExtensionContext, presetManager: P
36
35
  });
37
36
  selectList.onSelect = (item) => done(item.value);
38
37
  selectList.onCancel = () => done(null);
39
- container.addChild(selectList);
38
+ box.addChild(selectList);
39
+ box.addChild(new Spacer(1));
40
+
41
+ const keyHints = [
42
+ rawKeyHint("↑↓", "navigate"),
43
+ keyHint("tui.select.confirm", "select"),
44
+ keyHint("tui.select.cancel", "cancel"),
45
+ ];
46
+ box.addChild(new Text(keyHints.join(" "), 0, 0));
40
47
 
41
- container.addChild(new Spacer(1));
48
+ container.addChild(box);
42
49
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
43
50
 
44
51
  return {
@@ -31,7 +31,7 @@ export const idleTimeoutSchema = z
31
31
  type IdleTimeout = z.infer<typeof idleTimeoutSchema>;
32
32
  type IdleHash = string | number | boolean;
33
33
 
34
- const DEFAULT_IDLE_MS = 3 * 60 * 1000;
34
+ const DEFAULT_IDLE_MS = 5 * 60 * 1000;
35
35
  const POLL_MS = 1_000;
36
36
 
37
37
  export class IdleListener<T> {
@@ -1,4 +1,4 @@
1
- import { keyText } from "@earendil-works/pi-coding-agent";
1
+ import { keyHint } from "@earendil-works/pi-coding-agent";
2
2
  import { Container, Spacer, Text } from "@earendil-works/pi-tui";
3
3
 
4
4
  import { joinTextContent } from "../../utils/format";
@@ -28,7 +28,7 @@ export function renderWebResult(result: AgentToolResult<unknown>, expanded: bool
28
28
  container.addChild(new Text(theme.fg("muted", lines.slice(0, maxLines).join("\n")), 0, 0));
29
29
 
30
30
  const hidden = lines.length - maxLines;
31
- if (hidden > 0) container.addChild(new Text(theme.fg("dim", `... (${hidden} more lines, ${keyText("app.tools.expand")} to expand)`), 0, 0));
31
+ if (hidden > 0) container.addChild(new Text(theme.fg("dim", `... (${hidden} more lines, `) + keyHint("app.tools.expand", "to expand") + theme.fg("dim", ")"), 0, 0));
32
32
 
33
33
  return container;
34
34
  }
package/src/utils/tool.ts CHANGED
@@ -4,7 +4,7 @@ import { Type } from "typebox";
4
4
 
5
5
  import { formatDuration, joinTextContent } from "./format";
6
6
 
7
- import type { AgentToolResult, ExtensionAPI, ExtensionContext, Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
7
+ import type { AgentToolResult, AgentToolUpdateCallback, ExtensionAPI, ExtensionContext, Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
8
8
  import type { Component } from "@earendil-works/pi-tui";
9
9
  import type { Static, TObject, TProperties, TSchema } from "typebox";
10
10
 
@@ -28,8 +28,9 @@ export interface Action<C, F extends TProperties = TProperties, D extends Action
28
28
  showTiming?: boolean;
29
29
  /** Styled segments shown after the `<tool> <action>` prefix. */
30
30
  renderParams?: (args: Static<TObject<F>>, theme: Theme) => string[];
31
+ /** Only handles successful output; error output is handled centrally. */
31
32
  renderResult?: NonNullable<ToolDefinition<TObject<F>, D>["renderResult"]>;
32
- execute(args: Static<TObject<F>>, context: C, signal: AbortSignal | undefined): Promise<AgentToolResult<D>>;
33
+ execute(args: Static<TObject<F>>, context: C, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback<D> | undefined): Promise<AgentToolResult<D>>;
33
34
  }
34
35
 
35
36
  /** Identity helper bound to context `C`, so actions infer their field/details types while sharing one context shape. */
@@ -75,7 +76,6 @@ export function registerComposedTool<C>(pi: ExtensionAPI, config: ComposedToolCo
75
76
 
76
77
  const activeTiming = new ActiveTiming();
77
78
  const noTiming = new NoTiming();
78
- // Resolve per-action timing from the stable call args, so error results (no details) still settle.
79
79
  const timingFor = (action: string | undefined): Timing => (byName.get(action ?? "")?.showTiming ? activeTiming : noTiming);
80
80
 
81
81
  const renderActionResult: NonNullable<ToolDefinition["renderResult"]> = (result, options, theme, context) => {
@@ -111,7 +111,7 @@ export function registerComposedTool<C>(pi: ExtensionAPI, config: ComposedToolCo
111
111
 
112
112
  return timingFor(context.args.action).renderResult(inner, options, theme, context);
113
113
  },
114
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
114
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
115
115
  const action = byName.get(params.action);
116
116
  if (!action) throw new Error(`Unknown ${config.name} action "${params.action}"`);
117
117
 
@@ -121,7 +121,7 @@ export function registerComposedTool<C>(pi: ExtensionAPI, config: ComposedToolCo
121
121
  }
122
122
  }
123
123
 
124
- return action.execute(params as never, config.createContext(ctx), signal);
124
+ return action.execute(params as any, config.createContext(ctx), signal, onUpdate);
125
125
  },
126
126
  });
127
127
  }
@@ -147,55 +147,67 @@ interface TimingState {
147
147
  startedAt?: number;
148
148
  endedAt?: number;
149
149
  interval?: ReturnType<typeof setInterval>;
150
+ hasResult?: boolean;
150
151
  }
151
152
 
152
153
  class ActiveTiming implements Timing {
154
+ // No result yet: `renderCall` owns the live "Elapsed" ticker.
153
155
  renderCall(inner: Component, ...[theme, context]: RenderCallTail): Component {
154
156
  const state = context.state as TimingState;
157
+
155
158
  if (context.executionStarted && state.startedAt === undefined) {
156
159
  state.startedAt = Date.now();
157
160
  delete state.endedAt;
161
+ delete state.hasResult;
158
162
  }
159
163
 
160
- // Result is in: renderResult owns the timing line, so just settle here.
161
- if (!context.isPartial) {
162
- this.settle(state);
163
- return inner;
164
- }
165
-
166
- // Still running, no result yet: show a live "Elapsed" line, ticking once a second.
167
- if (state.startedAt === undefined) return inner;
164
+ // Not started, or final (`renderResult` owns the "Took" line): render nothing here.
165
+ if (state.startedAt === undefined || !context.isPartial) return inner;
168
166
 
169
167
  state.interval ??= setInterval(() => context.invalidate(), 1000);
170
- return this.withTimingLine(inner, "Elapsed", Date.now() - state.startedAt, theme);
168
+
169
+ // `renderResult` runs later in the same render pass, so decide visibility at render time:
170
+ // hide once any result has arrived.
171
+ return this.withTimingLine(inner, "Elapsed", Date.now() - state.startedAt, theme, () => !state.hasResult);
171
172
  }
172
173
 
174
+ // A result exists: `renderResult` owns the line — "Elapsed" while streaming, "Took" once final.
173
175
  renderResult(inner: Component, ...[options, theme, context]: RenderResultTail): Component {
174
176
  const state = context.state as TimingState;
175
- if (!options.isPartial || context.isError) this.settle(state);
177
+ state.hasResult = true;
178
+
179
+ const isRunning = options.isPartial && !context.isError;
180
+ if (!isRunning) this.settle(state);
176
181
  if (state.startedAt === undefined) return inner;
177
182
 
178
- const label = options.isPartial ? "Elapsed" : "Took";
179
183
  const endTime = state.endedAt ?? Date.now();
180
- return this.withTimingLine(inner, label, endTime - state.startedAt, theme);
184
+ return this.withTimingLine(inner, isRunning ? "Elapsed" : "Took", endTime - state.startedAt, theme);
181
185
  }
182
186
 
183
187
  private settle(state: TimingState): void {
184
- if (state.startedAt !== undefined) state.endedAt ??= Date.now();
188
+ if (state.startedAt !== undefined) {
189
+ state.endedAt ??= Date.now();
190
+ }
191
+
185
192
  if (state.interval) {
186
193
  clearInterval(state.interval);
187
194
  delete state.interval;
188
195
  }
189
196
  }
190
197
 
191
- private withTimingLine(content: Component, label: string, ms: number, theme: Theme): Container {
198
+ private withTimingLine(content: Component, label: string, ms: number, theme: Theme, visible?: () => boolean): Component {
192
199
  const container = new Container();
193
200
  container.addChild(content);
194
201
 
195
202
  container.addChild(new Spacer(1));
196
- container.addChild(new Text(`${theme.fg("muted", `${label} ${formatDuration(ms)}`)}`, 0, 0));
203
+ container.addChild(new Text(theme.fg("muted", `${label} ${formatDuration(ms)}`), 0, 0));
204
+
205
+ if (!visible) return container;
197
206
 
198
- return container;
207
+ return {
208
+ invalidate: () => container.invalidate(),
209
+ render: (width) => (visible() ? container.render(width) : content.render(width)),
210
+ };
199
211
  }
200
212
  }
201
213