@getpipher/armory-todo 0.1.0 → 0.3.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/README.md +107 -14
- package/docs/superpowers/plans/2026-07-20-spec-1-store-layer.md +1691 -0
- package/docs/superpowers/plans/2026-07-20-spec-2-health-hard-prune.md +762 -0
- package/docs/superpowers/plans/2026-07-20-spec-3-interactive-panel.md +651 -0
- package/docs/superpowers/plans/2026-07-21-title-notes-split.md +1586 -0
- package/docs/superpowers/specs/2026-07-20-lifecycle-boxes-prune-design.md +323 -0
- package/docs/superpowers/specs/2026-07-21-title-notes-split-design.md +411 -0
- package/docs/todo-SPEC.md +5 -0
- package/extensions/todo.ts +273 -36
- package/package.json +2 -2
- package/src/archive.ts +214 -0
- package/src/config.ts +101 -0
- package/src/hard-prune.ts +89 -0
- package/src/health.ts +97 -0
- package/src/migrate.ts +154 -0
- package/src/panel-data.ts +63 -0
- package/src/panel.ts +382 -0
- package/src/paths.ts +36 -0
- package/src/todo-store.ts +86 -37
|
@@ -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.
|