mitsupi 1.0.0 → 1.0.2

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,1651 @@
1
+ /**
2
+ * Todo storage settings are kept in <todo-dir>/settings.json.
3
+ *
4
+ * Defaults:
5
+ * {
6
+ * "gc": true, // delete closed todos older than gcDays on startup
7
+ * "gcDays": 7 // age threshold for GC (days since created_at)
8
+ * }
9
+ */
10
+ import { DynamicBorder, getMarkdownTheme, keyHint, type ExtensionAPI, type ExtensionContext, type Theme } from "@mariozechner/pi-coding-agent";
11
+ import { StringEnum } from "@mariozechner/pi-ai";
12
+ import { Type } from "@sinclair/typebox";
13
+ import path from "node:path";
14
+ import fs from "node:fs/promises";
15
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
16
+ import crypto from "node:crypto";
17
+ import {
18
+ Container,
19
+ type Focusable,
20
+ Input,
21
+ Key,
22
+ Markdown,
23
+ SelectList,
24
+ Spacer,
25
+ type SelectItem,
26
+ Text,
27
+ TUI,
28
+ fuzzyMatch,
29
+ getEditorKeybindings,
30
+ matchesKey,
31
+ truncateToWidth,
32
+ visibleWidth,
33
+ } from "@mariozechner/pi-tui";
34
+
35
+ const TODO_DIR_NAME = ".pi/todos";
36
+ const TODO_PATH_ENV = "PI_ISSUE_PATH";
37
+ const TODO_SETTINGS_NAME = "settings.json";
38
+ const TODO_ID_PREFIX = "TODO-";
39
+ const DEFAULT_TODO_SETTINGS = {
40
+ gc: true,
41
+ gcDays: 7,
42
+ };
43
+ const LOCK_TTL_MS = 30 * 60 * 1000;
44
+
45
+ interface TodoFrontMatter {
46
+ id: string;
47
+ title: string;
48
+ tags: string[];
49
+ status: string;
50
+ created_at: string;
51
+ }
52
+
53
+ interface TodoRecord extends TodoFrontMatter {
54
+ body: string;
55
+ }
56
+
57
+ interface LockInfo {
58
+ id: string;
59
+ pid: number;
60
+ session?: string | null;
61
+ created_at: string;
62
+ }
63
+
64
+ interface TodoSettings {
65
+ gc: boolean;
66
+ gcDays: number;
67
+ }
68
+
69
+ const TodoParams = Type.Object({
70
+ action: StringEnum(["list", "list-all", "get", "create", "update", "append", "delete"] as const),
71
+ id: Type.Optional(
72
+ Type.String({ description: "Todo id (TODO-<hex> or raw hex filename)" }),
73
+ ),
74
+ title: Type.Optional(Type.String({ description: "Short summary shown in lists" })),
75
+ status: Type.Optional(Type.String({ description: "Todo status" })),
76
+ tags: Type.Optional(Type.Array(Type.String({ description: "Todo tag" }))),
77
+ body: Type.Optional(
78
+ Type.String({ description: "Long-form details (markdown). Update replaces; append adds." }),
79
+ ),
80
+ });
81
+
82
+ type TodoAction = "list" | "list-all" | "get" | "create" | "update" | "append" | "delete";
83
+
84
+ type TodoOverlayAction = "work" | "refine" | "close" | "reopen" | "delete" | "cancel" | "actions";
85
+
86
+ type TodoMenuAction = TodoOverlayAction | "copy-path" | "close-dialog" | "view";
87
+
88
+ type TodoToolDetails =
89
+ | { action: "list" | "list-all"; todos: TodoFrontMatter[]; error?: string }
90
+ | { action: "get" | "create" | "update" | "append" | "delete"; todo: TodoRecord; error?: string };
91
+
92
+ function formatTodoId(id: string): string {
93
+ return `${TODO_ID_PREFIX}${id}`;
94
+ }
95
+
96
+ function normalizeTodoId(id: string): string {
97
+ let trimmed = id.trim();
98
+ if (trimmed.startsWith("#")) {
99
+ trimmed = trimmed.slice(1);
100
+ }
101
+ if (trimmed.toUpperCase().startsWith(TODO_ID_PREFIX)) {
102
+ trimmed = trimmed.slice(TODO_ID_PREFIX.length);
103
+ }
104
+ return trimmed;
105
+ }
106
+
107
+ function displayTodoId(id: string): string {
108
+ return formatTodoId(normalizeTodoId(id));
109
+ }
110
+
111
+ function isTodoClosed(status: string): boolean {
112
+ return ["closed", "done"].includes(status.toLowerCase());
113
+ }
114
+
115
+ function sortTodos(todos: TodoFrontMatter[]): TodoFrontMatter[] {
116
+ return [...todos].sort((a, b) => {
117
+ const aClosed = isTodoClosed(a.status);
118
+ const bClosed = isTodoClosed(b.status);
119
+ if (aClosed !== bClosed) return aClosed ? 1 : -1;
120
+ return (a.created_at || "").localeCompare(b.created_at || "");
121
+ });
122
+ }
123
+
124
+ function buildTodoSearchText(todo: TodoFrontMatter): string {
125
+ const tags = todo.tags.join(" ");
126
+ return `${formatTodoId(todo.id)} ${todo.id} ${todo.title} ${tags} ${todo.status}`.trim();
127
+ }
128
+
129
+ function filterTodos(todos: TodoFrontMatter[], query: string): TodoFrontMatter[] {
130
+ const trimmed = query.trim();
131
+ if (!trimmed) return todos;
132
+
133
+ const tokens = trimmed
134
+ .split(/\s+/)
135
+ .map((token) => token.trim())
136
+ .filter(Boolean);
137
+
138
+ if (tokens.length === 0) return todos;
139
+
140
+ const matches: Array<{ todo: TodoFrontMatter; score: number }> = [];
141
+ for (const todo of todos) {
142
+ const text = buildTodoSearchText(todo);
143
+ let totalScore = 0;
144
+ let matched = true;
145
+ for (const token of tokens) {
146
+ const result = fuzzyMatch(token, text);
147
+ if (!result.matches) {
148
+ matched = false;
149
+ break;
150
+ }
151
+ totalScore += result.score;
152
+ }
153
+ if (matched) {
154
+ matches.push({ todo, score: totalScore });
155
+ }
156
+ }
157
+
158
+ return matches
159
+ .sort((a, b) => {
160
+ const aClosed = isTodoClosed(a.todo.status);
161
+ const bClosed = isTodoClosed(b.todo.status);
162
+ if (aClosed !== bClosed) return aClosed ? 1 : -1;
163
+ return a.score - b.score;
164
+ })
165
+ .map((match) => match.todo);
166
+ }
167
+
168
+ class TodoSelectorComponent extends Container implements Focusable {
169
+ private searchInput: Input;
170
+ private listContainer: Container;
171
+ private allTodos: TodoFrontMatter[];
172
+ private filteredTodos: TodoFrontMatter[];
173
+ private selectedIndex = 0;
174
+ private onSelectCallback: (todo: TodoFrontMatter) => void;
175
+ private onCancelCallback: () => void;
176
+ private tui: TUI;
177
+ private theme: Theme;
178
+ private headerText: Text;
179
+ private hintText: Text;
180
+
181
+ private _focused = false;
182
+ get focused(): boolean {
183
+ return this._focused;
184
+ }
185
+ set focused(value: boolean) {
186
+ this._focused = value;
187
+ this.searchInput.focused = value;
188
+ }
189
+
190
+ constructor(
191
+ tui: TUI,
192
+ theme: Theme,
193
+ todos: TodoFrontMatter[],
194
+ onSelect: (todo: TodoFrontMatter) => void,
195
+ onCancel: () => void,
196
+ initialSearchInput?: string,
197
+ private onQuickAction?: (todo: TodoFrontMatter, action: "work" | "refine") => void,
198
+ ) {
199
+ super();
200
+ this.tui = tui;
201
+ this.theme = theme;
202
+ this.allTodos = todos;
203
+ this.filteredTodos = todos;
204
+ this.onSelectCallback = onSelect;
205
+ this.onCancelCallback = onCancel;
206
+
207
+ this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
208
+ this.addChild(new Spacer(1));
209
+
210
+ this.headerText = new Text("", 1, 0);
211
+ this.addChild(this.headerText);
212
+ this.addChild(new Spacer(1));
213
+
214
+ this.searchInput = new Input();
215
+ if (initialSearchInput) {
216
+ this.searchInput.setValue(initialSearchInput);
217
+ }
218
+ this.searchInput.onSubmit = () => {
219
+ const selected = this.filteredTodos[this.selectedIndex];
220
+ if (selected) this.onSelectCallback(selected);
221
+ };
222
+ this.addChild(this.searchInput);
223
+
224
+ this.addChild(new Spacer(1));
225
+ this.listContainer = new Container();
226
+ this.addChild(this.listContainer);
227
+
228
+ this.addChild(new Spacer(1));
229
+ this.hintText = new Text("", 1, 0);
230
+ this.addChild(this.hintText);
231
+ this.addChild(new Spacer(1));
232
+ this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
233
+
234
+ this.updateHeader();
235
+ this.updateHints();
236
+ this.applyFilter(this.searchInput.getValue());
237
+ }
238
+
239
+ setTodos(todos: TodoFrontMatter[]): void {
240
+ this.allTodos = todos;
241
+ this.updateHeader();
242
+ this.applyFilter(this.searchInput.getValue());
243
+ this.tui.requestRender();
244
+ }
245
+
246
+ getSearchValue(): string {
247
+ return this.searchInput.getValue();
248
+ }
249
+
250
+ private updateHeader(): void {
251
+ const openCount = this.allTodos.filter((todo) => !isTodoClosed(todo.status)).length;
252
+ const closedCount = this.allTodos.length - openCount;
253
+ const title = `Todos (${openCount} open, ${closedCount} closed)`;
254
+ this.headerText.setText(this.theme.fg("accent", this.theme.bold(title)));
255
+ }
256
+
257
+ private updateHints(): void {
258
+ this.hintText.setText(
259
+ this.theme.fg(
260
+ "dim",
261
+ "Type to search • ↑↓ select • Enter actions • Ctrl+Shift+W work • Ctrl+Shift+R refine • Esc close",
262
+ ),
263
+ );
264
+ }
265
+
266
+ private applyFilter(query: string): void {
267
+ this.filteredTodos = filterTodos(this.allTodos, query);
268
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredTodos.length - 1));
269
+ this.updateList();
270
+ }
271
+
272
+ private updateList(): void {
273
+ this.listContainer.clear();
274
+
275
+ if (this.filteredTodos.length === 0) {
276
+ this.listContainer.addChild(new Text(this.theme.fg("muted", " No matching todos"), 0, 0));
277
+ return;
278
+ }
279
+
280
+ const maxVisible = 10;
281
+ const startIndex = Math.max(
282
+ 0,
283
+ Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredTodos.length - maxVisible),
284
+ );
285
+ const endIndex = Math.min(startIndex + maxVisible, this.filteredTodos.length);
286
+
287
+ for (let i = startIndex; i < endIndex; i += 1) {
288
+ const todo = this.filteredTodos[i];
289
+ if (!todo) continue;
290
+ const isSelected = i === this.selectedIndex;
291
+ const closed = isTodoClosed(todo.status);
292
+ const prefix = isSelected ? this.theme.fg("accent", "→ ") : " ";
293
+ const titleColor = isSelected ? "accent" : closed ? "dim" : "text";
294
+ const statusColor = closed ? "dim" : "success";
295
+ const tagText = todo.tags.length ? ` [${todo.tags.join(", ")}]` : "";
296
+ const line =
297
+ prefix +
298
+ this.theme.fg("accent", formatTodoId(todo.id)) +
299
+ " " +
300
+ this.theme.fg(titleColor, todo.title || "(untitled)") +
301
+ this.theme.fg("muted", tagText) +
302
+ " " +
303
+ this.theme.fg(statusColor, `(${todo.status || "open"})`);
304
+ this.listContainer.addChild(new Text(line, 0, 0));
305
+ }
306
+
307
+ if (startIndex > 0 || endIndex < this.filteredTodos.length) {
308
+ const scrollInfo = this.theme.fg(
309
+ "dim",
310
+ ` (${this.selectedIndex + 1}/${this.filteredTodos.length})`,
311
+ );
312
+ this.listContainer.addChild(new Text(scrollInfo, 0, 0));
313
+ }
314
+ }
315
+
316
+ handleInput(keyData: string): void {
317
+ const kb = getEditorKeybindings();
318
+ if (kb.matches(keyData, "selectUp")) {
319
+ if (this.filteredTodos.length === 0) return;
320
+ this.selectedIndex = this.selectedIndex === 0 ? this.filteredTodos.length - 1 : this.selectedIndex - 1;
321
+ this.updateList();
322
+ return;
323
+ }
324
+ if (kb.matches(keyData, "selectDown")) {
325
+ if (this.filteredTodos.length === 0) return;
326
+ this.selectedIndex = this.selectedIndex === this.filteredTodos.length - 1 ? 0 : this.selectedIndex + 1;
327
+ this.updateList();
328
+ return;
329
+ }
330
+ if (kb.matches(keyData, "selectConfirm")) {
331
+ const selected = this.filteredTodos[this.selectedIndex];
332
+ if (selected) this.onSelectCallback(selected);
333
+ return;
334
+ }
335
+ if (kb.matches(keyData, "selectCancel")) {
336
+ this.onCancelCallback();
337
+ return;
338
+ }
339
+ if (matchesKey(keyData, Key.ctrlShift("r"))) {
340
+ const selected = this.filteredTodos[this.selectedIndex];
341
+ if (selected && this.onQuickAction) this.onQuickAction(selected, "refine");
342
+ return;
343
+ }
344
+ if (matchesKey(keyData, Key.ctrlShift("w"))) {
345
+ const selected = this.filteredTodos[this.selectedIndex];
346
+ if (selected && this.onQuickAction) this.onQuickAction(selected, "work");
347
+ return;
348
+ }
349
+ this.searchInput.handleInput(keyData);
350
+ this.applyFilter(this.searchInput.getValue());
351
+ }
352
+
353
+ override invalidate(): void {
354
+ super.invalidate();
355
+ this.updateHeader();
356
+ this.updateHints();
357
+ this.updateList();
358
+ }
359
+ }
360
+
361
+ class TodoDetailOverlayComponent {
362
+ private todo: TodoRecord;
363
+ private theme: Theme;
364
+ private tui: TUI;
365
+ private markdown: Markdown;
366
+ private scrollOffset = 0;
367
+ private viewHeight = 0;
368
+ private totalLines = 0;
369
+ private onAction: (action: TodoOverlayAction) => void;
370
+
371
+ constructor(tui: TUI, theme: Theme, todo: TodoRecord, onAction: (action: TodoOverlayAction) => void) {
372
+ this.tui = tui;
373
+ this.theme = theme;
374
+ this.todo = todo;
375
+ this.onAction = onAction;
376
+ this.markdown = new Markdown(this.getMarkdownText(), 1, 0, getMarkdownTheme());
377
+ }
378
+
379
+ private getMarkdownText(): string {
380
+ const body = this.todo.body?.trim();
381
+ return body ? body : "_No details yet._";
382
+ }
383
+
384
+ handleInput(keyData: string): void {
385
+ const kb = getEditorKeybindings();
386
+ if (kb.matches(keyData, "selectCancel")) {
387
+ this.onAction("cancel");
388
+ return;
389
+ }
390
+ if (kb.matches(keyData, "selectUp")) {
391
+ this.scrollBy(-1);
392
+ return;
393
+ }
394
+ if (kb.matches(keyData, "selectDown")) {
395
+ this.scrollBy(1);
396
+ return;
397
+ }
398
+ if (kb.matches(keyData, "selectPageUp")) {
399
+ this.scrollBy(-this.viewHeight || -1);
400
+ return;
401
+ }
402
+ if (kb.matches(keyData, "selectPageDown")) {
403
+ this.scrollBy(this.viewHeight || 1);
404
+ return;
405
+ }
406
+ if (keyData === "r" || keyData === "R") {
407
+ this.onAction("refine");
408
+ return;
409
+ }
410
+ if (keyData === "c" || keyData === "C") {
411
+ this.onAction("close");
412
+ return;
413
+ }
414
+ if (keyData === "o" || keyData === "O") {
415
+ this.onAction("reopen");
416
+ return;
417
+ }
418
+ if (keyData === "w" || keyData === "W") {
419
+ this.onAction("work");
420
+ return;
421
+ }
422
+ if (keyData === "a" || keyData === "A") {
423
+ this.onAction("actions");
424
+ return;
425
+ }
426
+ if (keyData === "d" || keyData === "D") {
427
+ this.onAction("delete");
428
+ return;
429
+ }
430
+ }
431
+
432
+ render(width: number): string[] {
433
+ const maxHeight = this.getMaxHeight();
434
+ const headerLines = 3;
435
+ const footerLines = 3;
436
+ const borderLines = 2;
437
+ const innerWidth = Math.max(10, width - 2);
438
+ const contentHeight = Math.max(1, maxHeight - headerLines - footerLines - borderLines);
439
+
440
+ const markdownLines = this.markdown.render(innerWidth);
441
+ this.totalLines = markdownLines.length;
442
+ this.viewHeight = contentHeight;
443
+ const maxScroll = Math.max(0, this.totalLines - contentHeight);
444
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScroll));
445
+
446
+ const visibleLines = markdownLines.slice(this.scrollOffset, this.scrollOffset + contentHeight);
447
+ const lines: string[] = [];
448
+
449
+ lines.push(this.buildTitleLine(innerWidth));
450
+ lines.push(this.buildMetaLine(innerWidth));
451
+ lines.push("");
452
+
453
+ for (const line of visibleLines) {
454
+ lines.push(truncateToWidth(line, innerWidth));
455
+ }
456
+ while (lines.length < headerLines + contentHeight) {
457
+ lines.push("");
458
+ }
459
+
460
+ lines.push("");
461
+ lines.push(this.buildActionLine(innerWidth));
462
+
463
+ const borderColor = (text: string) => this.theme.fg("borderMuted", text);
464
+ const top = borderColor(`┌${"─".repeat(innerWidth)}┐`);
465
+ const bottom = borderColor(`└${"─".repeat(innerWidth)}┘`);
466
+ const framedLines = lines.map((line) => {
467
+ const truncated = truncateToWidth(line, innerWidth);
468
+ const padding = Math.max(0, innerWidth - visibleWidth(truncated));
469
+ return borderColor("│") + truncated + " ".repeat(padding) + borderColor("│");
470
+ });
471
+
472
+ return [top, ...framedLines, bottom].map((line) => truncateToWidth(line, width));
473
+ }
474
+
475
+ invalidate(): void {
476
+ this.markdown = new Markdown(this.getMarkdownText(), 1, 0, getMarkdownTheme());
477
+ }
478
+
479
+ private getMaxHeight(): number {
480
+ const rows = this.tui.terminal.rows || 24;
481
+ return Math.max(10, Math.floor(rows * 0.8));
482
+ }
483
+
484
+ private buildTitleLine(width: number): string {
485
+ const titleText = this.todo.title
486
+ ? ` ${this.todo.title} `
487
+ : ` Todo ${formatTodoId(this.todo.id)} `;
488
+ const titleWidth = visibleWidth(titleText);
489
+ if (titleWidth >= width) {
490
+ return truncateToWidth(this.theme.fg("accent", titleText.trim()), width);
491
+ }
492
+ const leftWidth = Math.max(0, Math.floor((width - titleWidth) / 2));
493
+ const rightWidth = Math.max(0, width - titleWidth - leftWidth);
494
+ return (
495
+ this.theme.fg("borderMuted", "─".repeat(leftWidth)) +
496
+ this.theme.fg("accent", titleText) +
497
+ this.theme.fg("borderMuted", "─".repeat(rightWidth))
498
+ );
499
+ }
500
+
501
+ private buildMetaLine(width: number): string {
502
+ const status = this.todo.status || "open";
503
+ const statusColor = isTodoClosed(status) ? "dim" : "success";
504
+ const tagText = this.todo.tags.length ? this.todo.tags.join(", ") : "no tags";
505
+ const line =
506
+ this.theme.fg("accent", formatTodoId(this.todo.id)) +
507
+ this.theme.fg("muted", " • ") +
508
+ this.theme.fg(statusColor, status) +
509
+ this.theme.fg("muted", " • ") +
510
+ this.theme.fg("muted", tagText);
511
+ return truncateToWidth(line, width);
512
+ }
513
+
514
+ private buildActionLine(width: number): string {
515
+ const closed = isTodoClosed(this.todo.status);
516
+ const refine = this.theme.fg("accent", "r") + this.theme.fg("muted", " refine task");
517
+ const work = this.theme.fg("accent", "w") + this.theme.fg("muted", " work on todo");
518
+ const close = this.theme.fg("accent", "c") + this.theme.fg("muted", " close task");
519
+ const reopen = this.theme.fg("accent", "o") + this.theme.fg("muted", " reopen task");
520
+ const statusAction = closed ? reopen : close;
521
+ const actions = this.theme.fg("accent", "a") + this.theme.fg("muted", " actions");
522
+ const del = this.theme.fg("error", "d") + this.theme.fg("muted", " delete todo");
523
+ const back = this.theme.fg("dim", "esc back");
524
+ const pieces = [work, refine, statusAction, actions, del, back];
525
+
526
+ let line = pieces.join(this.theme.fg("muted", " • "));
527
+ if (this.totalLines > this.viewHeight) {
528
+ const start = Math.min(this.totalLines, this.scrollOffset + 1);
529
+ const end = Math.min(this.totalLines, this.scrollOffset + this.viewHeight);
530
+ const scrollInfo = this.theme.fg("dim", ` ${start}-${end}/${this.totalLines}`);
531
+ line += scrollInfo;
532
+ }
533
+
534
+ return truncateToWidth(line, width);
535
+ }
536
+
537
+ private scrollBy(delta: number): void {
538
+ const maxScroll = Math.max(0, this.totalLines - this.viewHeight);
539
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset + delta, maxScroll));
540
+ }
541
+ }
542
+
543
+ function getTodosDir(cwd: string): string {
544
+ const overridePath = process.env[TODO_PATH_ENV];
545
+ if (overridePath && overridePath.trim()) {
546
+ return path.resolve(cwd, overridePath.trim());
547
+ }
548
+ return path.resolve(cwd, TODO_DIR_NAME);
549
+ }
550
+
551
+ function getTodoSettingsPath(todosDir: string): string {
552
+ return path.join(todosDir, TODO_SETTINGS_NAME);
553
+ }
554
+
555
+ function normalizeTodoSettings(raw: Partial<TodoSettings>): TodoSettings {
556
+ const gc = raw.gc ?? DEFAULT_TODO_SETTINGS.gc;
557
+ const gcDays = Number.isFinite(raw.gcDays) ? raw.gcDays : DEFAULT_TODO_SETTINGS.gcDays;
558
+ return {
559
+ gc: Boolean(gc),
560
+ gcDays: Math.max(0, Math.floor(gcDays)),
561
+ };
562
+ }
563
+
564
+ async function readTodoSettings(todosDir: string): Promise<TodoSettings> {
565
+ const settingsPath = getTodoSettingsPath(todosDir);
566
+ let data: Partial<TodoSettings> = {};
567
+ let shouldWrite = false;
568
+
569
+ try {
570
+ const raw = await fs.readFile(settingsPath, "utf8");
571
+ data = JSON.parse(raw) as Partial<TodoSettings>;
572
+ } catch {
573
+ shouldWrite = true;
574
+ }
575
+
576
+ const normalized = normalizeTodoSettings(data);
577
+ if (
578
+ shouldWrite ||
579
+ data.gc === undefined ||
580
+ data.gcDays === undefined ||
581
+ !Number.isFinite(data.gcDays)
582
+ ) {
583
+ await fs.writeFile(settingsPath, JSON.stringify(normalized, null, 2) + "\n", "utf8");
584
+ }
585
+
586
+ return normalized;
587
+ }
588
+
589
+ async function garbageCollectTodos(todosDir: string, settings: TodoSettings): Promise<void> {
590
+ if (!settings.gc) return;
591
+
592
+ let entries: string[] = [];
593
+ try {
594
+ entries = await fs.readdir(todosDir);
595
+ } catch {
596
+ return;
597
+ }
598
+
599
+ const cutoff = Date.now() - settings.gcDays * 24 * 60 * 60 * 1000;
600
+ await Promise.all(
601
+ entries
602
+ .filter((entry) => entry.endsWith(".md"))
603
+ .map(async (entry) => {
604
+ const id = entry.slice(0, -3);
605
+ const filePath = path.join(todosDir, entry);
606
+ try {
607
+ const content = await fs.readFile(filePath, "utf8");
608
+ const { frontMatter } = splitFrontMatter(content);
609
+ const parsed = parseFrontMatter(frontMatter, id);
610
+ if (!isTodoClosed(parsed.status)) return;
611
+ const createdAt = Date.parse(parsed.created_at);
612
+ if (!Number.isFinite(createdAt)) return;
613
+ if (createdAt < cutoff) {
614
+ await fs.unlink(filePath);
615
+ }
616
+ } catch {
617
+ // ignore unreadable todo
618
+ }
619
+ }),
620
+ );
621
+ }
622
+
623
+ function getTodoPath(todosDir: string, id: string): string {
624
+ return path.join(todosDir, `${id}.md`);
625
+ }
626
+
627
+ function getLockPath(todosDir: string, id: string): string {
628
+ return path.join(todosDir, `${id}.lock`);
629
+ }
630
+
631
+ function stripQuotes(value: string): string {
632
+ const trimmed = value.trim();
633
+ if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
634
+ return trimmed.slice(1, -1);
635
+ }
636
+ return trimmed;
637
+ }
638
+
639
+ function parseTagsInline(value: string): string[] {
640
+ const inner = value.trim().slice(1, -1);
641
+ if (!inner.trim()) return [];
642
+ return inner
643
+ .split(",")
644
+ .map((item) => stripQuotes(item))
645
+ .map((item) => item.trim())
646
+ .filter(Boolean);
647
+ }
648
+
649
+ function parseFrontMatter(text: string, idFallback: string): TodoFrontMatter {
650
+ const data: TodoFrontMatter = {
651
+ id: idFallback,
652
+ title: "",
653
+ tags: [],
654
+ status: "open",
655
+ created_at: "",
656
+ };
657
+
658
+ let currentKey: string | null = null;
659
+ for (const rawLine of text.split(/\r?\n/)) {
660
+ const line = rawLine.trim();
661
+ if (!line) continue;
662
+
663
+ const listMatch = currentKey === "tags" ? line.match(/^-\s*(.+)$/) : null;
664
+ if (listMatch) {
665
+ data.tags.push(stripQuotes(listMatch[1]));
666
+ continue;
667
+ }
668
+
669
+ const match = line.match(/^(?<key>[a-zA-Z0-9_]+):\s*(?<value>.*)$/);
670
+ if (!match?.groups) continue;
671
+
672
+ const key = match.groups.key;
673
+ const value = match.groups.value ?? "";
674
+ currentKey = null;
675
+
676
+ if (key === "tags") {
677
+ if (!value) {
678
+ currentKey = "tags";
679
+ continue;
680
+ }
681
+ if (value.startsWith("[") && value.endsWith("]")) {
682
+ data.tags = parseTagsInline(value);
683
+ continue;
684
+ }
685
+ data.tags = [stripQuotes(value)].filter(Boolean);
686
+ continue;
687
+ }
688
+
689
+ switch (key) {
690
+ case "id":
691
+ data.id = stripQuotes(value) || data.id;
692
+ break;
693
+ case "title":
694
+ data.title = stripQuotes(value);
695
+ break;
696
+ case "status":
697
+ data.status = stripQuotes(value) || data.status;
698
+ break;
699
+ case "created_at":
700
+ data.created_at = stripQuotes(value);
701
+ break;
702
+ default:
703
+ break;
704
+ }
705
+ }
706
+
707
+ return data;
708
+ }
709
+
710
+ function splitFrontMatter(content: string): { frontMatter: string; body: string } {
711
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
712
+ if (!match) {
713
+ return { frontMatter: "", body: content };
714
+ }
715
+ const frontMatter = match[1] ?? "";
716
+ const body = content.slice(match[0].length);
717
+ return { frontMatter, body };
718
+ }
719
+
720
+ function parseTodoContent(content: string, idFallback: string): TodoRecord {
721
+ const { frontMatter, body } = splitFrontMatter(content);
722
+ const parsed = parseFrontMatter(frontMatter, idFallback);
723
+ return {
724
+ id: idFallback,
725
+ title: parsed.title,
726
+ tags: parsed.tags ?? [],
727
+ status: parsed.status,
728
+ created_at: parsed.created_at,
729
+ body: body ?? "",
730
+ };
731
+ }
732
+
733
+ function escapeYaml(value: string): string {
734
+ return value.replace(/\\/g, "\\\\").replace(/\"/g, "\\\"");
735
+ }
736
+
737
+ function serializeTodo(todo: TodoRecord): string {
738
+ const tags = todo.tags ?? [];
739
+ const lines = [
740
+ "---",
741
+ `id: \"${escapeYaml(todo.id)}\"`,
742
+ `title: \"${escapeYaml(todo.title)}\"`,
743
+ "tags:",
744
+ ...tags.map((tag) => ` - \"${escapeYaml(tag)}\"`),
745
+ `status: \"${escapeYaml(todo.status)}\"`,
746
+ `created_at: \"${escapeYaml(todo.created_at)}\"`,
747
+ "---",
748
+ "",
749
+ ];
750
+
751
+ const body = todo.body ?? "";
752
+ const trimmedBody = body.replace(/^\n+/, "").replace(/\s+$/, "");
753
+ return `${lines.join("\n")}${trimmedBody ? `${trimmedBody}\n` : ""}`;
754
+ }
755
+
756
+ async function ensureTodosDir(todosDir: string) {
757
+ await fs.mkdir(todosDir, { recursive: true });
758
+ }
759
+
760
+ async function readTodoFile(filePath: string, idFallback: string): Promise<TodoRecord> {
761
+ const content = await fs.readFile(filePath, "utf8");
762
+ return parseTodoContent(content, idFallback);
763
+ }
764
+
765
+ async function writeTodoFile(filePath: string, todo: TodoRecord) {
766
+ await fs.writeFile(filePath, serializeTodo(todo), "utf8");
767
+ }
768
+
769
+ async function generateTodoId(todosDir: string): Promise<string> {
770
+ for (let attempt = 0; attempt < 10; attempt += 1) {
771
+ const id = crypto.randomBytes(4).toString("hex");
772
+ const todoPath = getTodoPath(todosDir, id);
773
+ if (!existsSync(todoPath)) return id;
774
+ }
775
+ throw new Error("Failed to generate unique todo id");
776
+ }
777
+
778
+ async function readLockInfo(lockPath: string): Promise<LockInfo | null> {
779
+ try {
780
+ const raw = await fs.readFile(lockPath, "utf8");
781
+ return JSON.parse(raw) as LockInfo;
782
+ } catch {
783
+ return null;
784
+ }
785
+ }
786
+
787
+ async function acquireLock(
788
+ todosDir: string,
789
+ id: string,
790
+ ctx: ExtensionContext,
791
+ ): Promise<(() => Promise<void>) | { error: string }> {
792
+ const lockPath = getLockPath(todosDir, id);
793
+ const now = Date.now();
794
+ const session = ctx.sessionManager.getSessionFile();
795
+
796
+ for (let attempt = 0; attempt < 2; attempt += 1) {
797
+ try {
798
+ const handle = await fs.open(lockPath, "wx");
799
+ const info: LockInfo = {
800
+ id,
801
+ pid: process.pid,
802
+ session,
803
+ created_at: new Date(now).toISOString(),
804
+ };
805
+ await handle.writeFile(JSON.stringify(info, null, 2), "utf8");
806
+ await handle.close();
807
+ return async () => {
808
+ try {
809
+ await fs.unlink(lockPath);
810
+ } catch {
811
+ // ignore
812
+ }
813
+ };
814
+ } catch (error: any) {
815
+ if (error?.code !== "EEXIST") {
816
+ return { error: `Failed to acquire lock: ${error?.message ?? "unknown error"}` };
817
+ }
818
+ const stats = await fs.stat(lockPath).catch(() => null);
819
+ const lockAge = stats ? now - stats.mtimeMs : LOCK_TTL_MS + 1;
820
+ if (lockAge <= LOCK_TTL_MS) {
821
+ const info = await readLockInfo(lockPath);
822
+ const owner = info?.session ? ` (session ${info.session})` : "";
823
+ return { error: `Todo ${displayTodoId(id)} is locked${owner}. Try again later.` };
824
+ }
825
+ if (!ctx.hasUI) {
826
+ return { error: `Todo ${displayTodoId(id)} lock is stale; rerun in interactive mode to steal it.` };
827
+ }
828
+ const ok = await ctx.ui.confirm(
829
+ "Todo locked",
830
+ `Todo ${displayTodoId(id)} appears locked. Steal the lock?`,
831
+ );
832
+ if (!ok) {
833
+ return { error: `Todo ${displayTodoId(id)} remains locked.` };
834
+ }
835
+ await fs.unlink(lockPath).catch(() => undefined);
836
+ }
837
+ }
838
+
839
+ return { error: `Failed to acquire lock for todo ${displayTodoId(id)}.` };
840
+ }
841
+
842
+ async function withTodoLock<T>(
843
+ todosDir: string,
844
+ id: string,
845
+ ctx: ExtensionContext,
846
+ fn: () => Promise<T>,
847
+ ): Promise<T | { error: string }> {
848
+ const lock = await acquireLock(todosDir, id, ctx);
849
+ if (typeof lock === "object" && "error" in lock) return lock;
850
+ try {
851
+ return await fn();
852
+ } finally {
853
+ await lock();
854
+ }
855
+ }
856
+
857
+ async function listTodos(todosDir: string): Promise<TodoFrontMatter[]> {
858
+ let entries: string[] = [];
859
+ try {
860
+ entries = await fs.readdir(todosDir);
861
+ } catch {
862
+ return [];
863
+ }
864
+
865
+ const todos: TodoFrontMatter[] = [];
866
+ for (const entry of entries) {
867
+ if (!entry.endsWith(".md")) continue;
868
+ const id = entry.slice(0, -3);
869
+ const filePath = path.join(todosDir, entry);
870
+ try {
871
+ const content = await fs.readFile(filePath, "utf8");
872
+ const { frontMatter } = splitFrontMatter(content);
873
+ const parsed = parseFrontMatter(frontMatter, id);
874
+ todos.push({
875
+ id,
876
+ title: parsed.title,
877
+ tags: parsed.tags ?? [],
878
+ status: parsed.status,
879
+ created_at: parsed.created_at,
880
+ });
881
+ } catch {
882
+ // ignore unreadable todo
883
+ }
884
+ }
885
+
886
+ return sortTodos(todos);
887
+ }
888
+
889
+ function listTodosSync(todosDir: string): TodoFrontMatter[] {
890
+ let entries: string[] = [];
891
+ try {
892
+ entries = readdirSync(todosDir);
893
+ } catch {
894
+ return [];
895
+ }
896
+
897
+ const todos: TodoFrontMatter[] = [];
898
+ for (const entry of entries) {
899
+ if (!entry.endsWith(".md")) continue;
900
+ const id = entry.slice(0, -3);
901
+ const filePath = path.join(todosDir, entry);
902
+ try {
903
+ const content = readFileSync(filePath, "utf8");
904
+ const { frontMatter } = splitFrontMatter(content);
905
+ const parsed = parseFrontMatter(frontMatter, id);
906
+ todos.push({
907
+ id,
908
+ title: parsed.title,
909
+ tags: parsed.tags ?? [],
910
+ status: parsed.status,
911
+ created_at: parsed.created_at,
912
+ });
913
+ } catch {
914
+ // ignore
915
+ }
916
+ }
917
+
918
+ return sortTodos(todos);
919
+ }
920
+
921
+ function getTodoTitle(todo: TodoFrontMatter): string {
922
+ return todo.title || "(untitled)";
923
+ }
924
+
925
+ function getTodoStatus(todo: TodoFrontMatter): string {
926
+ return todo.status || "open";
927
+ }
928
+
929
+ function formatTodoHeading(todo: TodoFrontMatter): string {
930
+ const tagText = todo.tags.length ? ` [${todo.tags.join(", ")}]` : "";
931
+ return `${formatTodoId(todo.id)} ${getTodoTitle(todo)}${tagText}`;
932
+ }
933
+
934
+ function buildRefinePrompt(todoId: string, title: string): string {
935
+ return (
936
+ `let's refine task ${formatTodoId(todoId)} "${title}": ` +
937
+ "Please rewrite the todo body with a thorough, structured description so an engineer or agent can work without extra investigation. " +
938
+ "Include: Context, Goals, Scope/Non-scope, Checklist, Acceptance Criteria, and Risks/Open questions.\n\n"
939
+ );
940
+ }
941
+
942
+ function splitTodosByStatus(todos: TodoFrontMatter[]): { openTodos: TodoFrontMatter[]; closedTodos: TodoFrontMatter[] } {
943
+ const openTodos: TodoFrontMatter[] = [];
944
+ const closedTodos: TodoFrontMatter[] = [];
945
+ for (const todo of todos) {
946
+ if (isTodoClosed(getTodoStatus(todo))) {
947
+ closedTodos.push(todo);
948
+ } else {
949
+ openTodos.push(todo);
950
+ }
951
+ }
952
+ return { openTodos, closedTodos };
953
+ }
954
+
955
+ function formatTodoList(todos: TodoFrontMatter[]): string {
956
+ if (!todos.length) return "No todos.";
957
+
958
+ const { openTodos } = splitTodosByStatus(todos);
959
+ const lines: string[] = [];
960
+ const pushSection = (label: string, sectionTodos: TodoFrontMatter[]) => {
961
+ lines.push(`${label} (${sectionTodos.length}):`);
962
+ if (!sectionTodos.length) {
963
+ lines.push(" none");
964
+ return;
965
+ }
966
+ for (const todo of sectionTodos) {
967
+ lines.push(` ${formatTodoHeading(todo)}`);
968
+ }
969
+ };
970
+
971
+ pushSection("Open todos", openTodos);
972
+ return lines.join("\n");
973
+ }
974
+
975
+ function serializeTodoForAgent(todo: TodoRecord): string {
976
+ const payload = { ...todo, id: formatTodoId(todo.id) };
977
+ return JSON.stringify(payload, null, 2);
978
+ }
979
+
980
+ function serializeTodoListForAgent(todos: TodoFrontMatter[]): string {
981
+ const { openTodos, closedTodos } = splitTodosByStatus(todos);
982
+ const mapTodo = (todo: TodoFrontMatter) => ({ ...todo, id: formatTodoId(todo.id) });
983
+ return JSON.stringify(
984
+ {
985
+ open: openTodos.map(mapTodo),
986
+ closed: closedTodos.map(mapTodo),
987
+ },
988
+ null,
989
+ 2,
990
+ );
991
+ }
992
+
993
+ function renderTodoHeading(theme: Theme, todo: TodoFrontMatter): string {
994
+ const closed = isTodoClosed(getTodoStatus(todo));
995
+ const titleColor = closed ? "dim" : "text";
996
+ const tagText = todo.tags.length ? theme.fg("dim", ` [${todo.tags.join(", ")}]`) : "";
997
+ return (
998
+ theme.fg("accent", formatTodoId(todo.id)) +
999
+ " " +
1000
+ theme.fg(titleColor, getTodoTitle(todo)) +
1001
+ tagText
1002
+ );
1003
+ }
1004
+
1005
+ function renderTodoList(theme: Theme, todos: TodoFrontMatter[], expanded: boolean): string {
1006
+ if (!todos.length) return theme.fg("dim", "No todos");
1007
+
1008
+ const { openTodos, closedTodos } = splitTodosByStatus(todos);
1009
+ const lines: string[] = [];
1010
+ const pushSection = (label: string, sectionTodos: TodoFrontMatter[]) => {
1011
+ lines.push(theme.fg("muted", `${label} (${sectionTodos.length})`));
1012
+ if (!sectionTodos.length) {
1013
+ lines.push(theme.fg("dim", " none"));
1014
+ return;
1015
+ }
1016
+ const maxItems = expanded ? sectionTodos.length : Math.min(sectionTodos.length, 3);
1017
+ for (let i = 0; i < maxItems; i++) {
1018
+ lines.push(` ${renderTodoHeading(theme, sectionTodos[i])}`);
1019
+ }
1020
+ if (!expanded && sectionTodos.length > maxItems) {
1021
+ lines.push(theme.fg("dim", ` ... ${sectionTodos.length - maxItems} more`));
1022
+ }
1023
+ };
1024
+
1025
+ pushSection("Open todos", openTodos);
1026
+ if (expanded && closedTodos.length) {
1027
+ lines.push("");
1028
+ pushSection("Closed todos", closedTodos);
1029
+ }
1030
+ return lines.join("\n");
1031
+ }
1032
+
1033
+ function renderTodoDetail(theme: Theme, todo: TodoRecord, expanded: boolean): string {
1034
+ const summary = renderTodoHeading(theme, todo);
1035
+ if (!expanded) return summary;
1036
+
1037
+ const tags = todo.tags.length ? todo.tags.join(", ") : "none";
1038
+ const createdAt = todo.created_at || "unknown";
1039
+ const bodyText = todo.body?.trim() ? todo.body.trim() : "No details yet.";
1040
+ const bodyLines = bodyText.split("\n");
1041
+
1042
+ const lines = [
1043
+ summary,
1044
+ theme.fg("muted", `Status: ${getTodoStatus(todo)}`),
1045
+ theme.fg("muted", `Tags: ${tags}`),
1046
+ theme.fg("muted", `Created: ${createdAt}`),
1047
+ "",
1048
+ theme.fg("muted", "Body:"),
1049
+ ...bodyLines.map((line) => theme.fg("text", ` ${line}`)),
1050
+ ];
1051
+
1052
+ return lines.join("\n");
1053
+ }
1054
+
1055
+ function appendExpandHint(theme: Theme, text: string): string {
1056
+ return `${text}\n${theme.fg("dim", `(${keyHint("expandTools", "to expand")})`)}`;
1057
+ }
1058
+
1059
+ async function ensureTodoExists(filePath: string, id: string): Promise<TodoRecord | null> {
1060
+ if (!existsSync(filePath)) return null;
1061
+ return readTodoFile(filePath, id);
1062
+ }
1063
+
1064
+ async function appendTodoBody(filePath: string, todo: TodoRecord, text: string): Promise<TodoRecord> {
1065
+ const spacer = todo.body.trim().length ? "\n\n" : "";
1066
+ todo.body = `${todo.body.replace(/\s+$/, "")}${spacer}${text.trim()}\n`;
1067
+ await writeTodoFile(filePath, todo);
1068
+ return todo;
1069
+ }
1070
+
1071
+ async function updateTodoStatus(
1072
+ todosDir: string,
1073
+ id: string,
1074
+ status: string,
1075
+ ctx: ExtensionContext,
1076
+ ): Promise<TodoRecord | { error: string }> {
1077
+ const normalizedId = normalizeTodoId(id);
1078
+ const filePath = getTodoPath(todosDir, normalizedId);
1079
+ if (!existsSync(filePath)) {
1080
+ return { error: `Todo ${displayTodoId(id)} not found` };
1081
+ }
1082
+
1083
+ const result = await withTodoLock(todosDir, normalizedId, ctx, async () => {
1084
+ const existing = await ensureTodoExists(filePath, normalizedId);
1085
+ if (!existing) return { error: `Todo ${displayTodoId(id)} not found` } as const;
1086
+ existing.status = status;
1087
+ await writeTodoFile(filePath, existing);
1088
+ return existing;
1089
+ });
1090
+
1091
+ if (typeof result === "object" && "error" in result) {
1092
+ return { error: result.error };
1093
+ }
1094
+
1095
+ return result;
1096
+ }
1097
+
1098
+ async function deleteTodo(
1099
+ todosDir: string,
1100
+ id: string,
1101
+ ctx: ExtensionContext,
1102
+ ): Promise<TodoRecord | { error: string }> {
1103
+ const normalizedId = normalizeTodoId(id);
1104
+ const filePath = getTodoPath(todosDir, normalizedId);
1105
+ if (!existsSync(filePath)) {
1106
+ return { error: `Todo ${displayTodoId(id)} not found` };
1107
+ }
1108
+
1109
+ const result = await withTodoLock(todosDir, normalizedId, ctx, async () => {
1110
+ const existing = await ensureTodoExists(filePath, normalizedId);
1111
+ if (!existing) return { error: `Todo ${displayTodoId(id)} not found` } as const;
1112
+ await fs.unlink(filePath);
1113
+ return existing;
1114
+ });
1115
+
1116
+ if (typeof result === "object" && "error" in result) {
1117
+ return { error: result.error };
1118
+ }
1119
+
1120
+ return result;
1121
+ }
1122
+
1123
+ export default function todosExtension(pi: ExtensionAPI) {
1124
+ pi.on("session_start", async (_event, ctx) => {
1125
+ const todosDir = getTodosDir(ctx.cwd);
1126
+ await ensureTodosDir(todosDir);
1127
+ const settings = await readTodoSettings(todosDir);
1128
+ await garbageCollectTodos(todosDir, settings);
1129
+ });
1130
+
1131
+ pi.registerTool({
1132
+ name: "todo",
1133
+ label: "Todo",
1134
+ description:
1135
+ "Manage file-based todos in .pi/todos (list, list-all, get, create, update, append, delete). " +
1136
+ "Title is the short summary; body is long-form markdown notes (update replaces, append adds). " +
1137
+ "Todo ids are shown as TODO-<hex>; id parameters accept TODO-<hex> or the raw hex filename. " +
1138
+ "Close todos when the work is done. Set PI_ISSUE_PATH to override the todo directory.",
1139
+ parameters: TodoParams,
1140
+
1141
+ async execute(_toolCallId, params, _onUpdate, ctx) {
1142
+ const todosDir = getTodosDir(ctx.cwd);
1143
+ const action: TodoAction = params.action;
1144
+
1145
+ switch (action) {
1146
+ case "list": {
1147
+ const todos = await listTodos(todosDir);
1148
+ const { openTodos } = splitTodosByStatus(todos);
1149
+ return {
1150
+ content: [{ type: "text", text: serializeTodoListForAgent(openTodos) }],
1151
+ details: { action: "list", todos: openTodos },
1152
+ };
1153
+ }
1154
+
1155
+ case "list-all": {
1156
+ const todos = await listTodos(todosDir);
1157
+ return {
1158
+ content: [{ type: "text", text: serializeTodoListForAgent(todos) }],
1159
+ details: { action: "list-all", todos },
1160
+ };
1161
+ }
1162
+
1163
+ case "get": {
1164
+ if (!params.id) {
1165
+ return {
1166
+ content: [{ type: "text", text: "Error: id required" }],
1167
+ details: { action: "get", error: "id required" },
1168
+ };
1169
+ }
1170
+ const normalizedId = normalizeTodoId(params.id);
1171
+ const displayId = displayTodoId(params.id);
1172
+ const filePath = getTodoPath(todosDir, normalizedId);
1173
+ const todo = await ensureTodoExists(filePath, normalizedId);
1174
+ if (!todo) {
1175
+ return {
1176
+ content: [{ type: "text", text: `Todo ${displayId} not found` }],
1177
+ details: { action: "get", error: "not found" },
1178
+ };
1179
+ }
1180
+ return {
1181
+ content: [{ type: "text", text: serializeTodoForAgent(todo) }],
1182
+ details: { action: "get", todo },
1183
+ };
1184
+ }
1185
+
1186
+ case "create": {
1187
+ if (!params.title) {
1188
+ return {
1189
+ content: [{ type: "text", text: "Error: title required" }],
1190
+ details: { action: "create", error: "title required" },
1191
+ };
1192
+ }
1193
+ await ensureTodosDir(todosDir);
1194
+ const id = await generateTodoId(todosDir);
1195
+ const filePath = getTodoPath(todosDir, id);
1196
+ const todo: TodoRecord = {
1197
+ id,
1198
+ title: params.title,
1199
+ tags: params.tags ?? [],
1200
+ status: params.status ?? "open",
1201
+ created_at: new Date().toISOString(),
1202
+ body: params.body ?? "",
1203
+ };
1204
+
1205
+ const result = await withTodoLock(todosDir, id, ctx, async () => {
1206
+ await writeTodoFile(filePath, todo);
1207
+ return todo;
1208
+ });
1209
+
1210
+ if (typeof result === "object" && "error" in result) {
1211
+ return {
1212
+ content: [{ type: "text", text: result.error }],
1213
+ details: { action: "create", error: result.error },
1214
+ };
1215
+ }
1216
+
1217
+ return {
1218
+ content: [{ type: "text", text: serializeTodoForAgent(todo) }],
1219
+ details: { action: "create", todo },
1220
+ };
1221
+ }
1222
+
1223
+ case "update": {
1224
+ if (!params.id) {
1225
+ return {
1226
+ content: [{ type: "text", text: "Error: id required" }],
1227
+ details: { action: "update", error: "id required" },
1228
+ };
1229
+ }
1230
+ const normalizedId = normalizeTodoId(params.id);
1231
+ const displayId = displayTodoId(params.id);
1232
+ const filePath = getTodoPath(todosDir, normalizedId);
1233
+ if (!existsSync(filePath)) {
1234
+ return {
1235
+ content: [{ type: "text", text: `Todo ${displayId} not found` }],
1236
+ details: { action: "update", error: "not found" },
1237
+ };
1238
+ }
1239
+ const result = await withTodoLock(todosDir, normalizedId, ctx, async () => {
1240
+ const existing = await ensureTodoExists(filePath, normalizedId);
1241
+ if (!existing) return { error: `Todo ${displayId} not found` } as const;
1242
+
1243
+ existing.id = normalizedId;
1244
+ if (params.title !== undefined) existing.title = params.title;
1245
+ if (params.status !== undefined) existing.status = params.status;
1246
+ if (params.tags !== undefined) existing.tags = params.tags;
1247
+ if (params.body !== undefined) existing.body = params.body;
1248
+ if (!existing.created_at) existing.created_at = new Date().toISOString();
1249
+
1250
+ await writeTodoFile(filePath, existing);
1251
+ return existing;
1252
+ });
1253
+
1254
+ if (typeof result === "object" && "error" in result) {
1255
+ return {
1256
+ content: [{ type: "text", text: result.error }],
1257
+ details: { action: "update", error: result.error },
1258
+ };
1259
+ }
1260
+
1261
+ const updatedTodo = result as TodoRecord;
1262
+ return {
1263
+ content: [{ type: "text", text: serializeTodoForAgent(updatedTodo) }],
1264
+ details: { action: "update", todo: updatedTodo },
1265
+ };
1266
+ }
1267
+
1268
+ case "append": {
1269
+ if (!params.id) {
1270
+ return {
1271
+ content: [{ type: "text", text: "Error: id required" }],
1272
+ details: { action: "append", error: "id required" },
1273
+ };
1274
+ }
1275
+ const normalizedId = normalizeTodoId(params.id);
1276
+ const displayId = displayTodoId(params.id);
1277
+ const filePath = getTodoPath(todosDir, normalizedId);
1278
+ if (!existsSync(filePath)) {
1279
+ return {
1280
+ content: [{ type: "text", text: `Todo ${displayId} not found` }],
1281
+ details: { action: "append", error: "not found" },
1282
+ };
1283
+ }
1284
+ const result = await withTodoLock(todosDir, normalizedId, ctx, async () => {
1285
+ const existing = await ensureTodoExists(filePath, normalizedId);
1286
+ if (!existing) return { error: `Todo ${displayId} not found` } as const;
1287
+ if (!params.body || !params.body.trim()) {
1288
+ return existing;
1289
+ }
1290
+ const updated = await appendTodoBody(filePath, existing, params.body);
1291
+ return updated;
1292
+ });
1293
+
1294
+ if (typeof result === "object" && "error" in result) {
1295
+ return {
1296
+ content: [{ type: "text", text: result.error }],
1297
+ details: { action: "append", error: result.error },
1298
+ };
1299
+ }
1300
+
1301
+ const updatedTodo = result as TodoRecord;
1302
+ return {
1303
+ content: [{ type: "text", text: serializeTodoForAgent(updatedTodo) }],
1304
+ details: { action: "append", todo: updatedTodo },
1305
+ };
1306
+ }
1307
+
1308
+ case "delete": {
1309
+ if (!params.id) {
1310
+ return {
1311
+ content: [{ type: "text", text: "Error: id required" }],
1312
+ details: { action: "delete", error: "id required" },
1313
+ };
1314
+ }
1315
+
1316
+ const normalizedId = normalizeTodoId(params.id);
1317
+ const result = await deleteTodo(todosDir, normalizedId, ctx);
1318
+ if (typeof result === "object" && "error" in result) {
1319
+ return {
1320
+ content: [{ type: "text", text: result.error }],
1321
+ details: { action: "delete", error: result.error },
1322
+ };
1323
+ }
1324
+
1325
+ return {
1326
+ content: [{ type: "text", text: serializeTodoForAgent(result as TodoRecord) }],
1327
+ details: { action: "delete", todo: result as TodoRecord },
1328
+ };
1329
+ }
1330
+ }
1331
+ },
1332
+
1333
+
1334
+ renderCall(args, theme) {
1335
+ const action = typeof args.action === "string" ? args.action : "";
1336
+ const id = typeof args.id === "string" ? args.id : "";
1337
+ const normalizedId = id ? normalizeTodoId(id) : "";
1338
+ const title = typeof args.title === "string" ? args.title : "";
1339
+ let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", action);
1340
+ if (normalizedId) {
1341
+ text += " " + theme.fg("accent", formatTodoId(normalizedId));
1342
+ }
1343
+ if (title) {
1344
+ text += " " + theme.fg("dim", `"${title}"`);
1345
+ }
1346
+ return new Text(text, 0, 0);
1347
+ },
1348
+
1349
+ renderResult(result, { expanded, isPartial }, theme) {
1350
+ const details = result.details as TodoToolDetails | undefined;
1351
+ if (isPartial) {
1352
+ return new Text(theme.fg("warning", "Processing..."), 0, 0);
1353
+ }
1354
+ if (!details) {
1355
+ const text = result.content[0];
1356
+ return new Text(text?.type === "text" ? text.text : "", 0, 0);
1357
+ }
1358
+
1359
+ if (details.error) {
1360
+ return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
1361
+ }
1362
+
1363
+ if (details.action === "list" || details.action === "list-all") {
1364
+ let text = renderTodoList(theme, details.todos, expanded);
1365
+ if (!expanded) {
1366
+ const { closedTodos } = splitTodosByStatus(details.todos);
1367
+ if (closedTodos.length) {
1368
+ text = appendExpandHint(theme, text);
1369
+ }
1370
+ }
1371
+ return new Text(text, 0, 0);
1372
+ }
1373
+
1374
+ if (!details.todo) {
1375
+ const text = result.content[0];
1376
+ return new Text(text?.type === "text" ? text.text : "", 0, 0);
1377
+ }
1378
+
1379
+ let text = renderTodoDetail(theme, details.todo, expanded);
1380
+ const actionLabel =
1381
+ details.action === "create"
1382
+ ? "Created"
1383
+ : details.action === "update"
1384
+ ? "Updated"
1385
+ : details.action === "append"
1386
+ ? "Appended to"
1387
+ : details.action === "delete"
1388
+ ? "Deleted"
1389
+ : null;
1390
+ if (actionLabel) {
1391
+ const lines = text.split("\n");
1392
+ lines[0] = theme.fg("success", "✓ ") + theme.fg("muted", `${actionLabel} `) + lines[0];
1393
+ text = lines.join("\n");
1394
+ }
1395
+ if (!expanded) {
1396
+ text = appendExpandHint(theme, text);
1397
+ }
1398
+ return new Text(text, 0, 0);
1399
+ },
1400
+ });
1401
+
1402
+ pi.registerCommand("todos", {
1403
+ description: "List todos from .pi/todos",
1404
+ getArgumentCompletions: (argumentPrefix: string) => {
1405
+ const todos = listTodosSync(getTodosDir(process.cwd()));
1406
+ if (!todos.length) return null;
1407
+ const matches = filterTodos(todos, argumentPrefix);
1408
+ if (!matches.length) return null;
1409
+ return matches.map((todo) => {
1410
+ const title = todo.title || "(untitled)";
1411
+ const tags = todo.tags.length ? ` • ${todo.tags.join(", ")}` : "";
1412
+ return {
1413
+ value: title,
1414
+ label: `${formatTodoId(todo.id)} ${title}`,
1415
+ description: `${todo.status || "open"}${tags}`,
1416
+ };
1417
+ });
1418
+ },
1419
+ handler: async (args, ctx) => {
1420
+ const todosDir = getTodosDir(ctx.cwd);
1421
+ const todos = await listTodos(todosDir);
1422
+ const searchTerm = (args ?? "").trim();
1423
+
1424
+ if (!ctx.hasUI) {
1425
+ const text = formatTodoList(todos);
1426
+ console.log(text);
1427
+ return;
1428
+ }
1429
+
1430
+ let nextPrompt: string | null = null;
1431
+ let rootTui: TUI | null = null;
1432
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
1433
+ rootTui = tui;
1434
+ let selector: TodoSelectorComponent | null = null;
1435
+
1436
+ const addTodoPathToPrompt = (todoId: string) => {
1437
+ const filePath = getTodoPath(todosDir, todoId);
1438
+ const relativePath = path.relative(ctx.cwd, filePath);
1439
+ const displayPath =
1440
+ relativePath && !relativePath.startsWith("..") ? relativePath : filePath;
1441
+ const mention = `@${displayPath}`;
1442
+ const current = ctx.ui.getEditorText();
1443
+ const separator = current && !current.endsWith(" ") ? " " : "";
1444
+ ctx.ui.setEditorText(`${current}${separator}${mention}`);
1445
+ ctx.ui.notify(`Added ${mention} to prompt`, "info");
1446
+ };
1447
+
1448
+ const resolveTodoRecord = async (todo: TodoFrontMatter): Promise<TodoRecord | null> => {
1449
+ const filePath = getTodoPath(todosDir, todo.id);
1450
+ const record = await ensureTodoExists(filePath, todo.id);
1451
+ if (!record) {
1452
+ ctx.ui.notify(`Todo ${formatTodoId(todo.id)} not found`, "error");
1453
+ return null;
1454
+ }
1455
+ return record;
1456
+ };
1457
+
1458
+ const openTodoOverlay = async (record: TodoRecord) => {
1459
+ const action = await ctx.ui.custom<TodoOverlayAction>(
1460
+ (overlayTui, overlayTheme, _overlayKb, overlayDone) =>
1461
+ new TodoDetailOverlayComponent(overlayTui, overlayTheme, record, overlayDone),
1462
+ {
1463
+ overlay: true,
1464
+ overlayOptions: { width: "80%", maxHeight: "80%", anchor: "center" },
1465
+ },
1466
+ );
1467
+
1468
+ if (!action || action === "cancel") return;
1469
+ if (action === "actions") {
1470
+ await showActionMenu(record);
1471
+ return;
1472
+ }
1473
+ await applyTodoAction(record, action);
1474
+ };
1475
+
1476
+ const applyTodoAction = async (record: TodoRecord, action: TodoMenuAction) => {
1477
+ if (action === "cancel") return;
1478
+ if (action === "close-dialog") {
1479
+ done();
1480
+ return;
1481
+ }
1482
+ if (action === "refine") {
1483
+ const title = record.title || "(untitled)";
1484
+ nextPrompt = buildRefinePrompt(record.id, title);
1485
+ done();
1486
+ return;
1487
+ }
1488
+ if (action === "work") {
1489
+ const title = record.title || "(untitled)";
1490
+ nextPrompt = `work on todo ${formatTodoId(record.id)} "${title}"`;
1491
+ done();
1492
+ return;
1493
+ }
1494
+ if (action === "view") {
1495
+ await openTodoOverlay(record);
1496
+ return;
1497
+ }
1498
+ if (action === "copy-path") {
1499
+ addTodoPathToPrompt(record.id);
1500
+ return;
1501
+ }
1502
+
1503
+ if (action === "delete") {
1504
+ const ok = await ctx.ui.confirm(
1505
+ "Delete todo",
1506
+ `Delete todo ${formatTodoId(record.id)}? This cannot be undone.`,
1507
+ );
1508
+ if (!ok) {
1509
+ return;
1510
+ }
1511
+ const result = await deleteTodo(todosDir, record.id, ctx);
1512
+ if ("error" in result) {
1513
+ ctx.ui.notify(result.error, "error");
1514
+ return;
1515
+ }
1516
+ const updatedTodos = await listTodos(todosDir);
1517
+ selector?.setTodos(updatedTodos);
1518
+ ctx.ui.notify(`Deleted todo ${formatTodoId(record.id)}`, "info");
1519
+ return;
1520
+ }
1521
+
1522
+ const nextStatus = action === "close" ? "closed" : "open";
1523
+ const result = await updateTodoStatus(todosDir, record.id, nextStatus, ctx);
1524
+ if ("error" in result) {
1525
+ ctx.ui.notify(result.error, "error");
1526
+ return;
1527
+ }
1528
+
1529
+ const updatedTodos = await listTodos(todosDir);
1530
+ selector?.setTodos(updatedTodos);
1531
+ ctx.ui.notify(
1532
+ `${action === "close" ? "Closed" : "Reopened"} todo ${formatTodoId(record.id)}`,
1533
+ "info",
1534
+ );
1535
+ };
1536
+
1537
+ const showActionMenu = async (todo: TodoFrontMatter | TodoRecord) => {
1538
+ const record = "body" in todo ? todo : await resolveTodoRecord(todo);
1539
+ if (!record) return;
1540
+ const options: SelectItem[] = [
1541
+ { value: "view", label: "view", description: "View todo" },
1542
+ { value: "work", label: "work", description: "Work on todo" },
1543
+ { value: "refine", label: "refine", description: "Refine task" },
1544
+ { value: "close", label: "close", description: "Close todo" },
1545
+ { value: "reopen", label: "reopen", description: "Reopen todo" },
1546
+ { value: "copy-path", label: "copy", description: "Copy todo path into prompt" },
1547
+ { value: "delete", label: "delete", description: "Delete todo" },
1548
+ ];
1549
+ const title = record.title || "(untitled)";
1550
+ const selection = await ctx.ui.custom<TodoMenuAction | null>(
1551
+ (overlayTui, overlayTheme, _overlayKb, overlayDone) => {
1552
+ const container = new Container();
1553
+ container.addChild(
1554
+ new Text(
1555
+ overlayTheme.fg(
1556
+ "accent",
1557
+ overlayTheme.bold(`Actions for ${formatTodoId(record.id)} "${title}"`),
1558
+ ),
1559
+ ),
1560
+ );
1561
+ container.addChild(new Spacer(1));
1562
+
1563
+ const selectList = new SelectList(options, options.length, {
1564
+ selectedPrefix: (text) => overlayTheme.fg("accent", text),
1565
+ selectedText: (text) => overlayTheme.fg("accent", text),
1566
+ description: (text) => overlayTheme.fg("muted", text),
1567
+ scrollInfo: (text) => overlayTheme.fg("dim", text),
1568
+ noMatch: (text) => overlayTheme.fg("warning", text),
1569
+ });
1570
+
1571
+ selectList.onSelect = (item) => overlayDone(item.value as TodoMenuAction);
1572
+ selectList.onCancel = () => overlayDone(null);
1573
+
1574
+ container.addChild(selectList);
1575
+ container.addChild(new Spacer(1));
1576
+ container.addChild(
1577
+ new Text(overlayTheme.fg("dim", "Press enter to confirm or esc to cancel")),
1578
+ );
1579
+
1580
+ return {
1581
+ render(width: number) {
1582
+ const innerWidth = Math.max(10, width - 2);
1583
+ const contentLines = container.render(innerWidth);
1584
+ const borderColor = (text: string) => overlayTheme.fg("accent", text);
1585
+ const top = borderColor(`┌${"─".repeat(innerWidth)}┐`);
1586
+ const bottom = borderColor(`└${"─".repeat(innerWidth)}┘`);
1587
+ const framed = contentLines.map((line) => {
1588
+ const truncated = truncateToWidth(line, innerWidth);
1589
+ const padding = Math.max(0, innerWidth - visibleWidth(truncated));
1590
+ return (
1591
+ borderColor("│") + truncated + " ".repeat(padding) + borderColor("│")
1592
+ );
1593
+ });
1594
+ return [top, ...framed, bottom].map((line) => truncateToWidth(line, width));
1595
+ },
1596
+ invalidate() {
1597
+ container.invalidate();
1598
+ },
1599
+ handleInput(data: string) {
1600
+ selectList.handleInput(data);
1601
+ overlayTui.requestRender();
1602
+ },
1603
+ };
1604
+ },
1605
+ {
1606
+ overlay: true,
1607
+ overlayOptions: { width: "70%", maxHeight: "60%", anchor: "center" },
1608
+ },
1609
+ );
1610
+
1611
+ if (!selection) {
1612
+ tui.requestRender();
1613
+ return;
1614
+ }
1615
+ await applyTodoAction(record, selection);
1616
+ };
1617
+
1618
+ const handleSelect = async (todo: TodoFrontMatter) => {
1619
+ await showActionMenu(todo);
1620
+ };
1621
+
1622
+ selector = new TodoSelectorComponent(
1623
+ tui,
1624
+ theme,
1625
+ todos,
1626
+ (todo) => {
1627
+ void handleSelect(todo);
1628
+ },
1629
+ () => done(),
1630
+ searchTerm || undefined,
1631
+ (todo, action) => {
1632
+ const title = todo.title || "(untitled)";
1633
+ nextPrompt =
1634
+ action === "refine"
1635
+ ? buildRefinePrompt(todo.id, title)
1636
+ : `work on todo ${formatTodoId(todo.id)} "${title}"`;
1637
+ done();
1638
+ },
1639
+ );
1640
+
1641
+ return selector;
1642
+ });
1643
+
1644
+ if (nextPrompt) {
1645
+ ctx.ui.setEditorText(nextPrompt);
1646
+ rootTui?.requestRender();
1647
+ }
1648
+ },
1649
+ });
1650
+
1651
+ }