pi-cockpit 0.1.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.
@@ -0,0 +1,129 @@
1
+ // Pure model for the /cockpit settings panel.
2
+ //
3
+ // The old panel was a flat list of blind letter accelerators: no cursor, no
4
+ // focused row, no indication of what a key would cycle to, and every keystroke
5
+ // wrote to disk while failures were swallowed. This module owns the row model and
6
+ // the cursor/cycle logic so the behaviour is unit-testable; index.ts only paints
7
+ // it and performs the actual save.
8
+
9
+ import type { CockpitConfig, IconMode, ViewMode } from "./types.ts";
10
+
11
+ export type SaveState =
12
+ | { kind: "idle" }
13
+ | { kind: "saving" }
14
+ | { kind: "saved" }
15
+ | { kind: "failed"; message: string };
16
+
17
+ export interface SettingsRow {
18
+ key: string;
19
+ /** Single-letter accelerator, kept as a secondary path alongside the cursor. */
20
+ accel: string;
21
+ label: string;
22
+ value: string;
23
+ /** What pressing Enter/Space/accel switches to — shown so the cycle is visible. */
24
+ next: string;
25
+ }
26
+
27
+ const VIEW_MODES: ViewMode[] = ["list", "compact"];
28
+ const ICON_MODES: IconMode[] = ["auto", "nerd", "ascii"];
29
+
30
+ function cycle<T>(values: readonly T[], current: T): T {
31
+ const index = values.indexOf(current);
32
+ return values[(index + 1) % values.length];
33
+ }
34
+
35
+ // Empty means cockpit has not set a theme, so whatever /settings holds is live.
36
+ // It is deliberately not called "default": pi's setting is the authority here,
37
+ // and cockpit only ever nudges it.
38
+ const NO_THEME_LABEL = "(pi settings)";
39
+
40
+ function themeLabel(theme: string): string {
41
+ return theme === "" ? NO_THEME_LABEL : theme;
42
+ }
43
+
44
+ export function buildRows(config: CockpitConfig): SettingsRow[] {
45
+ return [
46
+ {
47
+ key: "enabled",
48
+ accel: "e",
49
+ label: "enabled",
50
+ value: config.enabled ? "on" : "off",
51
+ next: config.enabled ? "off" : "on",
52
+ },
53
+ {
54
+ key: "agentsMode",
55
+ accel: "a",
56
+ label: "agents",
57
+ value: config.agentsMode,
58
+ next: cycle(VIEW_MODES, config.agentsMode),
59
+ },
60
+ {
61
+ key: "todoMode",
62
+ accel: "t",
63
+ label: "todo",
64
+ value: config.todoMode,
65
+ next: cycle(VIEW_MODES, config.todoMode),
66
+ },
67
+ {
68
+ key: "todoExpanded",
69
+ accel: "x",
70
+ label: "todo expand",
71
+ value: config.todoExpanded ? "yes" : "no",
72
+ next: config.todoExpanded ? "no" : "yes",
73
+ },
74
+ {
75
+ key: "hideNativeAgents",
76
+ accel: "n",
77
+ label: "hide native",
78
+ value: config.hideNativeAgents ? "yes" : "no",
79
+ next: config.hideNativeAgents ? "no" : "yes",
80
+ },
81
+ {
82
+ key: "icons",
83
+ accel: "i",
84
+ label: "icons",
85
+ value: config.icons.mode,
86
+ next: cycle(ICON_MODES, config.icons.mode),
87
+ },
88
+ {
89
+ key: "theme",
90
+ accel: "h",
91
+ label: "theme",
92
+ value: themeLabel(config.theme),
93
+ // Not a cycle: this row opens the /theme picker, which previews live and
94
+ // reverts on Esc. Blind-cycling a name list could not do either.
95
+ //
96
+ // Worded as an affordance, not as a command to go type: every other row's
97
+ // `next` is the value Enter produces, and "open /theme" read as "leave this
98
+ // panel and run something else" when Enter opens it right here. The ellipsis
99
+ // is the usual terminal signal for "this one opens a dialog".
100
+ next: "picker…",
101
+ },
102
+ ];
103
+ }
104
+
105
+ /** Apply the row's cycle to the config, returning a new config object. */
106
+ export function applyRow(config: CockpitConfig, key: string): CockpitConfig {
107
+ switch (key) {
108
+ case "enabled":
109
+ return { ...config, enabled: !config.enabled };
110
+ case "agentsMode":
111
+ return { ...config, agentsMode: cycle(VIEW_MODES, config.agentsMode) };
112
+ case "todoMode":
113
+ return { ...config, todoMode: cycle(VIEW_MODES, config.todoMode) };
114
+ case "todoExpanded":
115
+ return { ...config, todoExpanded: !config.todoExpanded };
116
+ case "hideNativeAgents":
117
+ return { ...config, hideNativeAgents: !config.hideNativeAgents };
118
+ case "icons":
119
+ return { ...config, icons: { mode: cycle(ICON_MODES, config.icons.mode) } };
120
+ // "theme" is intentionally absent: the row hands off to the /theme picker,
121
+ // which owns both the preview and the write-through to pi's settings.
122
+ default:
123
+ return config;
124
+ }
125
+ }
126
+
127
+ export function rowKeyForAccel(rows: readonly SettingsRow[], accel: string): string | undefined {
128
+ return rows.find((row) => row.accel === accel)?.key;
129
+ }
@@ -0,0 +1,121 @@
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 { renderBashBgSummary } from "./bash-bg-widget.ts";
6
+ import { fitLineByPriority, type PrioritizedSegment } from "./layout.ts";
7
+ import { resolveGlyphs, spinFrame } from "./icons.ts";
8
+ import { panelRows } from "./viewport.ts";
9
+ import type { AgentRow, BashBgJob, CockpitConfig, TodoItem } from "./types.ts";
10
+
11
+ export interface TodoWidgetDeps {
12
+ getTodos: () => TodoItem[];
13
+ getConfig: () => CockpitConfig;
14
+ /** False when no redraw loop is running, so spinners must not freeze mid-cycle. */
15
+ isAnimating?: () => boolean;
16
+ }
17
+
18
+ export interface AgentWidgetDeps {
19
+ getAgents: () => AgentRow[];
20
+ getBashBgJobs: () => BashBgJob[];
21
+ getConfig: () => CockpitConfig;
22
+ isRunning: () => boolean;
23
+ isAnimating?: () => boolean;
24
+ }
25
+
26
+ const UTILS: WidthUtils = { measure: visibleWidth, clip: truncateToWidth };
27
+
28
+ // The TUI exposes the live terminal size, but a widget must never fail to render
29
+ // because a host handed it a stub without one.
30
+ export function terminalRows(tui: TUI): number | undefined {
31
+ try {
32
+ const rows = tui.terminal?.rows;
33
+ return typeof rows === "number" && rows > 0 ? rows : undefined;
34
+ } catch {
35
+ return undefined;
36
+ }
37
+ }
38
+
39
+ // Todo widget: pinned above the editor (setWidget "cockpit-stack", aboveEditor).
40
+ export function makeTodoWidget(deps: TodoWidgetDeps) {
41
+ return (tui: TUI, theme: Theme) => {
42
+ const paint: PaintTheme = theme;
43
+ return {
44
+ render(width: number): string[] {
45
+ const cfg = deps.getConfig();
46
+ const todos = deps.getTodos();
47
+ if (todos.length === 0) return [];
48
+ const g = resolveGlyphs(cfg.icons.mode);
49
+ const now = Date.now();
50
+ const spin = spinFrame(g, now, deps.isAnimating?.() ?? true);
51
+ const opts = {
52
+ glyphs: g,
53
+ spin,
54
+ now,
55
+ expanded: cfg.todoExpanded,
56
+ maxRows: panelRows(terminalRows(tui)),
57
+ };
58
+ return renderTodos(todos, cfg.todoExpanded ? "list" : cfg.todoMode, width, paint, UTILS, opts);
59
+ },
60
+ invalidate(): void {},
61
+ dispose(): void {},
62
+ };
63
+ };
64
+ }
65
+
66
+ // Agent widget: pinned below the editor, near the input box (setWidget "cockpit-agents", belowEditor).
67
+ export function makeAgentWidget(deps: AgentWidgetDeps) {
68
+ return (tui: TUI, theme: Theme) => {
69
+ const paint: PaintTheme = theme;
70
+ return {
71
+ render(width: number): string[] {
72
+ const cfg = deps.getConfig();
73
+ const agents = deps.getAgents();
74
+ const bashBgJobs = deps.getBashBgJobs();
75
+ const g = resolveGlyphs(cfg.icons.mode);
76
+ const now = Date.now();
77
+ const animating = deps.isAnimating?.() ?? true;
78
+ const spin = spinFrame(g, now, animating);
79
+ const bashBgLines = renderBashBgSummary(bashBgJobs, width, paint, UTILS, {
80
+ glyphs: g,
81
+ spin,
82
+ now,
83
+ });
84
+ if (agents.length === 0) return bashBgLines;
85
+ const running = deps.isRunning();
86
+ // The panel budget covers everything this widget prints, so the header
87
+ // and the background strip are charged to it before the roster is.
88
+ const panel = panelRows(terminalRows(tui));
89
+ const rosterRows = panel === undefined
90
+ ? undefined
91
+ : Math.max(1, panel - 1 - bashBgLines.length);
92
+ const opts = { glyphs: g, spin, now, maxRows: rosterRows };
93
+ const dot = theme.fg(running ? "success" : "muted", running ? g.dotRunning : g.dotIdle);
94
+ const failedCount = agents.filter((a) => a.status === "failed").length;
95
+ const runCount = agents.filter((a) => a.status === "running" || a.status === "retrying").length;
96
+ const pendingCount = agents.filter((a) => a.status === "pending").length;
97
+ const sleepingCount = agents.filter((a) => a.status === "sleeping").length;
98
+ // This header owns the roster summary, so compact mode must not print
99
+ // its own count line right underneath saying the same thing.
100
+ const headerSegs: PrioritizedSegment[] = [
101
+ { text: dot, priority: 100, clippable: false },
102
+ { text: theme.fg("muted", "Agents"), priority: 90, clippable: false },
103
+ ];
104
+ if (failedCount) {
105
+ headerSegs.push({ text: theme.fg("error", `${failedCount} failed`), priority: 95, clippable: false });
106
+ }
107
+ if (runCount) headerSegs.push({ text: theme.fg("dim", `${runCount} running`), priority: 80, clippable: false });
108
+ if (pendingCount) headerSegs.push({ text: theme.fg("dim", `${pendingCount} pending`), priority: 60, clippable: false });
109
+ if (sleepingCount) headerSegs.push({ text: theme.fg("dim", `${sleepingCount} sleeping`), priority: 50, clippable: false });
110
+ const lines: string[] = [];
111
+ // Was the one line in the package pushed without any width clipping.
112
+ lines.push(fitLineByPriority(headerSegs, width, UTILS, theme.fg("dim", g.separator), g.ellipsis));
113
+ lines.push(...renderAgents(agents, cfg.agentsMode, width, paint, UTILS, { ...opts, withHead: false }));
114
+ lines.push(...bashBgLines);
115
+ return lines;
116
+ },
117
+ invalidate(): void {},
118
+ dispose(): void {},
119
+ };
120
+ };
121
+ }
@@ -0,0 +1,255 @@
1
+ // The /theme picker.
2
+ //
3
+ // pi has no /theme command — themes live behind /settings → Theme. That built-in
4
+ // submenu is good (live preview, cancel restores, automatic light/dark pairing),
5
+ // so this exists to give the same quality one keystroke away, not to replace it.
6
+ //
7
+ // The whole design turns on one asymmetry in the host API:
8
+ //
9
+ // setTheme("nord") -> setThemeName -> ALSO writes settings (persists)
10
+ // setTheme(themeInstance) -> setThemeInstance -> in-memory only (does NOT persist)
11
+ //
12
+ // So previewing by *instance* is free: the user's stored setting is untouched no
13
+ // matter how many themes they scroll past, and cancelling restores the instance
14
+ // that was live when the picker opened. Only Enter commits, and only Enter takes
15
+ // the string path that writes through to pi's settings.
16
+
17
+ import type { Theme } from "@earendil-works/pi-coding-agent";
18
+ import type { Component } from "@earendil-works/pi-tui";
19
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
20
+ import { sanitizeExtensionStatusText } from "./extension-status.ts";
21
+ import type { IconGlyphs } from "./icons.ts";
22
+ import { fitLineByPriority, visibleStart, type PrioritizedSegment, type WidthUtils } from "./layout.ts";
23
+ import { overlayListRows } from "./viewport.ts";
24
+
25
+ const UTILS: WidthUtils = { measure: visibleWidth, clip: truncateToWidth };
26
+
27
+ // Border, title, separator, two footer hints.
28
+ const CARD_CHROME_ROWS = 6;
29
+
30
+ export interface ThemePickerParams {
31
+ /** Theme names the host knows about, in host order. */
32
+ themes: readonly string[];
33
+ /** Name to park the cursor on — the active theme where the host reveals it. */
34
+ initial: string;
35
+ /**
36
+ * A **real** Theme instance captured when the picker opened — the cancel target.
37
+ *
38
+ * MUST NOT be the host's exported `theme`: that is a Proxy which forwards every
39
+ * property read to `globalThis[THEME_KEY]`, i.e. to whatever is current. Handing
40
+ * it back to setThemeInstance stores the proxy inside the slot the proxy reads
41
+ * from, and the next colour lookup recurses until the stack blows.
42
+ *
43
+ * `undefined` when the host would not name its active theme, in which case there
44
+ * is no safe way to put it back and the picker refuses to preview at all.
45
+ */
46
+ original: Theme | undefined;
47
+ loadTheme: (name: string) => Theme | undefined;
48
+ /** In-memory apply. Must not persist. */
49
+ previewTheme: (theme: Theme) => void;
50
+ /** Persisting apply by name. */
51
+ commitTheme: (name: string) => { success: boolean; error?: string };
52
+ close: () => void;
53
+ requestRender: () => void;
54
+ getTerminalRows: () => number | undefined;
55
+ theme: Theme;
56
+ glyphs: IconGlyphs;
57
+ }
58
+
59
+ /**
60
+ * Name of the theme that is actually live, or undefined if the host won't say.
61
+ *
62
+ * The Theme a component is handed is pi's exported `theme`, which is a Proxy
63
+ * forwarding every read to the current global theme — so reading `.name` off it
64
+ * is a *live* reading, and the closest thing the extension API has to a reader
65
+ * for the current theme. It still does not expose the stored setting, which under
66
+ * Automatic mode is a "light/dark" pair rather than a single name.
67
+ *
68
+ * Being a Proxy is also why the caller must resolve a real instance from this
69
+ * name before using it as a cancel target: handing the Proxy itself back to
70
+ * setThemeInstance stores it in the slot it reads from, and the next colour
71
+ * lookup recurses until the stack blows.
72
+ */
73
+ export function activeThemeName(theme: Theme): string | undefined {
74
+ try {
75
+ const name = (theme as { name?: unknown }).name;
76
+ return typeof name === "string" && name !== "" ? name : undefined;
77
+ } catch {
78
+ // The Proxy throws outright when no theme has been initialised.
79
+ return undefined;
80
+ }
81
+ }
82
+
83
+ /** Cursor position for a name that may be absent from the list. */
84
+ export function initialIndex(themes: readonly string[], initial: string): number {
85
+ const found = themes.indexOf(initial);
86
+ return found === -1 ? 0 : found;
87
+ }
88
+
89
+ export class ThemePicker implements Component {
90
+ private selected: number;
91
+ private previewed = false;
92
+ private error: string | undefined;
93
+
94
+ /**
95
+ * Display names, stripped of control characters.
96
+ *
97
+ * Theme names come from theme files on disk, so they are third-party text
98
+ * reaching a box-drawn card. A newline in one would break the card open, and
99
+ * no width assertion would notice — a newline measures zero columns.
100
+ * `params.themes` stays authoritative for the value handed back to the host.
101
+ */
102
+ private readonly labels: readonly string[];
103
+
104
+ constructor(private readonly params: ThemePickerParams) {
105
+ this.labels = params.themes.map((name) => sanitizeExtensionStatusText(name));
106
+ this.selected = initialIndex(params.themes, params.initial);
107
+ // Show the highlighted theme immediately, so the picker opens already
108
+ // demonstrating what it does rather than waiting for a keypress.
109
+ this.preview();
110
+ }
111
+
112
+ invalidate(): void {}
113
+ dispose(): void {}
114
+
115
+ /** True when there is no instance to put back, so previewing would be one-way. */
116
+ private get previewBlocked(): boolean {
117
+ return this.params.original === undefined;
118
+ }
119
+
120
+ private preview(): void {
121
+ // Never preview what cannot be undone. Without a real original instance the
122
+ // only way back would be the persisting string form, which is the one thing
123
+ // scrolling a list must never do.
124
+ if (this.previewBlocked) return;
125
+ const name = this.params.themes[this.selected];
126
+ if (!name) return;
127
+ const instance = this.params.loadTheme(name);
128
+ if (!instance) {
129
+ this.error = `${this.labels[this.selected]} could not be loaded`;
130
+ return;
131
+ }
132
+ this.error = undefined;
133
+ this.previewed = true;
134
+ this.params.previewTheme(instance);
135
+ }
136
+
137
+ private cancel(): void {
138
+ // Restore by instance: nothing was persisted, so pi's stored setting — a
139
+ // single name or an automatic "light/dark" pair — is exactly as it was.
140
+ const { original } = this.params;
141
+ if (this.previewed && original) this.params.previewTheme(original);
142
+ this.params.close();
143
+ }
144
+
145
+ private commit(): void {
146
+ const name = this.params.themes[this.selected];
147
+ if (!name) {
148
+ this.cancel();
149
+ return;
150
+ }
151
+ const result = this.params.commitTheme(name);
152
+ if (!result.success) {
153
+ this.error = sanitizeExtensionStatusText(
154
+ result.error ?? `could not apply ${this.labels[this.selected]}`,
155
+ );
156
+ this.params.requestRender();
157
+ return;
158
+ }
159
+ this.params.close();
160
+ }
161
+
162
+ handleInput(data: string): void {
163
+ if (data === "\x1b") {
164
+ this.cancel();
165
+ return;
166
+ }
167
+ if (data === "\r" || data === "\n") {
168
+ this.commit();
169
+ return;
170
+ }
171
+ const count = this.params.themes.length;
172
+ if (count === 0) return;
173
+ // Arrows only. Plain letters stay inert here, per the terminal rule that
174
+ // reserves them until an explicit filter mode is entered; `/theme <name>`
175
+ // already covers the "I know what I want" path.
176
+ if (data === "\x1b[A") this.move(-1);
177
+ else if (data === "\x1b[B") this.move(1);
178
+ else if (data === "\x1b[5~") this.move(-this.pageSize());
179
+ else if (data === "\x1b[6~") this.move(this.pageSize());
180
+ else if (data === "\x1b[H") this.jump(0);
181
+ else if (data === "\x1b[F") this.jump(count - 1);
182
+ }
183
+
184
+ private move(delta: number): void {
185
+ const count = this.params.themes.length;
186
+ this.selected = (this.selected + delta % count + count) % count;
187
+ this.preview();
188
+ this.params.requestRender();
189
+ }
190
+
191
+ private jump(index: number): void {
192
+ this.selected = index;
193
+ this.preview();
194
+ this.params.requestRender();
195
+ }
196
+
197
+ private pageSize(): number {
198
+ return overlayListRows(this.params.getTerminalRows(), CARD_CHROME_ROWS);
199
+ }
200
+
201
+ render(width: number): string[] {
202
+ const { theme: t, glyphs: g, themes } = this.params;
203
+ const w = Math.max(1, Math.min(width, 60));
204
+ const lines = [
205
+ t.fg("text", "theme"),
206
+ t.fg("borderMuted", "─".repeat(w)),
207
+ ];
208
+
209
+ if (themes.length === 0) {
210
+ lines.push(t.fg("muted", "no themes registered"));
211
+ } else {
212
+ const page = this.pageSize();
213
+ const start = visibleStart(this.selected, themes.length, page);
214
+ for (let index = start; index < Math.min(themes.length, start + page); index++) {
215
+ const active = index === this.selected;
216
+ const marker = active ? t.fg("accent", g.selectMarker) : " ";
217
+ const name = t.fg(active ? "text" : "muted", this.labels[index]);
218
+ lines.push(truncateToWidth(`${marker} ${name}`, w, "…"));
219
+ }
220
+ const hiddenAbove = start;
221
+ const hiddenBelow = Math.max(0, themes.length - (start + page));
222
+ if (hiddenAbove || hiddenBelow) {
223
+ lines.push(t.fg("dim", ` ${g.upDown} ${this.selected + 1}/${themes.length}`));
224
+ }
225
+ }
226
+
227
+ if (this.previewBlocked) {
228
+ lines.push(t.fg("warning", `${g.blocked} pi did not name its active theme — no preview`));
229
+ }
230
+ if (this.error) lines.push(t.fg("error", `${g.cross} ${this.error}`));
231
+
232
+ // Enter is the only key that touches the stored setting, so the footer says
233
+ // which key persists rather than leaving the user to find out. When preview
234
+ // is off the wording changes with it — promising a revert we cannot perform
235
+ // would be worse than the missing feature.
236
+ const hints: PrioritizedSegment[] = this.previewBlocked
237
+ ? [
238
+ { text: t.fg("dim", `${g.upDown} move`), priority: 90, clippable: false },
239
+ { text: t.fg("dim", "Enter apply"), priority: 100, clippable: false },
240
+ { text: t.fg("dim", "Esc close"), priority: 95, clippable: false },
241
+ ]
242
+ : [
243
+ { text: t.fg("dim", `${g.upDown} preview`), priority: 90, clippable: false },
244
+ { text: t.fg("dim", "Enter save"), priority: 100, clippable: false },
245
+ { text: t.fg("dim", "Esc revert"), priority: 95, clippable: false },
246
+ ];
247
+ lines.push(fitLineByPriority(hints, w, UTILS, t.fg("dim", " · "), g.ellipsis));
248
+ lines.push(truncateToWidth(
249
+ t.fg("dim", "/settings pairs a light and a dark theme"),
250
+ w,
251
+ "…",
252
+ ));
253
+ return lines.map((line) => truncateToWidth(line, width, "…"));
254
+ }
255
+ }
@@ -0,0 +1,123 @@
1
+ import { sanitizeExtensionStatusText } from "./extension-status.ts";
2
+ import type { TodoActor, TodoItem, TodoSkill, TodoState } from "./types.ts";
3
+ import { TODO_STATE_ENTRY_TYPE } from "./types.ts";
4
+
5
+ // The todo snapshot is LLM-authored. A subject carrying a raw newline or an ANSI
6
+ // escape measures zero columns, so it slips past every width guard and then splits
7
+ // or repaints the terminal. Strip control characters where the data enters.
8
+ function clean(raw: string): string {
9
+ return sanitizeExtensionStatusText(raw);
10
+ }
11
+
12
+ // Minimal structural view of a session entry — matches the shape todo.ts reads back
13
+ // (type:"custom" + customType + data), without importing core session types.
14
+ interface RawEntry {
15
+ readonly type?: string;
16
+ readonly customType?: string;
17
+ readonly data?: unknown;
18
+ }
19
+
20
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
21
+ return value && typeof value === "object" && !Array.isArray(value)
22
+ ? (value as Record<string, unknown>)
23
+ : undefined;
24
+ }
25
+
26
+ function asStringArray(value: unknown): string[] {
27
+ if (!Array.isArray(value)) return [];
28
+ return [...new Set(value.filter((item): item is string => typeof item === "string" && item.length > 0))];
29
+ }
30
+
31
+ function asActor(value: unknown): TodoActor | undefined {
32
+ const actor = asRecord(value);
33
+ if (typeof actor?.id !== "string" || typeof actor.label !== "string") return undefined;
34
+ return { id: clean(actor.id), label: clean(actor.label) };
35
+ }
36
+
37
+ function asSkills(value: unknown): TodoSkill[] {
38
+ if (!Array.isArray(value)) return [];
39
+ const skills: TodoSkill[] = [];
40
+ for (const raw of value) {
41
+ const skill = asRecord(raw);
42
+ if (typeof skill?.name !== "string" || skill.name.length === 0) continue;
43
+ skills.push({
44
+ name: clean(skill.name),
45
+ ...(typeof skill.role === "string" ? { role: clean(skill.role) } : {}),
46
+ });
47
+ }
48
+ return skills;
49
+ }
50
+
51
+ // Map the todo tool's status strings onto our four display states.
52
+ // "deleted" is returned verbatim so the caller can drop the task.
53
+ export function mapStatus(raw: unknown): TodoState | "deleted" {
54
+ switch (raw) {
55
+ case "in_progress":
56
+ case "in-progress":
57
+ return "in_progress";
58
+ case "completed":
59
+ case "complete":
60
+ return "completed";
61
+ case "blocked":
62
+ return "blocked";
63
+ case "deleted":
64
+ return "deleted";
65
+ case "pending":
66
+ default:
67
+ return "pending";
68
+ }
69
+ }
70
+
71
+ // Rebuilt from the durable todo-state snapshot the todo tool persists after every
72
+ // mutation (tools/todo.ts:1091). We do NOT parse tool args: create-args carry no
73
+ // assigned ids, so an incremental rebuild could never match later updates by id.
74
+ // The snapshot is authoritative for both cold start and live refresh.
75
+ export class TodoStore {
76
+ private items = new Map<string, TodoItem>();
77
+
78
+ hydrateFromEntries(entries: readonly RawEntry[]): void {
79
+ let entry: RawEntry | undefined;
80
+ for (let i = entries.length - 1; i >= 0; i--) {
81
+ const e = entries[i];
82
+ if (e && e.type === "custom" && e.customType === TODO_STATE_ENTRY_TYPE) {
83
+ entry = e;
84
+ break;
85
+ }
86
+ }
87
+ const rawTasks = asRecord(asRecord(entry?.data)?.tasks);
88
+ const next = new Map<string, TodoItem>();
89
+ if (rawTasks) {
90
+ for (const [id, raw] of Object.entries(rawTasks)) {
91
+ const r = asRecord(raw);
92
+ const status = mapStatus(r?.status);
93
+ if (status === "deleted") continue;
94
+ const item: TodoItem = {
95
+ id,
96
+ subject: typeof r?.subject === "string" ? clean(r.subject) : "",
97
+ status,
98
+ blockedBy: asStringArray(r?.blockedBy),
99
+ skills: asSkills(r?.skills),
100
+ };
101
+ const createdBy = asActor(r?.createdBy);
102
+ const assignee = asActor(r?.assignee);
103
+ if (createdBy) item.createdBy = createdBy;
104
+ if (assignee) item.assignee = assignee;
105
+ if (typeof r?.updatedAt === "number") item.updatedAt = r.updatedAt;
106
+ next.set(id, item);
107
+ }
108
+ }
109
+ this.items = next;
110
+ }
111
+
112
+ snapshot(): TodoItem[] {
113
+ return [...this.items.values()];
114
+ }
115
+
116
+ get size(): number {
117
+ return this.items.size;
118
+ }
119
+
120
+ clear(): void {
121
+ this.items.clear();
122
+ }
123
+ }