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.
- package/package.json +70 -70
- package/src/agents-store.ts +81 -10
- package/src/config.ts +5 -2
- package/src/footer.ts +1 -0
- package/src/index.ts +155 -5
- package/src/quiet-tools.ts +310 -0
- package/src/render.ts +2 -1
- package/src/settings-view.ts +166 -129
- package/src/stack-widget.ts +142 -111
- package/src/thinking-fold.ts +86 -0
- package/src/thinking-timer.ts +224 -0
- package/src/types.ts +134 -114
package/src/stack-widget.ts
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
return (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
];
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
+
}
|