@getpipher/armory-todo 0.1.0 → 0.2.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,651 @@
1
+ # SPEC-3: Interactive `/todo` TUI Panel
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Turn `/todo` from a series of typed commands into a real interactive triage surface — a pi-tui panel with box tabs (Active / Parked / Archive / Config), a filter input, an action submenu on Enter, and live-persist config editing. Adopts the `@getpipher/cursor` + `@getpipher/vision` pattern.
6
+
7
+ **Architecture:** A `TodoPanel` class (Container subclass, like `VisionModelPicker`) in `src/panel.ts` encapsulates the whole panel — box tabs, filter Input, SelectList, action submenu, Config SettingsList. Pure data helpers in `src/panel-data.ts` are unit-testable; the TUI component itself is manual-gate. The extension wires `/todo` (no-arg) → `ctx.ui.custom()` in TUI mode; non-TUI falls back to the existing text notify.
8
+
9
+ **Tech Stack:** `@earendil-works/pi-tui` (Container, Spacer, Text, SelectList, Input, SettingsList, matchesKey, SelectItem, SettingItem, Component) + `@earendil-works/pi-coding-agent` (DynamicBorder, Theme). Zero new runtime deps.
10
+
11
+ **Design doc:** `docs/superpowers/specs/2026-07-20-lifecycle-boxes-prune-design.md` §10 (slash command + interactive panel).
12
+
13
+ ## Global Constraints
14
+
15
+ - **TUI-only panel**; non-TUI (`ctx.mode !== "tui"`) falls back to `ctx.ui.notify` text status (the existing behavior).
16
+ - **Live apply + persist** on each change (writes `todo.json` / `todo-archive.json` / `todo.config.json` immediately, like `/vision` writes `vision.json`).
17
+ - **Escape exits** the panel; arrow keys navigate; Enter selects/edits/opens sub-picker; Tab cycles boxes.
18
+ - **Power-user typed subcommands retained** — `/todo park <id>`, `/todo prune`, etc. all still work; only `/todo` (no-arg) opens the panel.
19
+ - **No new runtime deps.** pi-tui + pi-coding-agent are already peer deps.
20
+ - 2-space indent, no TODO/FIXME.
21
+
22
+ ---
23
+
24
+ ## File Structure
25
+
26
+ | File | Responsibility | Status |
27
+ |---|---|---|
28
+ | `src/panel-data.ts` | Pure helpers: `todoToItem(t)`, `archiveSummaryToItems(s)`, `actionsForTodo(t)`, `configToSettingItems(cfg)`. Unit-testable. | Create |
29
+ | `src/panel.ts` | `TodoPanel` class (Container) — the full interactive panel. Manual-gate. | Create |
30
+ | `extensions/todo.ts` | `/todo` (no-arg) → `ctx.ui.custom(panel)` in TUI mode; non-TUI fallback unchanged. | Modify |
31
+ | `test/panel-data.test.mts` | Pure helper tests. | Create |
32
+
33
+ ---
34
+
35
+ ## Task 1: `panel-data.ts` — pure helpers + tests
36
+
37
+ **Files:**
38
+ - Create: `src/panel-data.ts`, `test/panel-data.test.mts`
39
+
40
+ **Interfaces:**
41
+ - Produces: `todoToItem(t: Todo): SelectItem`, `archiveSummaryToItems(s: ArchiveSummary): SelectItem[]`, `actionsForTodo(t: Todo): { label: string; action: string }[]`, `configToSettingItems(cfg: TodoConfig): SettingItem[]`.
42
+
43
+ - [ ] **Step 1: Write the failing test**
44
+
45
+ Create `test/panel-data.test.mts`:
46
+
47
+ ```ts
48
+ let passed = 0;
49
+ let failed = 0;
50
+ function ok(name: string, cond: boolean, extra = ""): void {
51
+ if (cond) { passed++; } else { failed++; console.error(` ✗ ${name} ${extra}`); }
52
+ }
53
+ function eq<T>(name: string, got: T, want: T): void {
54
+ ok(name, got === want, `(got ${JSON.stringify(got)} want ${JSON.stringify(want)})`);
55
+ }
56
+
57
+ const { todoToItem, archiveSummaryToItems, actionsForTodo, configToSettingItems } = await import("../src/panel-data.ts");
58
+ import type { Todo } from "../src/todo-store.ts";
59
+
60
+ const t: Todo = { id: "td-x1", text: "ship the thing", project: "nuntius", tags: ["mcp"], priority: "critical", status: "open", source: "", createdAt: "2026-07-01T00:00:00Z", updatedAt: "2026-07-01T00:00:00Z", closedAt: null };
61
+
62
+ // todoToItem
63
+ const item = todoToItem(t);
64
+ eq("item value is id", item.value, "td-x1");
65
+ ok("item label has priority", item.label.includes("critical"));
66
+ ok("item label has text", item.label.includes("ship the thing"));
67
+ ok("item label has project", item.label.includes("nuntius"));
68
+
69
+ // actionsForTodo — open todo: complete, park, edit, delete
70
+ const openActions = actionsForTodo({ ...t, status: "open" });
71
+ ok("open has complete", openActions.some((a) => a.action === "complete"));
72
+ ok("open has park", openActions.some((a) => a.action === "park"));
73
+ ok("open has edit", openActions.some((a) => a.action === "edit"));
74
+ ok("open has delete", openActions.some((a) => a.action === "delete"));
75
+ ok("open no restore", !openActions.some((a) => a.action === "restore"));
76
+
77
+ // parked todo: un-park (open), complete, delete
78
+ const parkedActions = actionsForTodo({ ...t, status: "parked" });
79
+ ok("parked has open", parkedActions.some((a) => a.action === "open"));
80
+ ok("parked has complete", parkedActions.some((a) => a.action === "complete"));
81
+
82
+ // done todo: restore (if archived context) — but actionsForTodo works on live status
83
+ const doneActions = actionsForTodo({ ...t, status: "done" });
84
+ ok("done has restore", doneActions.some((a) => a.action === "restore"));
85
+
86
+ // archiveSummaryToItems
87
+ const summaryItems = archiveSummaryToItems({ total: 5, byProject: { nuntius: 3, "(none)": 2 }, byMonth: { "2026-07": 4, "2026-06": 1 } });
88
+ ok("summary has total item", summaryItems.some((i) => i.label.includes("total") || i.value === "total"));
89
+ ok("summary has project items", summaryItems.some((i) => i.label.includes("nuntius")));
90
+ ok("summary has month items", summaryItems.some((i) => i.label.includes("2026-07")));
91
+
92
+ // configToSettingItems
93
+ const { DEFAULT_CONFIG } = await import("../src/config.ts");
94
+ const settings = configToSettingItems(DEFAULT_CONFIG);
95
+ ok("settings has defaultAgeDays", settings.some((s) => s.id === "defaultAgeDays"));
96
+ ok("settings has activeMaxOpen", settings.some((s) => s.id === "activeMaxOpen"));
97
+ ok("settings has archiveOldDays", settings.some((s) => s.id === "archiveOldDays"));
98
+
99
+ console.log(`\n${passed} passed, ${failed} failed`);
100
+ if (failed > 0) process.exit(1);
101
+ ```
102
+
103
+ - [ ] **Step 2: Run test to verify it fails**
104
+
105
+ Run: `node test/panel-data.test.mts`
106
+ Expected: FAIL — `Cannot find module '../src/panel-data.ts'`
107
+
108
+ - [ ] **Step 3: Write minimal implementation**
109
+
110
+ Create `src/panel-data.ts`:
111
+
112
+ ```ts
113
+ // Pure data helpers for the /todo TUI panel (SPEC-3). Kept separate from
114
+ // panel.ts so they're unit-testable without a terminal — the panel component
115
+ // itself is manual-gate only.
116
+
117
+ import type { SelectItem, SettingItem } from "@earendil-works/pi-tui";
118
+ import type { Todo } from "./todo-store.ts";
119
+ import type { ArchiveSummary } from "./archive.ts";
120
+ import type { TodoConfig } from "./config.ts";
121
+
122
+ /** Format a todo as a SelectList item: "[id] (prio)⏵ text (project)". */
123
+ export function todoToItem(t: Todo): SelectItem {
124
+ const pin = t.status === "in_progress" ? " ⏵" : "";
125
+ const proj = t.project ? ` (${t.project})` : "";
126
+ return {
127
+ value: t.id,
128
+ label: `[${t.id}] (${t.priority})${pin} ${t.text}${proj}`,
129
+ };
130
+ }
131
+
132
+ /** Format an archive summary into SelectList items (project + month buckets). */
133
+ export function archiveSummaryToItems(s: ArchiveSummary): SelectItem[] {
134
+ const items: SelectItem[] = [{ value: "total", label: `Total: ${s.total}` }];
135
+ for (const [p, n] of Object.entries(s.byProject)) items.push({ value: `project:${p}`, label: ` project ${p}: ${n}` });
136
+ for (const [m, n] of Object.entries(s.byMonth)) items.push({ value: `month:${m}`, label: ` ${m}: ${n}` });
137
+ return items;
138
+ }
139
+
140
+ /** Available actions for a todo, depending on its status. */
141
+ export function actionsForTodo(t: Todo): { label: string; action: string }[] {
142
+ const actions: { label: string; action: string }[] = [];
143
+ if (t.status === "open" || t.status === "in_progress") {
144
+ actions.push({ label: "Complete", action: "complete" });
145
+ actions.push({ label: "Park (defer)", action: "park" });
146
+ }
147
+ if (t.status === "parked") {
148
+ actions.push({ label: "Re-activate (open)", action: "open" });
149
+ actions.push({ label: "Complete", action: "complete" });
150
+ }
151
+ if (t.status === "done" || t.status === "cancelled") {
152
+ actions.push({ label: "Restore (from archive)", action: "restore" });
153
+ }
154
+ actions.push({ label: "Edit text", action: "edit" });
155
+ actions.push({ label: "Delete (cancel)", action: "delete" });
156
+ return actions;
157
+ }
158
+
159
+ /** Config → SettingsList rows (editable, live-persist). */
160
+ export function configToSettingItems(cfg: TodoConfig): SettingItem[] {
161
+ return [
162
+ { id: "defaultAgeDays", label: "Prune age (days)", currentValue: String(cfg.prune.defaultAgeDays), values: ["3", "7", "14", "30"], description: "Done/cancelled older than this → archive on prune." },
163
+ { id: "hardAgeDays", label: "Hard-prune age (days)", currentValue: String(cfg.prune.hardAgeDays), values: ["90", "180", "365"], description: "Archive items older than this → suggested for hard-prune." },
164
+ { id: "activeMaxOpen", label: "Active max open", currentValue: String(cfg.health.activeMaxOpen), values: ["10", "15", "20", "25"], description: "Bloat flag when open+in_progress exceeds this." },
165
+ { id: "activeStaleDays", label: "Active stale (days)", currentValue: String(cfg.health.activeStaleDays), values: ["14", "30", "60"], description: "Bloat flag when open todos untouched longer than this." },
166
+ { id: "parkedMax", label: "Parked max", currentValue: String(cfg.health.parkedMax), values: ["5", "10", "15"], description: "Bloat flag when parked exceeds this." },
167
+ { id: "parkedStaleDays", label: "Parked stale (days)", currentValue: String(cfg.health.parkedStaleDays), values: ["30", "60", "90"], description: "Bloat flag when parked longer than this." },
168
+ { id: "archiveMax", label: "Archive max", currentValue: String(cfg.health.archiveMax), values: ["100", "200", "500"], description: "Bloat flag when archive exceeds this." },
169
+ { id: "archiveOldDays", label: "Archive old (days)", currentValue: String(cfg.health.archiveOldDays), values: ["90", "180", "365"], description: "Bloat flag when archive items older than this." },
170
+ ];
171
+ }
172
+ ```
173
+
174
+ - [ ] **Step 4: Run test to verify it passes**
175
+
176
+ Run: `node test/panel-data.test.mts`
177
+ Expected: PASS
178
+
179
+ - [ ] **Step 5: Commit**
180
+
181
+ ```bash
182
+ git add src/panel-data.ts test/panel-data.test.mts
183
+ git commit -m "feat(panel-data): pure helpers for the /todo TUI panel (todoToItem, actionsForTodo, configToSettingItems)"
184
+ ```
185
+
186
+ ---
187
+
188
+ ## Task 2: `TodoPanel` class — scaffold + box tabs + active box list
189
+
190
+ **Files:**
191
+ - Create: `src/panel.ts`
192
+
193
+ This task builds the panel shell (DynamicBorder framing, title, box-tab state, footer hints) + the Active box (SelectList + Input filter). Parked/Archive/Config boxes are added in Tasks 3–5.
194
+
195
+ **Interfaces:**
196
+ - Consumes: `listTodos`, `parkTodo`, `completeTodo`, `deleteTodo`, `updateTodo`, `restoreTodo` from the store modules; `archiveSummary`, `listArchived` from archive; `loadConfig`, `saveConfig` from config; `healthReport` from health; the pure helpers from `panel-data.ts`.
197
+ - Produces: `TodoPanel` class (extends Container) + `createTodoPanel(opts)` factory used by the extension.
198
+
199
+ - [ ] **Step 1: (manual gate — TUI component)**
200
+
201
+ - [ ] **Step 2: (skipped)**
202
+
203
+ - [ ] **Step 3: Write minimal implementation**
204
+
205
+ Create `src/panel.ts`:
206
+
207
+ ```ts
208
+ // Interactive /todo TUI panel (SPEC-3) — a Container subclass adopting the
209
+ // @getpipher/cursor + @getpipher/vision pattern. Box tabs (Active / Parked /
210
+ // Archive / Config), a filter Input, a SelectList, an action submenu on Enter,
211
+ // and a SettingsList for config. Live-persist on every change. Non-TUI modes
212
+ // fall back to ctx.ui.notify (handled by the extension, not here).
213
+ //
214
+ // Manual-gate: the pi-tui components need a real terminal. The pure data
215
+ // helpers (panel-data.ts) are unit-tested; this component is verified in a
216
+ // real pi session.
217
+
218
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
219
+ import {
220
+ Container,
221
+ Input,
222
+ SelectList,
223
+ SettingsList,
224
+ Spacer,
225
+ Text,
226
+ matchesKey,
227
+ type Component,
228
+ type SelectItem,
229
+ type Theme,
230
+ } from "@earendil-works/pi-tui";
231
+ import { listTodos, parkTodo, completeTodo, deleteTodo, updateTodo, type Todo, type Status } from "./todo-store.ts";
232
+ import { restoreTodo, archiveSummary, listArchived, type ArchiveSummary } from "./archive.ts";
233
+ import { loadConfig, saveConfig, type TodoConfig } from "./config.ts";
234
+ import { healthReport } from "./health.ts";
235
+ import { todoToItem, archiveSummaryToItems, actionsForTodo, configToSettingItems } from "./panel-data.ts";
236
+
237
+ export type Box = "active" | "parked" | "archive" | "config";
238
+ const BOXES: Box[] = ["active", "parked", "archive", "config"];
239
+
240
+ export interface TodoPanelOpts {
241
+ theme: Theme;
242
+ onDone: () => void;
243
+ onNotify: (msg: string, type?: "info" | "warning" | "error") => void;
244
+ onEdit: (title: string, prefill: string) => Promise<string | undefined>;
245
+ }
246
+
247
+ export class TodoPanel extends Container {
248
+ private readonly theme: Theme;
249
+ private readonly onDone: () => void;
250
+ private readonly onNotify: (msg: string, type?: "info" | "warning" | "error") => void;
251
+ private readonly onEdit: (title: string, prefill: string) => Promise<string | undefined>;
252
+ private currentBox: Box = "active";
253
+ private readonly filterInput: Input;
254
+ private readonly selectList: SelectList;
255
+ private actionMode = false;
256
+ private actionList: SelectList | null = null;
257
+ private config: TodoConfig;
258
+ private healthFlags: string[] = [];
259
+
260
+ constructor(opts: TodoPanelOpts) {
261
+ super();
262
+ this.theme = opts.theme;
263
+ this.onDone = opts.onDone;
264
+ this.onNotify = opts.onNotify;
265
+ this.onEdit = opts.onEdit;
266
+ this.config = loadConfig();
267
+ try { this.healthFlags = healthReport().flags; } catch { /* optional */ }
268
+
269
+ const accent = (s: string) => this.theme.fg("accent", s);
270
+ this.addChild(new DynamicBorder(accent));
271
+ this.addChild(new Spacer(1));
272
+
273
+ this.filterInput = new Input();
274
+ this.filterInput.onEscape = () => { this.onDone(); };
275
+ this.filterInput.onSubmit = () => { this.refreshList(); };
276
+
277
+ this.selectList = new SelectList([], 12, {
278
+ selectedPrefix: (s) => this.theme.fg("accent", s),
279
+ selectedText: (s) => this.theme.fg("accent", s),
280
+ description: (s) => this.theme.fg("muted", s),
281
+ scrollInfo: (s) => this.theme.fg("dim", s),
282
+ noMatch: (s) => this.theme.fg("warning", s),
283
+ });
284
+ this.selectList.onSelect = (item) => this.onItemSelect(item);
285
+ this.selectList.onCancel = () => { this.onDone(); };
286
+
287
+ this.refreshList();
288
+ this.renderShell();
289
+ }
290
+
291
+ private renderShell(): void {
292
+ // Keep children 0 (top border) + 1 (spacer); rebuild the rest.
293
+ const keep = this.children.slice(0, 2);
294
+ this.children.length = 0;
295
+ this.children.push(...keep);
296
+
297
+ const accent = (s: string) => this.theme.fg("accent", s);
298
+ const tabs = BOXES.map((b) => b === this.currentBox ? this.theme.fg("accent", this.theme.bold(`[${b}]`)) : this.theme.fg("dim", b)).join(" ");
299
+ this.addChild(new Text(accent(this.theme.bold(" TODO")) + " " + tabs, 0, 0));
300
+ if (this.healthFlags.length > 0) {
301
+ this.addChild(new Text(this.theme.fg("warning", ` ⚠ ${this.healthFlags.length} bloat signals — see Config tab`), 0, 0));
302
+ }
303
+ this.addChild(new Spacer(1));
304
+ this.addChild(new Text(this.theme.fg("muted", ` filter:`), 0, 0));
305
+ this.addChild(this.filterInput);
306
+ this.addChild(new Spacer(1));
307
+
308
+ if (this.actionMode && this.actionList) {
309
+ this.addChild(new Text(this.theme.fg("accent", " Action:"), 0, 0));
310
+ this.addChild(this.actionList);
311
+ } else if (this.currentBox === "config") {
312
+ this.renderConfigBox();
313
+ } else {
314
+ this.addChild(this.selectList);
315
+ }
316
+
317
+ this.addChild(new Spacer(1));
318
+ this.addChild(new Text(this.theme.fg("dim", " ↑↓ navigate • enter select/action • tab switch box • esc done"), 0, 0));
319
+ this.addChild(new Spacer(1));
320
+ this.addChild(new DynamicBorder(accent));
321
+ this.invalidate();
322
+ }
323
+
324
+ private refreshList(): void {
325
+ const filter = this.filterInput.getValue();
326
+ if (this.currentBox === "active") {
327
+ const todos = listTodos({ text: filter || undefined, limit: 50 });
328
+ this.selectList.setFilter(""); // SelectList has its own filter; we pre-filter instead
329
+ // Rebuild items
330
+ this.setSelectItems(todos.map(todoToItem));
331
+ } else if (this.currentBox === "parked") {
332
+ const todos = listTodos({ status: "parked", text: filter || undefined, limit: 50 });
333
+ this.setSelectItems(todos.map(todoToItem));
334
+ } else if (this.currentBox === "archive") {
335
+ if (!filter) {
336
+ const s = archiveSummary();
337
+ this.setSelectItems(archiveSummaryToItems(s));
338
+ } else {
339
+ const res = listArchived({ text: filter, limit: 50 });
340
+ this.setSelectItems(res.items.map(todoToItem));
341
+ }
342
+ }
343
+ }
344
+
345
+ /** Replace the SelectList's items by reconstructing it (SelectList has no
346
+ * public items setter; setFilter does fuzzy matching on the original list). */
347
+ private setSelectItems(items: SelectItem[]): void {
348
+ // SelectList filters internally via setFilter; to replace items we create
349
+ // a fresh instance preserving the theme + callbacks.
350
+ const wasSelected = this.selectList.getSelectedItem();
351
+ const fresh = new SelectList(items, 12, {
352
+ selectedPrefix: (s) => this.theme.fg("accent", s),
353
+ selectedText: (s) => this.theme.fg("accent", s),
354
+ description: (s) => this.theme.fg("muted", s),
355
+ scrollInfo: (s) => this.theme.fg("dim", s),
356
+ noMatch: (s) => this.theme.fg("warning", s),
357
+ });
358
+ fresh.onSelect = (item) => this.onItemSelect(item);
359
+ fresh.onCancel = () => { this.onDone(); };
360
+ if (wasSelected) {
361
+ const idx = items.findIndex((i) => i.value === wasSelected.value);
362
+ if (idx >= 0) fresh.setSelectedIndex(idx);
363
+ }
364
+ // Replace the field reference so renderShell uses the new list
365
+ (this as any).selectList = fresh;
366
+ this.renderShell();
367
+ }
368
+
369
+ private onItemSelect(item: SelectItem): void {
370
+ if (this.currentBox === "archive" && (item.value === "total" || item.value.startsWith("project:") || item.value.startsWith("month:"))) {
371
+ // Drill down: set the filter to the bucket and refresh
372
+ if (item.value.startsWith("project:")) {
373
+ this.filterInput.setValue(item.value.slice("project:".length));
374
+ } else if (item.value.startsWith("month:")) {
375
+ this.filterInput.setValue(item.value.slice("month:".length));
376
+ }
377
+ this.refreshList();
378
+ this.renderShell();
379
+ return;
380
+ }
381
+ this.openActionSubmenu(item.value);
382
+ }
383
+
384
+ private openActionSubmenu(id: string): void {
385
+ // Find the todo to determine available actions
386
+ const all = listTodos({ status: "all", limit: 200 });
387
+ const todo = all.find((t) => t.id === id);
388
+ if (!todo) {
389
+ // Maybe in archive
390
+ const arch = listArchived({ text: id, limit: 50 });
391
+ if (arch.items.length > 0) {
392
+ this.onNotify("Restore from archive via the archive box.", "info");
393
+ }
394
+ return;
395
+ }
396
+ const acts = actionsForTodo(todo);
397
+ const items: SelectItem[] = acts.map((a) => ({ value: a.action, label: a.label }));
398
+ this.actionList = new SelectList(items, 8, {
399
+ selectedPrefix: (s) => this.theme.fg("accent", s),
400
+ selectedText: (s) => this.theme.fg("accent", s),
401
+ description: (s) => this.theme.fg("muted", s),
402
+ scrollInfo: (s) => this.theme.fg("dim", s),
403
+ noMatch: (s) => this.theme.fg("warning", s),
404
+ });
405
+ this.actionList.onSelect = (a) => this.executeAction(id, a.value);
406
+ this.actionList.onCancel = () => { this.actionMode = false; this.actionList = null; this.renderShell(); };
407
+ this.actionMode = true;
408
+ this.renderShell();
409
+ }
410
+
411
+ private async executeAction(id: string, action: string): Promise<void> {
412
+ try {
413
+ switch (action) {
414
+ case "complete": completeTodo(id); this.onNotify(`Completed ${id}`); break;
415
+ case "park": parkTodo(id); this.onNotify(`Parked ${id}`); break;
416
+ case "open": updateTodo(id, { status: "open" as Status }); this.onNotify(`Re-activated ${id}`); break;
417
+ case "restore": restoreTodo(id); this.onNotify(`Restored ${id}`); break;
418
+ case "delete": deleteTodo(id); this.onNotify(`Cancelled ${id}`); break;
419
+ case "edit": {
420
+ const all = listTodos({ status: "all", limit: 200 });
421
+ const t = all.find((x) => x.id === id);
422
+ const edited = await this.onEdit("Edit TODO text", t?.text ?? "");
423
+ if (edited !== undefined) { updateTodo(id, { text: edited }); this.onNotify(`Edited ${id}`); }
424
+ break;
425
+ }
426
+ }
427
+ } catch (err) {
428
+ this.onNotify(`Error: ${(err as Error).message}`, "error");
429
+ }
430
+ this.actionMode = false;
431
+ this.actionList = null;
432
+ this.refreshList();
433
+ this.renderShell();
434
+ }
435
+
436
+ private renderConfigBox(): void {
437
+ const settings = configToSettingItems(this.config);
438
+ const sl = new SettingsList(settings, 12, {
439
+ label: (text, sel) => sel ? this.theme.fg("accent", this.theme.bold(text)) : text,
440
+ value: (text, sel) => sel ? this.theme.fg("accent", text) : this.theme.fg("muted", text),
441
+ description: (text) => this.theme.fg("dim", text),
442
+ cursor: "❯",
443
+ hint: (text) => this.theme.fg("dim", text),
444
+ },
445
+ (id, newValue) => {
446
+ this.applyConfigChange(id, newValue);
447
+ sl.updateValue(id, this.configValueDisplay(id));
448
+ },
449
+ () => { this.onDone(); });
450
+ this.addChild(sl);
451
+ }
452
+
453
+ private configValueDisplay(id: string): string {
454
+ const c = this.config;
455
+ switch (id) {
456
+ case "defaultAgeDays": return String(c.prune.defaultAgeDays);
457
+ case "hardAgeDays": return String(c.prune.hardAgeDays);
458
+ case "activeMaxOpen": return String(c.health.activeMaxOpen);
459
+ case "activeStaleDays": return String(c.health.activeStaleDays);
460
+ case "parkedMax": return String(c.health.parkedMax);
461
+ case "parkedStaleDays": return String(c.health.parkedStaleDays);
462
+ case "archiveMax": return String(c.health.archiveMax);
463
+ case "archiveOldDays": return String(c.health.archiveOldDays);
464
+ default: return "";
465
+ }
466
+ }
467
+
468
+ private applyConfigChange(id: string, value: string): void {
469
+ const n = Number(value);
470
+ if (!Number.isFinite(n)) return;
471
+ switch (id) {
472
+ case "defaultAgeDays": this.config.prune.defaultAgeDays = n; break;
473
+ case "hardAgeDays": this.config.prune.hardAgeDays = n; break;
474
+ case "activeMaxOpen": this.config.health.activeMaxOpen = n; break;
475
+ case "activeStaleDays": this.config.health.activeStaleDays = n; break;
476
+ case "parkedMax": this.config.health.parkedMax = n; break;
477
+ case "parkedStaleDays": this.config.health.parkedStaleDays = n; break;
478
+ case "archiveMax": this.config.health.archiveMax = n; break;
479
+ case "archiveOldDays": this.config.health.archiveOldDays = n; break;
480
+ }
481
+ saveConfig(this.config);
482
+ this.onNotify(`Config saved: ${id} = ${value}`, "info");
483
+ }
484
+
485
+ private switchBox(dir: 1 | -1): void {
486
+ const idx = BOXES.indexOf(this.currentBox);
487
+ const next = (idx + dir + BOXES.length) % BOXES.length;
488
+ this.currentBox = BOXES[next]!;
489
+ this.filterInput.setValue("");
490
+ this.actionMode = false;
491
+ this.actionList = null;
492
+ this.refreshList();
493
+ this.renderShell();
494
+ }
495
+
496
+ handleInput(data: string): void {
497
+ if (this.actionMode && this.actionList) {
498
+ if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
499
+ this.actionMode = false;
500
+ this.actionList = null;
501
+ this.renderShell();
502
+ return;
503
+ }
504
+ this.actionList.handleInput(data);
505
+ this.invalidate();
506
+ return;
507
+ }
508
+ if (matchesKey(data, "escape") || matchesKey(data, "esc")) { this.onDone(); return; }
509
+ if (matchesKey(data, "tab")) { this.switchBox(1); return; }
510
+ if (matchesKey(data, "shift+tab")) { this.switchBox(-1); return; }
511
+ // Navigation keys → SelectList; everything else → filter Input
512
+ if (matchesKey(data, "up") || matchesKey(data, "down") || matchesKey(data, "enter") || matchesKey(data, "return")) {
513
+ this.selectList.handleInput(data);
514
+ this.invalidate();
515
+ return;
516
+ }
517
+ this.filterInput.handleInput(data);
518
+ this.refreshList();
519
+ this.invalidate();
520
+ }
521
+ }
522
+ ```
523
+
524
+ - [ ] **Step 4: Verify syntax**
525
+
526
+ Run: `node --check src/panel.ts`
527
+ Expected: exit 0 (syntax valid — may have import-resolution warnings but syntax passes)
528
+
529
+ - [ ] **Step 5: Commit**
530
+
531
+ ```bash
532
+ git add src/panel.ts
533
+ git commit -m "feat(panel): TodoPanel — interactive /todo TUI with box tabs, filter, actions, config"
534
+ ```
535
+
536
+ ---
537
+
538
+ ## Task 3: Extension wiring — `/todo` (no-arg) opens the panel
539
+
540
+ **Files:**
541
+ - Modify: `extensions/todo.ts`
542
+
543
+ - [ ] **Step 1: (manual gate)**
544
+
545
+ - [ ] **Step 3: Write minimal implementation**
546
+
547
+ In `extensions/todo.ts`, add the import + update the slash handler so `/todo` (no-arg, empty `sub`) opens the panel in TUI mode:
548
+
549
+ ```ts
550
+ import { TodoPanel } from "../src/panel";
551
+ ```
552
+
553
+ In the slash handler, the default branch (currently `// default: list open`) becomes:
554
+
555
+ ```ts
556
+ // default: open the interactive panel (TUI) or list open (non-TUI)
557
+ if (ctx.mode === "tui") {
558
+ await ctx.ui.custom<boolean>((_tui, theme, _kb, done) => {
559
+ const panel = new TodoPanel({
560
+ theme: theme as any,
561
+ onDone: () => done(true),
562
+ onNotify: (msg, type) => ctx.ui.notify(msg, type ?? "info"),
563
+ onEdit: (title, prefill) => ctx.ui.editor(title, prefill),
564
+ });
565
+ return {
566
+ render: (width: number) => panel.render(width),
567
+ invalidate: () => panel.invalidate(),
568
+ handleInput: (data: string) => panel.handleInput(data),
569
+ dispose: () => { panel.dispose?.(); },
570
+ } as any;
571
+ });
572
+ return;
573
+ }
574
+ // non-TUI fallback: list open as text
575
+ const todos = listTodos();
576
+ const msg = todos.length ? todos.map(fmt).join("\n") : "(no open TODOs)";
577
+ if (ctx.hasUI) ctx.ui.notify(msg, "info");
578
+ ```
579
+
580
+ - [ ] **Step 4: Verify syntax**
581
+
582
+ Run: `node --check extensions/todo.ts`
583
+ Expected: exit 0
584
+
585
+ - [ ] **Step 5: Commit**
586
+
587
+ ```bash
588
+ git add extensions/todo.ts
589
+ git commit -m "feat(ext): /todo (no-arg) opens the interactive TUI panel; non-TUI fallback unchanged"
590
+ ```
591
+
592
+ ---
593
+
594
+ ## Task 4: README + AGENTS.md + final verification
595
+
596
+ **Files:**
597
+ - Modify: `README.md`, `AGENTS.md`
598
+
599
+ - [ ] **Step 3: Write the updates**
600
+
601
+ In `README.md`, add an "Interactive panel" subsection:
602
+
603
+ ```markdown
604
+ ## Interactive panel (SPEC-3)
605
+
606
+ Run `/todo` (no arg) in a TUI session to open the interactive triage panel:
607
+
608
+ - **Box tabs** (Tab / Shift+Tab): Active · Parked · Archive · Config
609
+ - **Filter input**: type to search by text (live filter)
610
+ - **SelectList**: arrow keys navigate, Enter selects
611
+ - **Action submenu** (on Enter): Complete / Park / Re-activate / Restore / Edit text / Delete
612
+ - **Archive box**: summary-first (counts by project + month) → Enter on a bucket to drill down
613
+ - **Config box**: SettingsList with prune ages + health thresholds — edit live, persists to `todo.config.json`
614
+ - **Escape**: exit the panel
615
+
616
+ Typed subcommands (`/todo park <id>`, `/todo prune`, etc.) all still work alongside the panel. Non-TUI sessions (`pi -p`, RPC) fall back to the text list.
617
+ ```
618
+
619
+ Update `AGENTS.md` Structure to include `src/panel.ts` + `src/panel-data.ts`, and the test count (add `panel-data`).
620
+
621
+ Update `package.json` test script to include `panel-data`:
622
+ ```
623
+ "test": "for t in todo-store todo-archive todo-config todo-migrate todo-health todo-hard-prune panel-data; do node test/$t.test.mts || exit 1; done"
624
+ ```
625
+
626
+ - [ ] **Step 4: Run all tests**
627
+
628
+ Run: `for t in todo-store todo-archive todo-config todo-migrate todo-health todo-hard-prune panel-data; do node test/$t.test.mts || exit 1; done`
629
+ Expected: all 7 suites PASS.
630
+
631
+ - [ ] **Step 5: Commit**
632
+
633
+ ```bash
634
+ git add README.md AGENTS.md package.json
635
+ git commit -m "docs(spec-3): interactive /todo TUI panel + final test count"
636
+ ```
637
+
638
+ ---
639
+
640
+ ## Final verification (SPEC-3 done → full v0.2.0 ready for QA)
641
+
642
+ - [ ] All 7 test suites pass.
643
+ - [ ] `node --check extensions/todo.ts` + `node --check src/panel.ts` pass.
644
+ - [ ] No `TODO`/`FIXME`/`HACK` in delivered code.
645
+ - [ ] **Manual QA (the one big gate, after SPEC-3):** local install → restart pi → `/todo` opens the panel → Tab through boxes → filter → Enter on a todo → action submenu → Complete/Park/Restore → Config tab edit → Escape exits. Plus the SPEC-1/2 gates (park drops from injection, prune, archive, restore, health, hard-prune confirm).
646
+ - [ ] After QA green → merge PR #3 → tag `v0.2.0` → CI auto-publishes → switch back to `npm:@getpipher/armory-todo`.
647
+
648
+ ## Out of scope for SPEC-3
649
+
650
+ - **Workstream B** — `title` + `notes`/`log` schema split.
651
+ - **Workstream C** — preventive caps-on-add + project registry.