@getpipher/armory-todo 0.2.0 → 0.3.1
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 +32 -9
- package/docs/superpowers/plans/2026-07-21-auto-prune-done-view.md +893 -0
- package/docs/superpowers/plans/2026-07-21-title-notes-split.md +1586 -0
- package/docs/superpowers/specs/2026-07-21-auto-prune-done-view-design.md +217 -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 +116 -34
- package/package.json +2 -2
- package/src/archive.ts +68 -6
- package/src/auto-prune.ts +15 -0
- package/src/health.ts +17 -1
- package/src/migrate.ts +110 -1
- package/src/panel-data.ts +26 -15
- package/src/panel.ts +67 -13
- package/src/todo-store.ts +43 -21
package/src/panel-data.ts
CHANGED
|
@@ -4,27 +4,21 @@
|
|
|
4
4
|
|
|
5
5
|
import type { SelectItem, SettingItem } from "@earendil-works/pi-tui";
|
|
6
6
|
import type { Todo } from "./todo-store.ts";
|
|
7
|
+
import type { DoneItem } from "./archive.ts";
|
|
7
8
|
import type { ArchiveSummary } from "./archive.ts";
|
|
8
9
|
import type { TodoConfig } from "./config.ts";
|
|
9
10
|
|
|
10
|
-
/** Format a todo as a SelectList item: "[id] (prio)⏵ (project)
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* The full text is still accessible via the "Edit text" action in the panel. */
|
|
11
|
+
/** Format a todo as a SelectList item: "[id] (prio)⏵ (project) • title".
|
|
12
|
+
* title is already ≤120 chars (enforced at write time), so no truncation is
|
|
13
|
+
* needed. The • marker shows when notes is non-empty (signals "open the
|
|
14
|
+
* detail view / use `todo get` for context"). */
|
|
15
15
|
export function todoToItem(t: Todo): SelectItem {
|
|
16
16
|
const pin = t.status === "in_progress" ? " ⏵" : "";
|
|
17
17
|
const proj = t.project ? ` (${t.project})` : "";
|
|
18
|
-
const
|
|
19
|
-
const maxText = 80;
|
|
20
|
-
let text = t.text;
|
|
21
|
-
if (text.length > maxText) {
|
|
22
|
-
const firstLine = text.split("\n")[0]!;
|
|
23
|
-
text = firstLine.length > maxText ? firstLine.slice(0, maxText - 1) + "…" : firstLine + "…";
|
|
24
|
-
}
|
|
18
|
+
const dot = t.notes.trim() ? " •" : "";
|
|
25
19
|
return {
|
|
26
20
|
value: t.id,
|
|
27
|
-
label:
|
|
21
|
+
label: `[${t.id}] (${t.priority})${pin}${proj}${dot} ${t.title}`,
|
|
28
22
|
};
|
|
29
23
|
}
|
|
30
24
|
|
|
@@ -50,7 +44,7 @@ export function actionsForTodo(t: Todo): { label: string; action: string }[] {
|
|
|
50
44
|
if (t.status === "done" || t.status === "cancelled") {
|
|
51
45
|
actions.push({ label: "Restore (from archive)", action: "restore" });
|
|
52
46
|
}
|
|
53
|
-
actions.push({ label: "Edit
|
|
47
|
+
actions.push({ label: "Edit title", action: "edit" });
|
|
54
48
|
actions.push({ label: "Delete (cancel)", action: "delete" });
|
|
55
49
|
return actions;
|
|
56
50
|
}
|
|
@@ -67,4 +61,21 @@ export function configToSettingItems(cfg: TodoConfig): SettingItem[] {
|
|
|
67
61
|
{ id: "archiveMax", label: "Archive max", currentValue: String(cfg.health.archiveMax), values: ["100", "200", "500"], description: "Bloat flag when archive exceeds this." },
|
|
68
62
|
{ id: "archiveOldDays", label: "Archive old (days)", currentValue: String(cfg.health.archiveOldDays), values: ["90", "180", "365"], description: "Bloat flag when archive items older than this." },
|
|
69
63
|
];
|
|
70
|
-
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Format a done todo (live or archived) as a SelectList item with a
|
|
67
|
+
* location tag: "[live Nd]" or "[archived YYYY-MM-DD]". */
|
|
68
|
+
export function todoDoneItem(d: DoneItem): SelectItem {
|
|
69
|
+
const proj = d.project ? ` (${d.project})` : "";
|
|
70
|
+
const loc = d.location === "archive" && d.archivedAt
|
|
71
|
+
? ` [archived ${d.archivedAt.slice(0, 10)}]`
|
|
72
|
+
: ` [live ${d.closedAt ? Math.floor((Date.now() - Date.parse(d.closedAt)) / 86400_000) : 0}d]`;
|
|
73
|
+
return { value: d.id, label: `[${d.id}] (done)${proj}${loc} ${d.title}` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Actions for a done todo: View detail always; Restore only if archived. */
|
|
77
|
+
export function actionsForDoneTodo(d: DoneItem): { label: string; action: string }[] {
|
|
78
|
+
const acts: { label: string; action: string }[] = [{ label: "View detail", action: "view" }];
|
|
79
|
+
if (d.location === "archive") acts.push({ label: "Restore (from archive)", action: "restore" });
|
|
80
|
+
return acts;
|
|
81
|
+
}
|
package/src/panel.ts
CHANGED
|
@@ -21,13 +21,13 @@ import {
|
|
|
21
21
|
type Theme,
|
|
22
22
|
} from "@earendil-works/pi-tui";
|
|
23
23
|
import { listTodos, parkTodo, completeTodo, deleteTodo, updateTodo, type Todo, type Status } from "./todo-store.ts";
|
|
24
|
-
import { restoreTodo, archiveSummary, listArchived } from "./archive.ts";
|
|
24
|
+
import { restoreTodo, archiveSummary, listArchived, listDoneUnified } from "./archive.ts";
|
|
25
25
|
import { loadConfig, saveConfig, type TodoConfig } from "./config.ts";
|
|
26
26
|
import { healthReport } from "./health.ts";
|
|
27
|
-
import { todoToItem, archiveSummaryToItems, actionsForTodo, configToSettingItems } from "./panel-data.ts";
|
|
27
|
+
import { todoToItem, archiveSummaryToItems, actionsForTodo, configToSettingItems, todoDoneItem, actionsForDoneTodo } from "./panel-data.ts";
|
|
28
28
|
|
|
29
|
-
export type Box = "active" | "parked" | "archive" | "config";
|
|
30
|
-
const BOXES: Box[] = ["active", "parked", "archive", "config"];
|
|
29
|
+
export type Box = "active" | "parked" | "done" | "archive" | "config";
|
|
30
|
+
const BOXES: Box[] = ["active", "parked", "done", "archive", "config"];
|
|
31
31
|
|
|
32
32
|
export interface TodoPanelOpts {
|
|
33
33
|
theme: Theme;
|
|
@@ -47,6 +47,8 @@ export class TodoPanel extends Container {
|
|
|
47
47
|
private editMode = false;
|
|
48
48
|
private editInput: Input | null = null;
|
|
49
49
|
private editId = "";
|
|
50
|
+
private detailMode = false;
|
|
51
|
+
private detailId = "";
|
|
50
52
|
private settingsList: SettingsList | null = null;
|
|
51
53
|
private config: TodoConfig;
|
|
52
54
|
private healthFlags: string[] = [];
|
|
@@ -105,6 +107,20 @@ export class TodoPanel extends Container {
|
|
|
105
107
|
} else if (this.actionMode && this.actionList) {
|
|
106
108
|
this.addChild(new Text(this.theme.fg("accent", " Action:"), 0, 0));
|
|
107
109
|
this.addChild(this.actionList);
|
|
110
|
+
} else if (this.detailMode) {
|
|
111
|
+
const all = listTodos({ status: "all", limit: 200 });
|
|
112
|
+
const t = all.find((x) => x.id === this.detailId);
|
|
113
|
+
if (!t) { this.detailMode = false; this.renderShell(); return; }
|
|
114
|
+
const proj = t.project || "no project";
|
|
115
|
+
const tags = t.tags.length ? t.tags.join(" ") : "(none)";
|
|
116
|
+
const notesText = t.notes || "(empty)";
|
|
117
|
+
this.addChild(new Text(this.theme.fg("accent", " " + t.title), 0, 0));
|
|
118
|
+
this.addChild(new Text(this.theme.fg("muted", " (" + t.priority + "/" + t.status + ") - " + proj + " - #" + tags), 0, 0));
|
|
119
|
+
this.addChild(new Spacer(1));
|
|
120
|
+
this.addChild(new Text(this.theme.fg("dim", " notes:"), 0, 0));
|
|
121
|
+
this.addChild(new Text(" " + notesText, 0, 0));
|
|
122
|
+
this.addChild(new Spacer(1));
|
|
123
|
+
this.addChild(new Text(this.theme.fg("dim", " notes: read-only - todo update <id> notes=... to edit"), 0, 0));
|
|
108
124
|
} else if (this.currentBox === "config") {
|
|
109
125
|
this.renderConfigBox();
|
|
110
126
|
} else {
|
|
@@ -126,6 +142,9 @@ export class TodoPanel extends Container {
|
|
|
126
142
|
} else if (this.currentBox === "parked") {
|
|
127
143
|
const todos = listTodos({ status: "parked", text: filter || undefined, limit: 50 });
|
|
128
144
|
this.setSelectItems(todos.map(todoToItem));
|
|
145
|
+
} else if (this.currentBox === "done") {
|
|
146
|
+
const items = listDoneUnified({ text: filter || undefined, limit: 50 });
|
|
147
|
+
this.setSelectItems(items.map(todoDoneItem));
|
|
129
148
|
} else if (this.currentBox === "archive") {
|
|
130
149
|
if (!filter) {
|
|
131
150
|
const s = archiveSummary();
|
|
@@ -173,13 +192,20 @@ export class TodoPanel extends Container {
|
|
|
173
192
|
}
|
|
174
193
|
|
|
175
194
|
private openActionSubmenu(id: string): void {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
this.onNotify("
|
|
180
|
-
|
|
195
|
+
let acts: { label: string; action: string }[];
|
|
196
|
+
if (this.currentBox === "done") {
|
|
197
|
+
const d = listDoneUnified({}).find((x) => x.id === id);
|
|
198
|
+
if (!d) { this.onNotify("Done todo not found.", "info"); return; }
|
|
199
|
+
acts = actionsForDoneTodo(d);
|
|
200
|
+
} else {
|
|
201
|
+
const all = listTodos({ status: "all", limit: 200 });
|
|
202
|
+
const todo = all.find((t) => t.id === id);
|
|
203
|
+
if (!todo) {
|
|
204
|
+
this.onNotify("Todo not found in the live store (archive restore: use the archive box).", "info");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
acts = [{ label: "View detail", action: "view" }, ...actionsForTodo(todo)];
|
|
181
208
|
}
|
|
182
|
-
const acts = actionsForTodo(todo);
|
|
183
209
|
const items: SelectItem[] = acts.map((a) => ({ value: a.action, label: a.label }));
|
|
184
210
|
this.actionList = new SelectList(items, 8, {
|
|
185
211
|
selectedPrefix: (s) => this.theme.fg("accent", s),
|
|
@@ -194,9 +220,23 @@ export class TodoPanel extends Container {
|
|
|
194
220
|
this.renderShell();
|
|
195
221
|
}
|
|
196
222
|
|
|
223
|
+
private viewDetail(id: string): void {
|
|
224
|
+
const all = listTodos({ status: "all", limit: 200 });
|
|
225
|
+
const t = all.find((x) => x.id === id);
|
|
226
|
+
if (!t) { this.onNotify("Todo not found.", "info"); return; }
|
|
227
|
+
this.detailId = id;
|
|
228
|
+
this.detailMode = true;
|
|
229
|
+
this.actionMode = false;
|
|
230
|
+
this.actionList = null;
|
|
231
|
+
this.editMode = false;
|
|
232
|
+
this.editInput = null;
|
|
233
|
+
this.renderShell();
|
|
234
|
+
}
|
|
235
|
+
|
|
197
236
|
private async executeAction(id: string, action: string): Promise<void> {
|
|
198
237
|
try {
|
|
199
238
|
switch (action) {
|
|
239
|
+
case "view": this.viewDetail(id); return;
|
|
200
240
|
case "complete": completeTodo(id); this.onNotify(`Completed ${id}`); break;
|
|
201
241
|
case "park": parkTodo(id); this.onNotify(`Parked ${id}`); break;
|
|
202
242
|
case "open": updateTodo(id, { status: "open" as Status }); this.onNotify(`Re-activated ${id}`); break;
|
|
@@ -207,9 +247,12 @@ export class TodoPanel extends Container {
|
|
|
207
247
|
const t = all.find((x) => x.id === id);
|
|
208
248
|
this.editId = id;
|
|
209
249
|
this.editInput = new Input();
|
|
210
|
-
this.editInput.setValue(t?.
|
|
250
|
+
this.editInput.setValue(t?.title ?? "");
|
|
211
251
|
this.editInput.onSubmit = (value) => {
|
|
212
|
-
if (value.trim()) {
|
|
252
|
+
if (value.trim()) {
|
|
253
|
+
try { updateTodo(id, { title: value.trim() }); this.onNotify(`Edited ${id}`); }
|
|
254
|
+
catch (err) { this.onNotify((err as Error).message, "error"); }
|
|
255
|
+
}
|
|
213
256
|
this.exitEditMode();
|
|
214
257
|
};
|
|
215
258
|
this.editInput.onEscape = () => this.exitEditMode();
|
|
@@ -221,7 +264,7 @@ export class TodoPanel extends Container {
|
|
|
221
264
|
}
|
|
222
265
|
}
|
|
223
266
|
} catch (err) {
|
|
224
|
-
this.onNotify(
|
|
267
|
+
this.onNotify((err as Error).message, "error");
|
|
225
268
|
}
|
|
226
269
|
this.actionMode = false;
|
|
227
270
|
this.actionList = null;
|
|
@@ -308,6 +351,17 @@ export class TodoPanel extends Container {
|
|
|
308
351
|
this.invalidate();
|
|
309
352
|
return;
|
|
310
353
|
}
|
|
354
|
+
if (this.detailMode) {
|
|
355
|
+
if (matchesKey(data, "escape") || matchesKey(data, "esc") || matchesKey(data, "enter") || matchesKey(data, "return")) {
|
|
356
|
+
this.detailMode = false;
|
|
357
|
+
this.detailId = "";
|
|
358
|
+
this.refreshList();
|
|
359
|
+
this.renderShell();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
this.invalidate();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
311
365
|
if (this.actionMode && this.actionList) {
|
|
312
366
|
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
|
|
313
367
|
this.actionMode = false;
|
package/src/todo-store.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from "node:fs";
|
|
19
19
|
import { dirname } from "node:path";
|
|
20
20
|
import { getLivePath, getTodoDir, getLegacyPath } from "./paths.ts";
|
|
21
|
-
import { migrateIfNeeded } from "./migrate.ts";
|
|
21
|
+
import { migrateIfNeeded, migrateV2ToV3 } from "./migrate.ts";
|
|
22
22
|
|
|
23
23
|
export type Priority = "low" | "med" | "high" | "critical";
|
|
24
24
|
export type Status = "open" | "in_progress" | "parked" | "done" | "cancelled";
|
|
@@ -31,7 +31,8 @@ const STATUS_SET = new Set(STATUSES);
|
|
|
31
31
|
|
|
32
32
|
export interface Todo {
|
|
33
33
|
id: string;
|
|
34
|
-
|
|
34
|
+
title: string; // ≤120 chars, non-empty, trimmed
|
|
35
|
+
notes: string; // any length, may be ""
|
|
35
36
|
project: string;
|
|
36
37
|
tags: string[];
|
|
37
38
|
priority: Priority;
|
|
@@ -43,13 +44,14 @@ export interface Todo {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export interface Store {
|
|
46
|
-
version:
|
|
47
|
+
version: 3;
|
|
47
48
|
updatedAt: string;
|
|
48
49
|
todos: Todo[];
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
export interface AddInput {
|
|
52
|
-
|
|
53
|
+
title: string;
|
|
54
|
+
notes?: string;
|
|
53
55
|
project?: string;
|
|
54
56
|
tags?: string[];
|
|
55
57
|
priority?: Priority;
|
|
@@ -57,7 +59,8 @@ export interface AddInput {
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
export interface UpdateInput {
|
|
60
|
-
|
|
62
|
+
title?: string;
|
|
63
|
+
notes?: string;
|
|
61
64
|
project?: string;
|
|
62
65
|
tags?: string[];
|
|
63
66
|
priority?: Priority;
|
|
@@ -86,8 +89,19 @@ function genId(): string {
|
|
|
86
89
|
return "td-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
const TITLE_MAX = 120; // must match the constant in migrate.ts
|
|
93
|
+
|
|
94
|
+
function normalizeTitle(raw: string): string {
|
|
95
|
+
const t = raw.trim();
|
|
96
|
+
if (!t) throw new TodoError("title is required");
|
|
97
|
+
if (t.length > TITLE_MAX) {
|
|
98
|
+
throw new TodoError(`title must be ≤${TITLE_MAX} chars (got ${t.length}); move detail into notes`);
|
|
99
|
+
}
|
|
100
|
+
return t;
|
|
101
|
+
}
|
|
102
|
+
|
|
89
103
|
function emptyStore(): Store {
|
|
90
|
-
return { version:
|
|
104
|
+
return { version: 3, updatedAt: now(), todos: [] };
|
|
91
105
|
}
|
|
92
106
|
|
|
93
107
|
export function getStorePath(): string {
|
|
@@ -105,14 +119,18 @@ export function loadStore(): Store {
|
|
|
105
119
|
if (!existsSync(path)) return emptyStore();
|
|
106
120
|
try {
|
|
107
121
|
const raw = readFileSync(path, "utf8");
|
|
108
|
-
|
|
122
|
+
let parsed = JSON.parse(raw) as Store;
|
|
109
123
|
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.todos)) {
|
|
110
124
|
throw new Error("invalid store shape");
|
|
111
125
|
}
|
|
112
|
-
if (parsed.version
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
126
|
+
if (parsed.version === 2) {
|
|
127
|
+
// v2 → v3: curated map + fallback, persist once so migration runs a single time.
|
|
128
|
+
const migrated = migrateV2ToV3(parsed as any) as unknown as Store;
|
|
129
|
+
saveStore(migrated);
|
|
130
|
+
return migrated;
|
|
131
|
+
}
|
|
132
|
+
if (parsed.version !== 3) {
|
|
133
|
+
throw new Error("invalid store shape");
|
|
116
134
|
}
|
|
117
135
|
return parsed;
|
|
118
136
|
} catch {
|
|
@@ -160,13 +178,14 @@ function findOrFail(store: Store, id: string): Todo {
|
|
|
160
178
|
}
|
|
161
179
|
|
|
162
180
|
export function addTodo(input: AddInput): Todo {
|
|
163
|
-
const
|
|
164
|
-
if (!text) throw new TodoError("text is required");
|
|
181
|
+
const title = normalizeTitle(input.title);
|
|
165
182
|
if (input.priority) assertPriority(input.priority);
|
|
183
|
+
const notes = (input.notes ?? "").trim();
|
|
166
184
|
const store = loadStore();
|
|
167
185
|
const todo: Todo = {
|
|
168
186
|
id: genId(),
|
|
169
|
-
|
|
187
|
+
title,
|
|
188
|
+
notes,
|
|
170
189
|
project: (input.project ?? "").trim(),
|
|
171
190
|
tags: (input.tags ?? []).map((t) => t.trim()).filter(Boolean),
|
|
172
191
|
priority: input.priority ?? "med",
|
|
@@ -195,7 +214,7 @@ export function listTodos(filter: ListFilter = {}): Todo[] {
|
|
|
195
214
|
if (filter.tag) out = out.filter((t) => t.tags.includes(filter.tag as string));
|
|
196
215
|
if (filter.text) {
|
|
197
216
|
const q = filter.text.toLowerCase();
|
|
198
|
-
out = out.filter((t) => t.
|
|
217
|
+
out = out.filter((t) => t.title.toLowerCase().includes(q) || t.notes.toLowerCase().includes(q));
|
|
199
218
|
}
|
|
200
219
|
if (filter.since) out = out.filter((t) => t.createdAt >= (filter.since as string));
|
|
201
220
|
if (filter.before) out = out.filter((t) => t.createdAt < (filter.before as string));
|
|
@@ -218,11 +237,8 @@ export function listTodos(filter: ListFilter = {}): Todo[] {
|
|
|
218
237
|
export function updateTodo(id: string, patch: UpdateInput): Todo {
|
|
219
238
|
const store = loadStore();
|
|
220
239
|
const todo = findOrFail(store, id);
|
|
221
|
-
if (patch.
|
|
222
|
-
|
|
223
|
-
if (!text) throw new TodoError("text must not be empty");
|
|
224
|
-
todo.text = text;
|
|
225
|
-
}
|
|
240
|
+
if (patch.title !== undefined) todo.title = normalizeTitle(patch.title);
|
|
241
|
+
if (patch.notes !== undefined) todo.notes = patch.notes.trim();
|
|
226
242
|
if (patch.project !== undefined) todo.project = patch.project.trim();
|
|
227
243
|
if (patch.tags !== undefined) todo.tags = patch.tags.map((t) => t.trim()).filter(Boolean);
|
|
228
244
|
if (patch.priority !== undefined) {
|
|
@@ -242,6 +258,11 @@ export function updateTodo(id: string, patch: UpdateInput): Todo {
|
|
|
242
258
|
return todo;
|
|
243
259
|
}
|
|
244
260
|
|
|
261
|
+
export function getTodo(id: string): Todo {
|
|
262
|
+
const store = loadStore();
|
|
263
|
+
return findOrFail(store, id);
|
|
264
|
+
}
|
|
265
|
+
|
|
245
266
|
export function completeTodo(id: string): Todo {
|
|
246
267
|
return updateTodo(id, { status: "done" });
|
|
247
268
|
}
|
|
@@ -272,7 +293,8 @@ export function renderOpenBlock(max = 15): string {
|
|
|
272
293
|
const lines = shown.map((t) => {
|
|
273
294
|
const tag = t.project ? ` (${t.project})` : "";
|
|
274
295
|
const pin = t.status === "in_progress" ? " ⏵" : "";
|
|
275
|
-
|
|
296
|
+
const dot = t.notes.trim() ? " •" : "";
|
|
297
|
+
return `- [${t.id}] (${t.priority})${pin}${dot} ${t.title}${tag}`;
|
|
276
298
|
});
|
|
277
299
|
const overflow = todos.length > max ? `\n- … +${todos.length - max} more (use \`todo list\`)` : "";
|
|
278
300
|
return `## Open TODOs (${todos.length})\n${lines.join("\n")}${overflow}\n`;
|