pi-cockpit 0.3.0 → 0.5.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.
@@ -1,111 +1,142 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import type { TUI } from "@earendil-works/pi-tui";
3
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
- import { renderAgents, renderTodos, type PaintTheme, type WidthUtils } from "./render.ts";
5
- import { fitLineByPriority, type PrioritizedSegment } from "./layout.ts";
6
- import { resolveGlyphs, spinFrame } from "./icons.ts";
7
- import { panelRows } from "./viewport.ts";
8
- import type { AgentRow, CockpitConfig, TodoItem } from "./types.ts";
9
-
10
- export interface TodoWidgetDeps {
11
- getTodos: () => TodoItem[];
12
- getConfig: () => CockpitConfig;
13
- /** False when no redraw loop is running, so spinners must not freeze mid-cycle. */
14
- isAnimating?: () => boolean;
15
- }
16
-
17
- export interface AgentWidgetDeps {
18
- getAgents: () => AgentRow[];
19
- getConfig: () => CockpitConfig;
20
- isRunning: () => boolean;
21
- isAnimating?: () => boolean;
22
- }
23
-
24
- const UTILS: WidthUtils = { measure: visibleWidth, clip: truncateToWidth };
25
-
26
- // The TUI exposes the live terminal size, but a widget must never fail to render
27
- // because a host handed it a stub without one.
28
- export function terminalRows(tui: TUI): number | undefined {
29
- try {
30
- const rows = tui.terminal?.rows;
31
- return typeof rows === "number" && rows > 0 ? rows : undefined;
32
- } catch {
33
- return undefined;
34
- }
35
- }
36
-
37
- // Todo widget: pinned above the editor (setWidget "cockpit-stack", aboveEditor).
38
- export function makeTodoWidget(deps: TodoWidgetDeps) {
39
- return (tui: TUI, theme: Theme) => {
40
- const paint: PaintTheme = theme;
41
- return {
42
- render(width: number): string[] {
43
- const cfg = deps.getConfig();
44
- const todos = deps.getTodos();
45
- if (todos.length === 0) return [];
46
- const g = resolveGlyphs(cfg.icons.mode);
47
- const now = Date.now();
48
- const spin = spinFrame(g, now, deps.isAnimating?.() ?? true);
49
- const opts = {
50
- glyphs: g,
51
- spin,
52
- now,
53
- expanded: cfg.todoExpanded,
54
- maxRows: panelRows(terminalRows(tui)),
55
- };
56
- return renderTodos(todos, cfg.todoExpanded ? "list" : cfg.todoMode, width, paint, UTILS, opts);
57
- },
58
- invalidate(): void {},
59
- dispose(): void {},
60
- };
61
- };
62
- }
63
-
64
- // Agent widget: pinned below the editor, near the input box (setWidget "cockpit-agents", belowEditor).
65
- export function makeAgentWidget(deps: AgentWidgetDeps) {
66
- return (tui: TUI, theme: Theme) => {
67
- const paint: PaintTheme = theme;
68
- return {
69
- render(width: number): string[] {
70
- const cfg = deps.getConfig();
71
- const agents = deps.getAgents();
72
- if (agents.length === 0) return [];
73
- const g = resolveGlyphs(cfg.icons.mode);
74
- const now = Date.now();
75
- const animating = deps.isAnimating?.() ?? true;
76
- const spin = spinFrame(g, now, animating);
77
- const running = deps.isRunning();
78
- // The panel budget covers the roster header and its rows.
79
- const panel = panelRows(terminalRows(tui));
80
- const rosterRows = panel === undefined
81
- ? undefined
82
- : Math.max(1, panel - 1);
83
- const opts = { glyphs: g, spin, now, maxRows: rosterRows };
84
- const dot = theme.fg(running ? "success" : "muted", running ? g.dotRunning : g.dotIdle);
85
- const failedCount = agents.filter((a) => a.status === "failed").length;
86
- const runCount = agents.filter((a) => a.status === "running" || a.status === "retrying").length;
87
- const pendingCount = agents.filter((a) => a.status === "pending").length;
88
- const sleepingCount = agents.filter((a) => a.status === "sleeping").length;
89
- // This header owns the roster summary, so compact mode must not print
90
- // its own count line right underneath saying the same thing.
91
- const headerSegs: PrioritizedSegment[] = [
92
- { text: dot, priority: 100, clippable: false },
93
- { text: theme.fg("muted", "Agents"), priority: 90, clippable: false },
94
- ];
95
- if (failedCount) {
96
- headerSegs.push({ text: theme.fg("error", `${failedCount} failed`), priority: 95, clippable: false });
97
- }
98
- if (runCount) headerSegs.push({ text: theme.fg("dim", `${runCount} running`), priority: 80, clippable: false });
99
- if (pendingCount) headerSegs.push({ text: theme.fg("dim", `${pendingCount} pending`), priority: 60, clippable: false });
100
- if (sleepingCount) headerSegs.push({ text: theme.fg("dim", `${sleepingCount} sleeping`), priority: 50, clippable: false });
101
- const lines: string[] = [];
102
- // Was the one line in the package pushed without any width clipping.
103
- lines.push(fitLineByPriority(headerSegs, width, UTILS, theme.fg("dim", g.separator), g.ellipsis));
104
- lines.push(...renderAgents(agents, cfg.agentsMode, width, paint, UTILS, { ...opts, withHead: false }));
105
- return lines;
106
- },
107
- invalidate(): void {},
108
- dispose(): void {},
109
- };
110
- };
111
- }
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { TUI } from "@earendil-works/pi-tui";
3
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+ import { renderAgents, renderTodos, type PaintTheme, type WidthUtils } from "./render.ts";
5
+ import { fitLineByPriority, type PrioritizedSegment } from "./layout.ts";
6
+ import { resolveGlyphs, spinFrame } from "./icons.ts";
7
+ import { panelRows } from "./viewport.ts";
8
+ import type { AgentRow, CockpitConfig, TodoItem } from "./types.ts";
9
+
10
+ export interface TodoWidgetDeps {
11
+ getTodos: () => TodoItem[];
12
+ getConfig: () => CockpitConfig;
13
+ /** False when no redraw loop is running, so spinners must not freeze mid-cycle. */
14
+ isAnimating?: () => boolean;
15
+ }
16
+
17
+ export interface AgentWidgetDeps {
18
+ getAgents: () => AgentRow[];
19
+ getConfig: () => CockpitConfig;
20
+ isRunning: () => boolean;
21
+ isAnimating?: () => boolean;
22
+ }
23
+
24
+ const UTILS: WidthUtils = { measure: visibleWidth, clip: truncateToWidth };
25
+
26
+ // The TUI exposes the live terminal size, but a widget must never fail to render
27
+ // because a host handed it a stub without one.
28
+ export function terminalRows(tui: TUI): number | undefined {
29
+ try {
30
+ const rows = tui.terminal?.rows;
31
+ return typeof rows === "number" && rows > 0 ? rows : undefined;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ function visibleAgentRows(rows: AgentRow[]): AgentRow[] {
38
+ const byId = new Map(rows.map((row) => [row.correlationId, row]));
39
+ return rows.filter((row) => !row.agent.startsWith("graph(")).map((row) => {
40
+ let parent = row.parentCorrelationId;
41
+ const visited = new Set([row.correlationId]);
42
+ while (parent) {
43
+ if (visited.has(parent)) {
44
+ parent = undefined;
45
+ break;
46
+ }
47
+ visited.add(parent);
48
+ const parentRow = byId.get(parent);
49
+ if (!parentRow || !parentRow.agent.startsWith("graph(")) break;
50
+ parent = parentRow.parentCorrelationId;
51
+ }
52
+ return parent === row.parentCorrelationId ? row : { ...row, parentCorrelationId: parent };
53
+ });
54
+ }
55
+
56
+ // Todo widget: pinned above the editor (setWidget "cockpit-stack", aboveEditor).
57
+ export function makeTodoWidget(deps: TodoWidgetDeps) {
58
+ return (tui: TUI, theme: Theme) => {
59
+ const paint: PaintTheme = theme;
60
+ return {
61
+ render(width: number): string[] {
62
+ const cfg = deps.getConfig();
63
+ const todos = deps.getTodos();
64
+ if (todos.length === 0) return [];
65
+ const g = resolveGlyphs(cfg.icons.mode);
66
+ const now = Date.now();
67
+ const spin = spinFrame(g, now, deps.isAnimating?.() ?? true);
68
+ const opts = {
69
+ glyphs: g,
70
+ spin,
71
+ now,
72
+ expanded: cfg.todoExpanded,
73
+ maxRows: panelRows(terminalRows(tui)),
74
+ };
75
+ return renderTodos(todos, cfg.todoExpanded ? "list" : cfg.todoMode, width, paint, UTILS, opts);
76
+ },
77
+ invalidate(): void {},
78
+ dispose(): void {},
79
+ };
80
+ };
81
+ }
82
+
83
+ // Agent widget: pinned below the editor, near the input box (setWidget "cockpit-agents", belowEditor).
84
+ export function makeAgentWidget(deps: AgentWidgetDeps) {
85
+ return (tui: TUI, theme: Theme) => {
86
+ const paint: PaintTheme = theme;
87
+ return {
88
+ render(width: number): string[] {
89
+ const cfg = deps.getConfig();
90
+ // graph(...) is the dispatch container, not an additional worker. Keep it
91
+ // in AgentsStore for linkage and cleanup, and bridge its visible descendants
92
+ // to the nearest non-graph ancestor for rendering.
93
+ const roster = visibleAgentRows(deps.getAgents());
94
+ if (roster.length === 0) return [];
95
+ // Quiet mode keeps the roster fully expanded (role, task, state, duration,
96
+ // tool, tokens) but strips the live streaming tail: that per-message text
97
+ // updates on every teammate message and is exactly the dynamic noise quiet
98
+ // mode exists to suppress. Header counts are tail-independent, so reading
99
+ // them from the stripped rows leaves the summary unchanged.
100
+ const agents = cfg.quietMode
101
+ ? roster.map((row) => (row.tail ? { ...row, tail: "" } : row))
102
+ : roster;
103
+ const g = resolveGlyphs(cfg.icons.mode);
104
+ const now = Date.now();
105
+ const animating = deps.isAnimating?.() ?? true;
106
+ const spin = spinFrame(g, now, animating);
107
+ const running = deps.isRunning();
108
+
109
+ // The panel budget covers the roster header and its rows.
110
+ const panel = panelRows(terminalRows(tui));
111
+ const rosterRows = panel === undefined
112
+ ? undefined
113
+ : Math.max(1, panel - 1);
114
+ const opts = { glyphs: g, spin, now, maxRows: rosterRows };
115
+ const dot = theme.fg(running ? "success" : "muted", running ? g.dotRunning : g.dotIdle);
116
+ const failedCount = agents.filter((a) => a.status === "failed").length;
117
+ const runCount = agents.filter((a) => a.status === "running" || a.status === "retrying").length;
118
+ const pendingCount = agents.filter((a) => a.status === "pending").length;
119
+ const sleepingCount = agents.filter((a) => a.status === "sleeping").length;
120
+ // This header owns the roster summary, so compact mode must not print
121
+ // its own count line right underneath saying the same thing.
122
+ const headerSegs: PrioritizedSegment[] = [
123
+ { text: dot, priority: 100, clippable: false },
124
+ { text: theme.fg("muted", "Agents"), priority: 90, clippable: false },
125
+ ];
126
+ if (failedCount) {
127
+ headerSegs.push({ text: theme.fg("error", `${failedCount} failed`), priority: 95, clippable: false });
128
+ }
129
+ if (runCount) headerSegs.push({ text: theme.fg("dim", `${runCount} running`), priority: 80, clippable: false });
130
+ if (pendingCount) headerSegs.push({ text: theme.fg("dim", `${pendingCount} pending`), priority: 60, clippable: false });
131
+ if (sleepingCount) headerSegs.push({ text: theme.fg("dim", `${sleepingCount} sleeping`), priority: 50, clippable: false });
132
+ const lines: string[] = [];
133
+ // Was the one line in the package pushed without any width clipping.
134
+ lines.push(fitLineByPriority(headerSegs, width, UTILS, theme.fg("dim", g.separator), g.ellipsis));
135
+ lines.push(...renderAgents(agents, cfg.agentsMode, width, paint, UTILS, { ...opts, withHead: false }));
136
+ return lines;
137
+ },
138
+ invalidate(): void {},
139
+ dispose(): void {},
140
+ };
141
+ };
142
+ }
@@ -0,0 +1,86 @@
1
+ // Thinking-fold control through pi's native app.thinking.toggle action.
2
+ //
3
+ // pi's extension API can rename the hidden-thinking label but cannot hide
4
+ // thinking blocks: the visibility flag (hideThinkingBlock) is private to
5
+ // interactive mode. The sanctioned reach point is the editor's actionHandlers
6
+ // map — pi's own setCustomEditorComponent copies it across the jiti module
7
+ // boundary with this same duck typing ("instanceof fails across jiti module
8
+ // boundaries"), and pasteToEditor injects synthetic editor input the same way.
9
+ //
10
+ // State ownership stays with pi: the toggle handler persists through pi's
11
+ // settingsManager, and Ctrl+T / /settings remain authoritative. cockpit reads
12
+ // pi's settings files only to decide whether a dispatch is needed, and never
13
+ // writes them.
14
+
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
18
+ import type { Component, TUI } from "@earendil-works/pi-tui";
19
+
20
+ const THINKING_TOGGLE_ACTION = "app.thinking.toggle";
21
+
22
+ function asChildren(value: unknown): Component[] | undefined {
23
+ if (typeof value !== "object" || value === null) return undefined;
24
+ const children = (value as { children?: unknown }).children;
25
+ return Array.isArray(children) ? (children as Component[]) : undefined;
26
+ }
27
+
28
+ /**
29
+ * Find the handler pi wired for app.thinking.toggle on the editor. Walks the
30
+ * TUI component tree and duck-types the actionHandlers Map. Returns undefined
31
+ * when the editor is not mounted or pi moved the action map.
32
+ */
33
+ export function findThinkingToggle(tui: TUI): (() => void) | undefined {
34
+ const stack: Component[] = [...(asChildren(tui) ?? [])];
35
+ const seen = new Set<Component>();
36
+ while (stack.length > 0) {
37
+ const node = stack.pop() as Component;
38
+ if (seen.has(node)) continue;
39
+ seen.add(node);
40
+ const handlers = (node as { actionHandlers?: unknown }).actionHandlers;
41
+ if (handlers instanceof Map) {
42
+ const toggle = handlers.get(THINKING_TOGGLE_ACTION);
43
+ if (typeof toggle === "function") return toggle as () => void;
44
+ }
45
+ const children = asChildren(node);
46
+ if (children) stack.push(...children);
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ function readHideFlag(path: string): boolean | undefined {
52
+ try {
53
+ if (!existsSync(path)) return undefined;
54
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as { hideThinkingBlock?: unknown };
55
+ return typeof parsed.hideThinkingBlock === "boolean" ? parsed.hideThinkingBlock : undefined;
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Effective hideThinkingBlock the way pi merges it: project settings override
63
+ * global, and a missing or unparseable flag defaults to false — the same
64
+ * `?? false` settingsManager applies. (An untrusted project's override is not
65
+ * visible here; pi ignores it at runtime. Such a project-level flag for this
66
+ * key is a pathological case cockpit does not try to second-guess.)
67
+ */
68
+ export function readHideThinkingBlock(cwd: string): boolean {
69
+ const global = readHideFlag(join(getAgentDir(), "settings.json"));
70
+ const project = readHideFlag(join(cwd, CONFIG_DIR_NAME, "settings.json"));
71
+ return project ?? global ?? false;
72
+ }
73
+
74
+ /**
75
+ * Bring pi's thinking visibility to the wanted state via the native toggle.
76
+ * Reads first so the dispatch never flips the setting the wrong way; returns
77
+ * true when the wanted state holds afterwards, false when the editor or its
78
+ * action handler is unreachable (non-TUI mode, startup race, pi refactor).
79
+ */
80
+ export function ensureThinkingFolded(tui: TUI | undefined, cwd: string, hidden: boolean): boolean {
81
+ if (!tui) return false;
82
+ const toggle = findThinkingToggle(tui);
83
+ if (!toggle) return false;
84
+ if (readHideThinkingBlock(cwd) !== hidden) toggle();
85
+ return true;
86
+ }
@@ -0,0 +1,224 @@
1
+ // Live elapsed-time labels for folded thinking rows.
2
+ //
3
+ // Pi renders one static label ("thoughts") per hidden thinking run. This
4
+ // module upgrades that label while the fold itself stays pi's: while the model
5
+ // is thinking, the row shows a spinner and the running elapsed time; when the
6
+ // run ends, the row settles to the actual duration ("thoughts · 8.4s").
7
+ //
8
+ // Label targeting: pi's ctx.ui.setHiddenThinkingLabel is global — it rewrites
9
+ // every assistant message component. A final duration belongs to one message,
10
+ // so it is written per component instance instead: the streaming component
11
+ // stays mounted in the chat container after message_end (pi only clears its
12
+ // reference), so a duck-typed walk — the same pattern thinking-fold.ts uses
13
+ // for actionHandlers — can address exactly the row that just finished
14
+ // thinking. When the walk finds nothing (non-TUI mode, pi refactor), the
15
+ // global label API is the degraded fallback: the animation still runs, and
16
+ // the final duration lingers briefly before the base label returns.
17
+
18
+ import type { AssistantMessageEvent } from "@earendil-works/pi-ai";
19
+ import type { TUI } from "@earendil-works/pi-tui";
20
+ import { ANIMATION_PERIOD_MS, spinFrame, type IconGlyphs } from "./icons.ts";
21
+
22
+ /**
23
+ * Duck-typed AssistantMessageComponent: the two public setters pi gives it.
24
+ * No other component in pi's chat tree exposes this pair.
25
+ */
26
+ export interface ThinkingLabelTarget {
27
+ setHiddenThinkingLabel(label: string): void;
28
+ setHideThinkingBlock(hide: boolean): void;
29
+ }
30
+
31
+ function isThinkingLabelTarget(node: object): node is ThinkingLabelTarget {
32
+ const candidate = node as Partial<ThinkingLabelTarget>;
33
+ return (
34
+ typeof candidate.setHiddenThinkingLabel === "function"
35
+ && typeof candidate.setHideThinkingBlock === "function"
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Collect label targets in document order. The newest assistant message is
41
+ * always the last target: pi appends the streaming component to the chat
42
+ * container on message_start, and later siblings (tool executions, the next
43
+ * message) are not targets.
44
+ */
45
+ export function findThinkingLabelTargets(tui: TUI): ThinkingLabelTarget[] {
46
+ const targets: ThinkingLabelTarget[] = [];
47
+ const seen = new Set<unknown>();
48
+ const walk = (node: unknown): void => {
49
+ if (typeof node !== "object" || node === null || seen.has(node)) return;
50
+ seen.add(node);
51
+ if (isThinkingLabelTarget(node)) targets.push(node);
52
+ const children = (node as { children?: unknown }).children;
53
+ if (Array.isArray(children)) {
54
+ for (const child of children) walk(child);
55
+ }
56
+ };
57
+ walk(tui);
58
+ return targets;
59
+ }
60
+
61
+ export function formatThinkingDuration(ms: number): string {
62
+ if (!Number.isFinite(ms) || ms <= 0) return "0.0s";
63
+ if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`;
64
+ const totalSeconds = Math.round(ms / 1000);
65
+ if (totalSeconds < 60) return `${totalSeconds}s`;
66
+ const minutes = Math.floor(totalSeconds / 60);
67
+ return `${minutes}m${String(totalSeconds % 60).padStart(2, "0")}s`;
68
+ }
69
+
70
+ export interface ThinkingTimerOptions {
71
+ getTui: () => TUI | undefined;
72
+ /** Instance label writes do not schedule a paint; the owner must. */
73
+ requestRender: () => void;
74
+ /** Label folded rows currently show; undefined means pi's own default. */
75
+ getBaseLabel: () => string | undefined;
76
+ getGlyphs: () => IconGlyphs;
77
+ /** Effective hideThinkingBlock — labels only render while thinking is folded. */
78
+ isThinkingHidden: () => boolean;
79
+ isEnabled: () => boolean;
80
+ /** Global label fallback for when no component instance is reachable. */
81
+ setGlobalLabel: (label: string | undefined) => void;
82
+ now?: () => number;
83
+ /** How long the fallback final label lingers before the base returns. */
84
+ settleMs?: number;
85
+ }
86
+
87
+ const DEFAULT_SETTLE_MS = 5_000;
88
+
89
+ export class ThinkingFoldTimer {
90
+ readonly #options: ThinkingTimerOptions;
91
+ #startedAt: number | undefined;
92
+ #ticker: ReturnType<typeof setInterval> | undefined;
93
+ #settleTimer: ReturnType<typeof setTimeout> | undefined;
94
+ // Invalidates pending settle restores across stop/reset boundaries so a
95
+ // late timer can never rewrite a label a newer run already owns.
96
+ #generation = 0;
97
+
98
+ constructor(options: ThinkingTimerOptions) {
99
+ this.#options = options;
100
+ }
101
+
102
+ onAssistantMessageEvent(event: AssistantMessageEvent | undefined): void {
103
+ if (!event) return;
104
+ if (!this.#options.isEnabled()) {
105
+ this.stop();
106
+ return;
107
+ }
108
+ if (event.type === "thinking_start") this.#begin();
109
+ else if (event.type === "thinking_end") this.#finish();
110
+ }
111
+
112
+ /**
113
+ * Settle an interrupted run: message_end fires for aborted and failed
114
+ * messages too (agent_end only removes the row afterwards), so the
115
+ * duration written here is still accurate and lands on a mounted row.
116
+ */
117
+ onAssistantMessageEnd(): void {
118
+ if (this.#startedAt !== undefined) this.#finish();
119
+ }
120
+
121
+ /** One animation frame. The internal interval drives it; tests call it directly. */
122
+ tick(): void {
123
+ if (this.#startedAt === undefined) return;
124
+ const now = this.#now();
125
+ const glyphs = this.#options.getGlyphs();
126
+ const label = `${spinFrame(glyphs, now)} thinking ${formatThinkingDuration(now - (this.#startedAt ?? now))}`;
127
+ this.#paint(label);
128
+ }
129
+
130
+ /** The agent stopped: drop the run without settling a duration. */
131
+ stop(): void {
132
+ this.#generation++;
133
+ this.#clearTimers();
134
+ this.#startedAt = undefined;
135
+ }
136
+
137
+ /** Session boundary: forget the run; pi rebuilds the chat from entries. */
138
+ reset(): void {
139
+ this.#generation++;
140
+ this.#clearTimers();
141
+ this.#startedAt = undefined;
142
+ }
143
+
144
+ #begin(): void {
145
+ // The gate is per-run: when thinking is unfolded the label row is not
146
+ // rendered, so there is nothing to animate and no duration to keep.
147
+ if (!this.#options.isThinkingHidden()) return;
148
+ this.#startedAt = this.#now();
149
+ if (!this.#ticker) {
150
+ this.#ticker = setInterval(() => this.tick(), ANIMATION_PERIOD_MS);
151
+ this.#ticker.unref?.();
152
+ }
153
+ // Paint immediately so the row flips to the live label with the first
154
+ // frame instead of waiting a quarter second.
155
+ this.tick();
156
+ }
157
+
158
+ #finish(): void {
159
+ const startedAt = this.#startedAt;
160
+ if (startedAt === undefined) return;
161
+ this.#clearTimers();
162
+ this.#startedAt = undefined;
163
+ const duration = formatThinkingDuration(this.#now() - startedAt);
164
+ const base = this.#options.getBaseLabel() ?? "thoughts";
165
+ this.#paint(`${base}${this.#options.getGlyphs().separator}${duration}`, true);
166
+ }
167
+
168
+ #paint(label: string, settle = false): void {
169
+ const tui = this.#options.getTui();
170
+ const targets = tui ? findThinkingLabelTargets(tui) : [];
171
+ const target = targets.at(-1);
172
+ if (target) {
173
+ try {
174
+ target.setHiddenThinkingLabel(label);
175
+ this.#options.requestRender();
176
+ } catch {
177
+ // component may be mid-teardown; the global path recovers
178
+ this.#paintGlobal(label, settle);
179
+ }
180
+ return;
181
+ }
182
+ this.#paintGlobal(label, settle);
183
+ }
184
+
185
+ #paintGlobal(label: string, settle: boolean): void {
186
+ try {
187
+ this.#options.setGlobalLabel(label);
188
+ } catch {
189
+ // no UI surface at all — nothing to paint
190
+ }
191
+ if (!settle) return;
192
+ // The global label is shared by every folded row, so the final
193
+ // duration cannot stay: give it a moment to be read, then hand the
194
+ // label back to whatever the owner currently wants.
195
+ this.#generation++;
196
+ const generation = this.#generation;
197
+ if (this.#settleTimer) clearTimeout(this.#settleTimer);
198
+ this.#settleTimer = setTimeout(() => {
199
+ this.#settleTimer = undefined;
200
+ if (generation !== this.#generation) return;
201
+ try {
202
+ this.#options.setGlobalLabel(this.#options.getBaseLabel());
203
+ } catch {
204
+ // best-effort restore
205
+ }
206
+ }, this.#options.settleMs ?? DEFAULT_SETTLE_MS);
207
+ this.#settleTimer.unref?.();
208
+ }
209
+
210
+ #clearTimers(): void {
211
+ if (this.#ticker) {
212
+ clearInterval(this.#ticker);
213
+ this.#ticker = undefined;
214
+ }
215
+ if (this.#settleTimer) {
216
+ clearTimeout(this.#settleTimer);
217
+ this.#settleTimer = undefined;
218
+ }
219
+ }
220
+
221
+ #now(): number {
222
+ return this.#options.now?.() ?? Date.now();
223
+ }
224
+ }