mitsupi 1.0.1 → 1.0.3

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,2058 @@
1
+ /**
2
+ * This extension stores todo items as files under <todo-dir> (defaults to .pi/todos,
3
+ * or the path in PI_TODO_PATH). Each todo is a standalone markdown file named
4
+ * <id>.md and an optional <id>.lock file is used while a session is editing it.
5
+ *
6
+ * File format in .pi/todos:
7
+ * - The file starts with a JSON object (not YAML) containing the front matter:
8
+ * { id, title, tags, status, created_at, assigned_to_session }
9
+ * - After the JSON block comes optional markdown body text separated by a blank line.
10
+ * - Example:
11
+ * {
12
+ * "id": "deadbeef",
13
+ * "title": "Add tests",
14
+ * "tags": ["qa"],
15
+ * "status": "open",
16
+ * "created_at": "2026-01-25T17:00:00.000Z",
17
+ * "assigned_to_session": "session.json"
18
+ * }
19
+ *
20
+ * Notes about the work go here.
21
+ *
22
+ * Todo storage settings are kept in <todo-dir>/settings.json.
23
+ * Defaults:
24
+ * {
25
+ * "gc": true, // delete closed todos older than gcDays on startup
26
+ * "gcDays": 7 // age threshold for GC (days since created_at)
27
+ * }
28
+ *
29
+ * Use `/todos` to bring up the visual todo manager or just let the LLM use them
30
+ * naturally.
31
+ */
32
+ import { DynamicBorder, copyToClipboard, getMarkdownTheme, keyHint, type ExtensionAPI, type ExtensionContext, type Theme } from "@mariozechner/pi-coding-agent";
33
+ import { StringEnum } from "@mariozechner/pi-ai";
34
+ import { Type } from "@sinclair/typebox";
35
+ import path from "node:path";
36
+ import fs from "node:fs/promises";
37
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
38
+ import crypto from "node:crypto";
39
+ import {
40
+ Container,
41
+ type Focusable,
42
+ Input,
43
+ Key,
44
+ Markdown,
45
+ SelectList,
46
+ Spacer,
47
+ type SelectItem,
48
+ Text,
49
+ TUI,
50
+ fuzzyMatch,
51
+ getEditorKeybindings,
52
+ matchesKey,
53
+ truncateToWidth,
54
+ visibleWidth,
55
+ } from "@mariozechner/pi-tui";
56
+
57
+ const TODO_DIR_NAME = ".pi/todos";
58
+ const TODO_PATH_ENV = "PI_TODO_PATH";
59
+ const TODO_SETTINGS_NAME = "settings.json";
60
+ const TODO_ID_PREFIX = "TODO-";
61
+ const TODO_ID_PATTERN = /^[a-f0-9]{8}$/i;
62
+ const DEFAULT_TODO_SETTINGS = {
63
+ gc: true,
64
+ gcDays: 7,
65
+ };
66
+ const LOCK_TTL_MS = 30 * 60 * 1000;
67
+
68
+ interface TodoFrontMatter {
69
+ id: string;
70
+ title: string;
71
+ tags: string[];
72
+ status: string;
73
+ created_at: string;
74
+ assigned_to_session?: string;
75
+ }
76
+
77
+ interface TodoRecord extends TodoFrontMatter {
78
+ body: string;
79
+ }
80
+
81
+ interface LockInfo {
82
+ id: string;
83
+ pid: number;
84
+ session?: string | null;
85
+ created_at: string;
86
+ }
87
+
88
+ interface TodoSettings {
89
+ gc: boolean;
90
+ gcDays: number;
91
+ }
92
+
93
+ const TodoParams = Type.Object({
94
+ action: StringEnum([
95
+ "list",
96
+ "list-all",
97
+ "get",
98
+ "create",
99
+ "update",
100
+ "append",
101
+ "delete",
102
+ "claim",
103
+ "release",
104
+ ] as const),
105
+ id: Type.Optional(
106
+ Type.String({ description: "Todo id (TODO-<hex> or raw hex filename)" }),
107
+ ),
108
+ title: Type.Optional(Type.String({ description: "Short summary shown in lists" })),
109
+ status: Type.Optional(Type.String({ description: "Todo status" })),
110
+ tags: Type.Optional(Type.Array(Type.String({ description: "Todo tag" }))),
111
+ body: Type.Optional(
112
+ Type.String({ description: "Long-form details (markdown). Update replaces; append adds." }),
113
+ ),
114
+ force: Type.Optional(Type.Boolean({ description: "Override another session's assignment" })),
115
+ });
116
+
117
+ type TodoAction =
118
+ | "list"
119
+ | "list-all"
120
+ | "get"
121
+ | "create"
122
+ | "update"
123
+ | "append"
124
+ | "delete"
125
+ | "claim"
126
+ | "release";
127
+
128
+ type TodoOverlayAction = "back" | "work";
129
+
130
+ type TodoMenuAction =
131
+ | "work"
132
+ | "refine"
133
+ | "close"
134
+ | "reopen"
135
+ | "release"
136
+ | "delete"
137
+ | "copy"
138
+ | "view";
139
+
140
+ type TodoToolDetails =
141
+ | { action: "list" | "list-all"; todos: TodoFrontMatter[]; currentSessionId?: string; error?: string }
142
+ | {
143
+ action: "get" | "create" | "update" | "append" | "delete" | "claim" | "release";
144
+ todo: TodoRecord;
145
+ error?: string;
146
+ };
147
+
148
+ function formatTodoId(id: string): string {
149
+ return `${TODO_ID_PREFIX}${id}`;
150
+ }
151
+
152
+ function normalizeTodoId(id: string): string {
153
+ let trimmed = id.trim();
154
+ if (trimmed.startsWith("#")) {
155
+ trimmed = trimmed.slice(1);
156
+ }
157
+ if (trimmed.toUpperCase().startsWith(TODO_ID_PREFIX)) {
158
+ trimmed = trimmed.slice(TODO_ID_PREFIX.length);
159
+ }
160
+ return trimmed;
161
+ }
162
+
163
+ function validateTodoId(id: string): { id: string } | { error: string } {
164
+ const normalized = normalizeTodoId(id);
165
+ if (!normalized || !TODO_ID_PATTERN.test(normalized)) {
166
+ return { error: "Invalid todo id. Expected TODO-<hex>." };
167
+ }
168
+ return { id: normalized.toLowerCase() };
169
+ }
170
+
171
+ function displayTodoId(id: string): string {
172
+ return formatTodoId(normalizeTodoId(id));
173
+ }
174
+
175
+ function isTodoClosed(status: string): boolean {
176
+ return ["closed", "done"].includes(status.toLowerCase());
177
+ }
178
+
179
+ function clearAssignmentIfClosed(todo: TodoFrontMatter): void {
180
+ if (isTodoClosed(getTodoStatus(todo))) {
181
+ todo.assigned_to_session = undefined;
182
+ }
183
+ }
184
+
185
+ function sortTodos(todos: TodoFrontMatter[]): TodoFrontMatter[] {
186
+ return [...todos].sort((a, b) => {
187
+ const aClosed = isTodoClosed(a.status);
188
+ const bClosed = isTodoClosed(b.status);
189
+ if (aClosed !== bClosed) return aClosed ? 1 : -1;
190
+ const aAssigned = !aClosed && Boolean(a.assigned_to_session);
191
+ const bAssigned = !bClosed && Boolean(b.assigned_to_session);
192
+ if (aAssigned !== bAssigned) return aAssigned ? -1 : 1;
193
+ return (a.created_at || "").localeCompare(b.created_at || "");
194
+ });
195
+ }
196
+
197
+ function buildTodoSearchText(todo: TodoFrontMatter): string {
198
+ const tags = todo.tags.join(" ");
199
+ const assignment = todo.assigned_to_session ? `assigned:${todo.assigned_to_session}` : "";
200
+ return `${formatTodoId(todo.id)} ${todo.id} ${todo.title} ${tags} ${todo.status} ${assignment}`.trim();
201
+ }
202
+
203
+ function filterTodos(todos: TodoFrontMatter[], query: string): TodoFrontMatter[] {
204
+ const trimmed = query.trim();
205
+ if (!trimmed) return todos;
206
+
207
+ const tokens = trimmed
208
+ .split(/\s+/)
209
+ .map((token) => token.trim())
210
+ .filter(Boolean);
211
+
212
+ if (tokens.length === 0) return todos;
213
+
214
+ const matches: Array<{ todo: TodoFrontMatter; score: number }> = [];
215
+ for (const todo of todos) {
216
+ const text = buildTodoSearchText(todo);
217
+ let totalScore = 0;
218
+ let matched = true;
219
+ for (const token of tokens) {
220
+ const result = fuzzyMatch(token, text);
221
+ if (!result.matches) {
222
+ matched = false;
223
+ break;
224
+ }
225
+ totalScore += result.score;
226
+ }
227
+ if (matched) {
228
+ matches.push({ todo, score: totalScore });
229
+ }
230
+ }
231
+
232
+ return matches
233
+ .sort((a, b) => {
234
+ const aClosed = isTodoClosed(a.todo.status);
235
+ const bClosed = isTodoClosed(b.todo.status);
236
+ if (aClosed !== bClosed) return aClosed ? 1 : -1;
237
+ const aAssigned = !aClosed && Boolean(a.todo.assigned_to_session);
238
+ const bAssigned = !bClosed && Boolean(b.todo.assigned_to_session);
239
+ if (aAssigned !== bAssigned) return aAssigned ? -1 : 1;
240
+ return a.score - b.score;
241
+ })
242
+ .map((match) => match.todo);
243
+ }
244
+
245
+ class TodoSelectorComponent extends Container implements Focusable {
246
+ private searchInput: Input;
247
+ private listContainer: Container;
248
+ private allTodos: TodoFrontMatter[];
249
+ private filteredTodos: TodoFrontMatter[];
250
+ private selectedIndex = 0;
251
+ private onSelectCallback: (todo: TodoFrontMatter) => void;
252
+ private onCancelCallback: () => void;
253
+ private tui: TUI;
254
+ private theme: Theme;
255
+ private headerText: Text;
256
+ private hintText: Text;
257
+ private currentSessionId?: string;
258
+
259
+ private _focused = false;
260
+ get focused(): boolean {
261
+ return this._focused;
262
+ }
263
+ set focused(value: boolean) {
264
+ this._focused = value;
265
+ this.searchInput.focused = value;
266
+ }
267
+
268
+ constructor(
269
+ tui: TUI,
270
+ theme: Theme,
271
+ todos: TodoFrontMatter[],
272
+ onSelect: (todo: TodoFrontMatter) => void,
273
+ onCancel: () => void,
274
+ initialSearchInput?: string,
275
+ currentSessionId?: string,
276
+ private onQuickAction?: (todo: TodoFrontMatter, action: "work" | "refine") => void,
277
+ ) {
278
+ super();
279
+ this.tui = tui;
280
+ this.theme = theme;
281
+ this.currentSessionId = currentSessionId;
282
+ this.allTodos = todos;
283
+ this.filteredTodos = todos;
284
+ this.onSelectCallback = onSelect;
285
+ this.onCancelCallback = onCancel;
286
+
287
+ this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
288
+ this.addChild(new Spacer(1));
289
+
290
+ this.headerText = new Text("", 1, 0);
291
+ this.addChild(this.headerText);
292
+ this.addChild(new Spacer(1));
293
+
294
+ this.searchInput = new Input();
295
+ if (initialSearchInput) {
296
+ this.searchInput.setValue(initialSearchInput);
297
+ }
298
+ this.searchInput.onSubmit = () => {
299
+ const selected = this.filteredTodos[this.selectedIndex];
300
+ if (selected) this.onSelectCallback(selected);
301
+ };
302
+ this.addChild(this.searchInput);
303
+
304
+ this.addChild(new Spacer(1));
305
+ this.listContainer = new Container();
306
+ this.addChild(this.listContainer);
307
+
308
+ this.addChild(new Spacer(1));
309
+ this.hintText = new Text("", 1, 0);
310
+ this.addChild(this.hintText);
311
+ this.addChild(new Spacer(1));
312
+ this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
313
+
314
+ this.updateHeader();
315
+ this.updateHints();
316
+ this.applyFilter(this.searchInput.getValue());
317
+ }
318
+
319
+ setTodos(todos: TodoFrontMatter[]): void {
320
+ this.allTodos = todos;
321
+ this.updateHeader();
322
+ this.applyFilter(this.searchInput.getValue());
323
+ this.tui.requestRender();
324
+ }
325
+
326
+ getSearchValue(): string {
327
+ return this.searchInput.getValue();
328
+ }
329
+
330
+ private updateHeader(): void {
331
+ const openCount = this.allTodos.filter((todo) => !isTodoClosed(todo.status)).length;
332
+ const closedCount = this.allTodos.length - openCount;
333
+ const title = `Todos (${openCount} open, ${closedCount} closed)`;
334
+ this.headerText.setText(this.theme.fg("accent", this.theme.bold(title)));
335
+ }
336
+
337
+ private updateHints(): void {
338
+ this.hintText.setText(
339
+ this.theme.fg(
340
+ "dim",
341
+ "Type to search • ↑↓ select • Enter actions • Ctrl+Shift+W work • Ctrl+Shift+R refine • Esc close",
342
+ ),
343
+ );
344
+ }
345
+
346
+ private applyFilter(query: string): void {
347
+ this.filteredTodos = filterTodos(this.allTodos, query);
348
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredTodos.length - 1));
349
+ this.updateList();
350
+ }
351
+
352
+ private updateList(): void {
353
+ this.listContainer.clear();
354
+
355
+ if (this.filteredTodos.length === 0) {
356
+ this.listContainer.addChild(new Text(this.theme.fg("muted", " No matching todos"), 0, 0));
357
+ return;
358
+ }
359
+
360
+ const maxVisible = 10;
361
+ const startIndex = Math.max(
362
+ 0,
363
+ Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredTodos.length - maxVisible),
364
+ );
365
+ const endIndex = Math.min(startIndex + maxVisible, this.filteredTodos.length);
366
+
367
+ for (let i = startIndex; i < endIndex; i += 1) {
368
+ const todo = this.filteredTodos[i];
369
+ if (!todo) continue;
370
+ const isSelected = i === this.selectedIndex;
371
+ const closed = isTodoClosed(todo.status);
372
+ const prefix = isSelected ? this.theme.fg("accent", "→ ") : " ";
373
+ const titleColor = isSelected ? "accent" : closed ? "dim" : "text";
374
+ const statusColor = closed ? "dim" : "success";
375
+ const tagText = todo.tags.length ? ` [${todo.tags.join(", ")}]` : "";
376
+ const assignmentText = renderAssignmentSuffix(this.theme, todo, this.currentSessionId);
377
+ const line =
378
+ prefix +
379
+ this.theme.fg("accent", formatTodoId(todo.id)) +
380
+ " " +
381
+ this.theme.fg(titleColor, todo.title || "(untitled)") +
382
+ this.theme.fg("muted", tagText) +
383
+ assignmentText +
384
+ " " +
385
+ this.theme.fg(statusColor, `(${todo.status || "open"})`);
386
+ this.listContainer.addChild(new Text(line, 0, 0));
387
+ }
388
+
389
+ if (startIndex > 0 || endIndex < this.filteredTodos.length) {
390
+ const scrollInfo = this.theme.fg(
391
+ "dim",
392
+ ` (${this.selectedIndex + 1}/${this.filteredTodos.length})`,
393
+ );
394
+ this.listContainer.addChild(new Text(scrollInfo, 0, 0));
395
+ }
396
+ }
397
+
398
+ handleInput(keyData: string): void {
399
+ const kb = getEditorKeybindings();
400
+ if (kb.matches(keyData, "selectUp")) {
401
+ if (this.filteredTodos.length === 0) return;
402
+ this.selectedIndex = this.selectedIndex === 0 ? this.filteredTodos.length - 1 : this.selectedIndex - 1;
403
+ this.updateList();
404
+ return;
405
+ }
406
+ if (kb.matches(keyData, "selectDown")) {
407
+ if (this.filteredTodos.length === 0) return;
408
+ this.selectedIndex = this.selectedIndex === this.filteredTodos.length - 1 ? 0 : this.selectedIndex + 1;
409
+ this.updateList();
410
+ return;
411
+ }
412
+ if (kb.matches(keyData, "selectConfirm")) {
413
+ const selected = this.filteredTodos[this.selectedIndex];
414
+ if (selected) this.onSelectCallback(selected);
415
+ return;
416
+ }
417
+ if (kb.matches(keyData, "selectCancel")) {
418
+ this.onCancelCallback();
419
+ return;
420
+ }
421
+ if (matchesKey(keyData, Key.ctrlShift("r"))) {
422
+ const selected = this.filteredTodos[this.selectedIndex];
423
+ if (selected && this.onQuickAction) this.onQuickAction(selected, "refine");
424
+ return;
425
+ }
426
+ if (matchesKey(keyData, Key.ctrlShift("w"))) {
427
+ const selected = this.filteredTodos[this.selectedIndex];
428
+ if (selected && this.onQuickAction) this.onQuickAction(selected, "work");
429
+ return;
430
+ }
431
+ this.searchInput.handleInput(keyData);
432
+ this.applyFilter(this.searchInput.getValue());
433
+ }
434
+
435
+ override invalidate(): void {
436
+ super.invalidate();
437
+ this.updateHeader();
438
+ this.updateHints();
439
+ this.updateList();
440
+ }
441
+ }
442
+
443
+ class TodoActionMenuComponent extends Container {
444
+ private selectList: SelectList;
445
+ private onSelectCallback: (action: TodoMenuAction) => void;
446
+ private onCancelCallback: () => void;
447
+
448
+ constructor(
449
+ theme: Theme,
450
+ todo: TodoRecord,
451
+ onSelect: (action: TodoMenuAction) => void,
452
+ onCancel: () => void,
453
+ ) {
454
+ super();
455
+ this.onSelectCallback = onSelect;
456
+ this.onCancelCallback = onCancel;
457
+
458
+ const closed = isTodoClosed(todo.status);
459
+ const title = todo.title || "(untitled)";
460
+ const options: SelectItem[] = [
461
+ { value: "view", label: "view", description: "View todo" },
462
+ { value: "work", label: "work", description: "Work on todo" },
463
+ { value: "refine", label: "refine", description: "Refine task" },
464
+ ...(closed
465
+ ? [{ value: "reopen", label: "reopen", description: "Reopen todo" }]
466
+ : [{ value: "close", label: "close", description: "Close todo" }]),
467
+ ...(todo.assigned_to_session
468
+ ? [{ value: "release", label: "release", description: "Release assignment" }]
469
+ : []),
470
+ { value: "copy", label: "copy", description: "Copy path to clipboard" },
471
+ { value: "delete", label: "delete", description: "Delete todo" },
472
+ ];
473
+
474
+ this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
475
+ this.addChild(
476
+ new Text(
477
+ theme.fg(
478
+ "accent",
479
+ theme.bold(`Actions for ${formatTodoId(todo.id)} "${title}"`),
480
+ ),
481
+ ),
482
+ );
483
+
484
+ this.selectList = new SelectList(options, options.length, {
485
+ selectedPrefix: (text) => theme.fg("accent", text),
486
+ selectedText: (text) => theme.fg("accent", text),
487
+ description: (text) => theme.fg("muted", text),
488
+ scrollInfo: (text) => theme.fg("dim", text),
489
+ noMatch: (text) => theme.fg("warning", text),
490
+ });
491
+
492
+ this.selectList.onSelect = (item) => this.onSelectCallback(item.value as TodoMenuAction);
493
+ this.selectList.onCancel = () => this.onCancelCallback();
494
+
495
+ this.addChild(this.selectList);
496
+ this.addChild(new Text(theme.fg("dim", "Enter to confirm • Esc back")));
497
+ this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
498
+ }
499
+
500
+ handleInput(keyData: string): void {
501
+ this.selectList.handleInput(keyData);
502
+ }
503
+
504
+ override invalidate(): void {
505
+ super.invalidate();
506
+ }
507
+ }
508
+
509
+ class TodoDeleteConfirmComponent extends Container {
510
+ private selectList: SelectList;
511
+ private onConfirm: (confirmed: boolean) => void;
512
+
513
+ constructor(theme: Theme, message: string, onConfirm: (confirmed: boolean) => void) {
514
+ super();
515
+ this.onConfirm = onConfirm;
516
+
517
+ const options: SelectItem[] = [
518
+ { value: "yes", label: "Yes" },
519
+ { value: "no", label: "No" },
520
+ ];
521
+
522
+ this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
523
+ this.addChild(new Text(theme.fg("accent", message)));
524
+
525
+ this.selectList = new SelectList(options, options.length, {
526
+ selectedPrefix: (text) => theme.fg("accent", text),
527
+ selectedText: (text) => theme.fg("accent", text),
528
+ description: (text) => theme.fg("muted", text),
529
+ scrollInfo: (text) => theme.fg("dim", text),
530
+ noMatch: (text) => theme.fg("warning", text),
531
+ });
532
+
533
+ this.selectList.onSelect = (item) => this.onConfirm(item.value === "yes");
534
+ this.selectList.onCancel = () => this.onConfirm(false);
535
+
536
+ this.addChild(this.selectList);
537
+ this.addChild(new Text(theme.fg("dim", "Enter to confirm • Esc back")));
538
+ this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
539
+ }
540
+
541
+ handleInput(keyData: string): void {
542
+ this.selectList.handleInput(keyData);
543
+ }
544
+
545
+ override invalidate(): void {
546
+ super.invalidate();
547
+ }
548
+ }
549
+
550
+ class TodoDetailOverlayComponent {
551
+ private todo: TodoRecord;
552
+ private theme: Theme;
553
+ private tui: TUI;
554
+ private markdown: Markdown;
555
+ private scrollOffset = 0;
556
+ private viewHeight = 0;
557
+ private totalLines = 0;
558
+ private onAction: (action: TodoOverlayAction) => void;
559
+
560
+ constructor(tui: TUI, theme: Theme, todo: TodoRecord, onAction: (action: TodoOverlayAction) => void) {
561
+ this.tui = tui;
562
+ this.theme = theme;
563
+ this.todo = todo;
564
+ this.onAction = onAction;
565
+ this.markdown = new Markdown(this.getMarkdownText(), 1, 0, getMarkdownTheme());
566
+ }
567
+
568
+ private getMarkdownText(): string {
569
+ const body = this.todo.body?.trim();
570
+ return body ? body : "_No details yet._";
571
+ }
572
+
573
+ handleInput(keyData: string): void {
574
+ const kb = getEditorKeybindings();
575
+ if (kb.matches(keyData, "selectCancel")) {
576
+ this.onAction("back");
577
+ return;
578
+ }
579
+ if (kb.matches(keyData, "selectConfirm")) {
580
+ this.onAction("work");
581
+ return;
582
+ }
583
+ if (kb.matches(keyData, "selectUp")) {
584
+ this.scrollBy(-1);
585
+ return;
586
+ }
587
+ if (kb.matches(keyData, "selectDown")) {
588
+ this.scrollBy(1);
589
+ return;
590
+ }
591
+ if (kb.matches(keyData, "selectPageUp")) {
592
+ this.scrollBy(-this.viewHeight || -1);
593
+ return;
594
+ }
595
+ if (kb.matches(keyData, "selectPageDown")) {
596
+ this.scrollBy(this.viewHeight || 1);
597
+ return;
598
+ }
599
+ }
600
+
601
+ render(width: number): string[] {
602
+ const maxHeight = this.getMaxHeight();
603
+ const headerLines = 3;
604
+ const footerLines = 3;
605
+ const borderLines = 2;
606
+ const innerWidth = Math.max(10, width - 2);
607
+ const contentHeight = Math.max(1, maxHeight - headerLines - footerLines - borderLines);
608
+
609
+ const markdownLines = this.markdown.render(innerWidth);
610
+ this.totalLines = markdownLines.length;
611
+ this.viewHeight = contentHeight;
612
+ const maxScroll = Math.max(0, this.totalLines - contentHeight);
613
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScroll));
614
+
615
+ const visibleLines = markdownLines.slice(this.scrollOffset, this.scrollOffset + contentHeight);
616
+ const lines: string[] = [];
617
+
618
+ lines.push(this.buildTitleLine(innerWidth));
619
+ lines.push(this.buildMetaLine(innerWidth));
620
+ lines.push("");
621
+
622
+ for (const line of visibleLines) {
623
+ lines.push(truncateToWidth(line, innerWidth));
624
+ }
625
+ while (lines.length < headerLines + contentHeight) {
626
+ lines.push("");
627
+ }
628
+
629
+ lines.push("");
630
+ lines.push(this.buildActionLine(innerWidth));
631
+
632
+ const borderColor = (text: string) => this.theme.fg("borderMuted", text);
633
+ const top = borderColor(`┌${"─".repeat(innerWidth)}┐`);
634
+ const bottom = borderColor(`└${"─".repeat(innerWidth)}┘`);
635
+ const framedLines = lines.map((line) => {
636
+ const truncated = truncateToWidth(line, innerWidth);
637
+ const padding = Math.max(0, innerWidth - visibleWidth(truncated));
638
+ return borderColor("│") + truncated + " ".repeat(padding) + borderColor("│");
639
+ });
640
+
641
+ return [top, ...framedLines, bottom].map((line) => truncateToWidth(line, width));
642
+ }
643
+
644
+ invalidate(): void {
645
+ this.markdown = new Markdown(this.getMarkdownText(), 1, 0, getMarkdownTheme());
646
+ }
647
+
648
+ private getMaxHeight(): number {
649
+ const rows = this.tui.terminal.rows || 24;
650
+ return Math.max(10, Math.floor(rows * 0.8));
651
+ }
652
+
653
+ private buildTitleLine(width: number): string {
654
+ const titleText = this.todo.title
655
+ ? ` ${this.todo.title} `
656
+ : ` Todo ${formatTodoId(this.todo.id)} `;
657
+ const titleWidth = visibleWidth(titleText);
658
+ if (titleWidth >= width) {
659
+ return truncateToWidth(this.theme.fg("accent", titleText.trim()), width);
660
+ }
661
+ const leftWidth = Math.max(0, Math.floor((width - titleWidth) / 2));
662
+ const rightWidth = Math.max(0, width - titleWidth - leftWidth);
663
+ return (
664
+ this.theme.fg("borderMuted", "─".repeat(leftWidth)) +
665
+ this.theme.fg("accent", titleText) +
666
+ this.theme.fg("borderMuted", "─".repeat(rightWidth))
667
+ );
668
+ }
669
+
670
+ private buildMetaLine(width: number): string {
671
+ const status = this.todo.status || "open";
672
+ const statusColor = isTodoClosed(status) ? "dim" : "success";
673
+ const tagText = this.todo.tags.length ? this.todo.tags.join(", ") : "no tags";
674
+ const line =
675
+ this.theme.fg("accent", formatTodoId(this.todo.id)) +
676
+ this.theme.fg("muted", " • ") +
677
+ this.theme.fg(statusColor, status) +
678
+ this.theme.fg("muted", " • ") +
679
+ this.theme.fg("muted", tagText);
680
+ return truncateToWidth(line, width);
681
+ }
682
+
683
+ private buildActionLine(width: number): string {
684
+ const work = this.theme.fg("accent", "enter") + this.theme.fg("muted", " work on todo");
685
+ const back = this.theme.fg("dim", "esc back");
686
+ const pieces = [work, back];
687
+
688
+ let line = pieces.join(this.theme.fg("muted", " • "));
689
+ if (this.totalLines > this.viewHeight) {
690
+ const start = Math.min(this.totalLines, this.scrollOffset + 1);
691
+ const end = Math.min(this.totalLines, this.scrollOffset + this.viewHeight);
692
+ const scrollInfo = this.theme.fg("dim", ` ${start}-${end}/${this.totalLines}`);
693
+ line += scrollInfo;
694
+ }
695
+
696
+ return truncateToWidth(line, width);
697
+ }
698
+
699
+ private scrollBy(delta: number): void {
700
+ const maxScroll = Math.max(0, this.totalLines - this.viewHeight);
701
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset + delta, maxScroll));
702
+ }
703
+ }
704
+
705
+ function getTodosDir(cwd: string): string {
706
+ const overridePath = process.env[TODO_PATH_ENV];
707
+ if (overridePath && overridePath.trim()) {
708
+ return path.resolve(cwd, overridePath.trim());
709
+ }
710
+ return path.resolve(cwd, TODO_DIR_NAME);
711
+ }
712
+
713
+ function getTodosDirLabel(cwd: string): string {
714
+ const overridePath = process.env[TODO_PATH_ENV];
715
+ if (overridePath && overridePath.trim()) {
716
+ return path.resolve(cwd, overridePath.trim());
717
+ }
718
+ return TODO_DIR_NAME;
719
+ }
720
+
721
+ function getTodoSettingsPath(todosDir: string): string {
722
+ return path.join(todosDir, TODO_SETTINGS_NAME);
723
+ }
724
+
725
+ function normalizeTodoSettings(raw: Partial<TodoSettings>): TodoSettings {
726
+ const gc = raw.gc ?? DEFAULT_TODO_SETTINGS.gc;
727
+ const gcDays = Number.isFinite(raw.gcDays) ? raw.gcDays : DEFAULT_TODO_SETTINGS.gcDays;
728
+ return {
729
+ gc: Boolean(gc),
730
+ gcDays: Math.max(0, Math.floor(gcDays)),
731
+ };
732
+ }
733
+
734
+ async function readTodoSettings(todosDir: string): Promise<TodoSettings> {
735
+ const settingsPath = getTodoSettingsPath(todosDir);
736
+ let data: Partial<TodoSettings> = {};
737
+
738
+ try {
739
+ const raw = await fs.readFile(settingsPath, "utf8");
740
+ data = JSON.parse(raw) as Partial<TodoSettings>;
741
+ } catch {
742
+ data = {};
743
+ }
744
+
745
+ return normalizeTodoSettings(data);
746
+ }
747
+
748
+ async function garbageCollectTodos(todosDir: string, settings: TodoSettings): Promise<void> {
749
+ if (!settings.gc) return;
750
+
751
+ let entries: string[] = [];
752
+ try {
753
+ entries = await fs.readdir(todosDir);
754
+ } catch {
755
+ return;
756
+ }
757
+
758
+ const cutoff = Date.now() - settings.gcDays * 24 * 60 * 60 * 1000;
759
+ await Promise.all(
760
+ entries
761
+ .filter((entry) => entry.endsWith(".md"))
762
+ .map(async (entry) => {
763
+ const id = entry.slice(0, -3);
764
+ const filePath = path.join(todosDir, entry);
765
+ try {
766
+ const content = await fs.readFile(filePath, "utf8");
767
+ const { frontMatter } = splitFrontMatter(content);
768
+ const parsed = parseFrontMatter(frontMatter, id);
769
+ if (!isTodoClosed(parsed.status)) return;
770
+ const createdAt = Date.parse(parsed.created_at);
771
+ if (!Number.isFinite(createdAt)) return;
772
+ if (createdAt < cutoff) {
773
+ await fs.unlink(filePath);
774
+ }
775
+ } catch {
776
+ // ignore unreadable todo
777
+ }
778
+ }),
779
+ );
780
+ }
781
+
782
+ function getTodoPath(todosDir: string, id: string): string {
783
+ return path.join(todosDir, `${id}.md`);
784
+ }
785
+
786
+ function getLockPath(todosDir: string, id: string): string {
787
+ return path.join(todosDir, `${id}.lock`);
788
+ }
789
+
790
+ function parseFrontMatter(text: string, idFallback: string): TodoFrontMatter {
791
+ const data: TodoFrontMatter = {
792
+ id: idFallback,
793
+ title: "",
794
+ tags: [],
795
+ status: "open",
796
+ created_at: "",
797
+ assigned_to_session: undefined,
798
+ };
799
+
800
+ const trimmed = text.trim();
801
+ if (!trimmed) return data;
802
+
803
+ try {
804
+ const parsed = JSON.parse(trimmed) as Partial<TodoFrontMatter> | null;
805
+ if (!parsed || typeof parsed !== "object") return data;
806
+ if (typeof parsed.id === "string" && parsed.id) data.id = parsed.id;
807
+ if (typeof parsed.title === "string") data.title = parsed.title;
808
+ if (typeof parsed.status === "string" && parsed.status) data.status = parsed.status;
809
+ if (typeof parsed.created_at === "string") data.created_at = parsed.created_at;
810
+ if (typeof parsed.assigned_to_session === "string" && parsed.assigned_to_session.trim()) {
811
+ data.assigned_to_session = parsed.assigned_to_session;
812
+ }
813
+ if (Array.isArray(parsed.tags)) {
814
+ data.tags = parsed.tags.filter((tag): tag is string => typeof tag === "string");
815
+ }
816
+ } catch {
817
+ return data;
818
+ }
819
+
820
+ return data;
821
+ }
822
+
823
+ function findJsonObjectEnd(content: string): number {
824
+ let depth = 0;
825
+ let inString = false;
826
+ let escaped = false;
827
+
828
+ for (let i = 0; i < content.length; i += 1) {
829
+ const char = content[i];
830
+
831
+ if (inString) {
832
+ if (escaped) {
833
+ escaped = false;
834
+ continue;
835
+ }
836
+ if (char === "\\") {
837
+ escaped = true;
838
+ continue;
839
+ }
840
+ if (char === "\"") {
841
+ inString = false;
842
+ }
843
+ continue;
844
+ }
845
+
846
+ if (char === "\"") {
847
+ inString = true;
848
+ continue;
849
+ }
850
+
851
+ if (char === "{") {
852
+ depth += 1;
853
+ continue;
854
+ }
855
+
856
+ if (char === "}") {
857
+ depth -= 1;
858
+ if (depth === 0) return i;
859
+ }
860
+ }
861
+
862
+ return -1;
863
+ }
864
+
865
+ function splitFrontMatter(content: string): { frontMatter: string; body: string } {
866
+ if (!content.startsWith("{")) {
867
+ return { frontMatter: "", body: content };
868
+ }
869
+
870
+ const endIndex = findJsonObjectEnd(content);
871
+ if (endIndex === -1) {
872
+ return { frontMatter: "", body: content };
873
+ }
874
+
875
+ const frontMatter = content.slice(0, endIndex + 1);
876
+ const body = content.slice(endIndex + 1).replace(/^\r?\n+/, "");
877
+ return { frontMatter, body };
878
+ }
879
+
880
+ function parseTodoContent(content: string, idFallback: string): TodoRecord {
881
+ const { frontMatter, body } = splitFrontMatter(content);
882
+ const parsed = parseFrontMatter(frontMatter, idFallback);
883
+ return {
884
+ id: idFallback,
885
+ title: parsed.title,
886
+ tags: parsed.tags ?? [],
887
+ status: parsed.status,
888
+ created_at: parsed.created_at,
889
+ assigned_to_session: parsed.assigned_to_session,
890
+ body: body ?? "",
891
+ };
892
+ }
893
+
894
+ function serializeTodo(todo: TodoRecord): string {
895
+ const frontMatter = JSON.stringify(
896
+ {
897
+ id: todo.id,
898
+ title: todo.title,
899
+ tags: todo.tags ?? [],
900
+ status: todo.status,
901
+ created_at: todo.created_at,
902
+ assigned_to_session: todo.assigned_to_session || undefined,
903
+ },
904
+ null,
905
+ 2,
906
+ );
907
+
908
+ const body = todo.body ?? "";
909
+ const trimmedBody = body.replace(/^\n+/, "").replace(/\s+$/, "");
910
+ if (!trimmedBody) return `${frontMatter}\n`;
911
+ return `${frontMatter}\n\n${trimmedBody}\n`;
912
+ }
913
+
914
+ async function ensureTodosDir(todosDir: string) {
915
+ await fs.mkdir(todosDir, { recursive: true });
916
+ }
917
+
918
+ async function readTodoFile(filePath: string, idFallback: string): Promise<TodoRecord> {
919
+ const content = await fs.readFile(filePath, "utf8");
920
+ return parseTodoContent(content, idFallback);
921
+ }
922
+
923
+ async function writeTodoFile(filePath: string, todo: TodoRecord) {
924
+ await fs.writeFile(filePath, serializeTodo(todo), "utf8");
925
+ }
926
+
927
+ async function generateTodoId(todosDir: string): Promise<string> {
928
+ for (let attempt = 0; attempt < 10; attempt += 1) {
929
+ const id = crypto.randomBytes(4).toString("hex");
930
+ const todoPath = getTodoPath(todosDir, id);
931
+ if (!existsSync(todoPath)) return id;
932
+ }
933
+ throw new Error("Failed to generate unique todo id");
934
+ }
935
+
936
+ async function readLockInfo(lockPath: string): Promise<LockInfo | null> {
937
+ try {
938
+ const raw = await fs.readFile(lockPath, "utf8");
939
+ return JSON.parse(raw) as LockInfo;
940
+ } catch {
941
+ return null;
942
+ }
943
+ }
944
+
945
+ async function acquireLock(
946
+ todosDir: string,
947
+ id: string,
948
+ ctx: ExtensionContext,
949
+ ): Promise<(() => Promise<void>) | { error: string }> {
950
+ const lockPath = getLockPath(todosDir, id);
951
+ const now = Date.now();
952
+ const session = ctx.sessionManager.getSessionFile();
953
+
954
+ for (let attempt = 0; attempt < 2; attempt += 1) {
955
+ try {
956
+ const handle = await fs.open(lockPath, "wx");
957
+ const info: LockInfo = {
958
+ id,
959
+ pid: process.pid,
960
+ session,
961
+ created_at: new Date(now).toISOString(),
962
+ };
963
+ await handle.writeFile(JSON.stringify(info, null, 2), "utf8");
964
+ await handle.close();
965
+ return async () => {
966
+ try {
967
+ await fs.unlink(lockPath);
968
+ } catch {
969
+ // ignore
970
+ }
971
+ };
972
+ } catch (error: any) {
973
+ if (error?.code !== "EEXIST") {
974
+ return { error: `Failed to acquire lock: ${error?.message ?? "unknown error"}` };
975
+ }
976
+ const stats = await fs.stat(lockPath).catch(() => null);
977
+ const lockAge = stats ? now - stats.mtimeMs : LOCK_TTL_MS + 1;
978
+ if (lockAge <= LOCK_TTL_MS) {
979
+ const info = await readLockInfo(lockPath);
980
+ const owner = info?.session ? ` (session ${info.session})` : "";
981
+ return { error: `Todo ${displayTodoId(id)} is locked${owner}. Try again later.` };
982
+ }
983
+ if (!ctx.hasUI) {
984
+ return { error: `Todo ${displayTodoId(id)} lock is stale; rerun in interactive mode to steal it.` };
985
+ }
986
+ const ok = await ctx.ui.confirm(
987
+ "Todo locked",
988
+ `Todo ${displayTodoId(id)} appears locked. Steal the lock?`,
989
+ );
990
+ if (!ok) {
991
+ return { error: `Todo ${displayTodoId(id)} remains locked.` };
992
+ }
993
+ await fs.unlink(lockPath).catch(() => undefined);
994
+ }
995
+ }
996
+
997
+ return { error: `Failed to acquire lock for todo ${displayTodoId(id)}.` };
998
+ }
999
+
1000
+ async function withTodoLock<T>(
1001
+ todosDir: string,
1002
+ id: string,
1003
+ ctx: ExtensionContext,
1004
+ fn: () => Promise<T>,
1005
+ ): Promise<T | { error: string }> {
1006
+ const lock = await acquireLock(todosDir, id, ctx);
1007
+ if (typeof lock === "object" && "error" in lock) return lock;
1008
+ try {
1009
+ return await fn();
1010
+ } finally {
1011
+ await lock();
1012
+ }
1013
+ }
1014
+
1015
+ async function listTodos(todosDir: string): Promise<TodoFrontMatter[]> {
1016
+ let entries: string[] = [];
1017
+ try {
1018
+ entries = await fs.readdir(todosDir);
1019
+ } catch {
1020
+ return [];
1021
+ }
1022
+
1023
+ const todos: TodoFrontMatter[] = [];
1024
+ for (const entry of entries) {
1025
+ if (!entry.endsWith(".md")) continue;
1026
+ const id = entry.slice(0, -3);
1027
+ const filePath = path.join(todosDir, entry);
1028
+ try {
1029
+ const content = await fs.readFile(filePath, "utf8");
1030
+ const { frontMatter } = splitFrontMatter(content);
1031
+ const parsed = parseFrontMatter(frontMatter, id);
1032
+ todos.push({
1033
+ id,
1034
+ title: parsed.title,
1035
+ tags: parsed.tags ?? [],
1036
+ status: parsed.status,
1037
+ created_at: parsed.created_at,
1038
+ assigned_to_session: parsed.assigned_to_session,
1039
+ });
1040
+ } catch {
1041
+ // ignore unreadable todo
1042
+ }
1043
+ }
1044
+
1045
+ return sortTodos(todos);
1046
+ }
1047
+
1048
+ function listTodosSync(todosDir: string): TodoFrontMatter[] {
1049
+ let entries: string[] = [];
1050
+ try {
1051
+ entries = readdirSync(todosDir);
1052
+ } catch {
1053
+ return [];
1054
+ }
1055
+
1056
+ const todos: TodoFrontMatter[] = [];
1057
+ for (const entry of entries) {
1058
+ if (!entry.endsWith(".md")) continue;
1059
+ const id = entry.slice(0, -3);
1060
+ const filePath = path.join(todosDir, entry);
1061
+ try {
1062
+ const content = readFileSync(filePath, "utf8");
1063
+ const { frontMatter } = splitFrontMatter(content);
1064
+ const parsed = parseFrontMatter(frontMatter, id);
1065
+ todos.push({
1066
+ id,
1067
+ title: parsed.title,
1068
+ tags: parsed.tags ?? [],
1069
+ status: parsed.status,
1070
+ created_at: parsed.created_at,
1071
+ assigned_to_session: parsed.assigned_to_session,
1072
+ });
1073
+ } catch {
1074
+ // ignore
1075
+ }
1076
+ }
1077
+
1078
+ return sortTodos(todos);
1079
+ }
1080
+
1081
+ function getTodoTitle(todo: TodoFrontMatter): string {
1082
+ return todo.title || "(untitled)";
1083
+ }
1084
+
1085
+ function getTodoStatus(todo: TodoFrontMatter): string {
1086
+ return todo.status || "open";
1087
+ }
1088
+
1089
+ function formatAssignmentSuffix(todo: TodoFrontMatter): string {
1090
+ return todo.assigned_to_session ? ` (assigned: ${todo.assigned_to_session})` : "";
1091
+ }
1092
+
1093
+ function renderAssignmentSuffix(
1094
+ theme: Theme,
1095
+ todo: TodoFrontMatter,
1096
+ currentSessionId?: string,
1097
+ ): string {
1098
+ if (!todo.assigned_to_session) return "";
1099
+ const isCurrent = todo.assigned_to_session === currentSessionId;
1100
+ const color = isCurrent ? "success" : "dim";
1101
+ const suffix = isCurrent ? ", current" : "";
1102
+ return theme.fg(color, ` (assigned: ${todo.assigned_to_session}${suffix})`);
1103
+ }
1104
+
1105
+ function formatTodoHeading(todo: TodoFrontMatter): string {
1106
+ const tagText = todo.tags.length ? ` [${todo.tags.join(", ")}]` : "";
1107
+ return `${formatTodoId(todo.id)} ${getTodoTitle(todo)}${tagText}${formatAssignmentSuffix(todo)}`;
1108
+ }
1109
+
1110
+ function buildRefinePrompt(todoId: string, title: string): string {
1111
+ return (
1112
+ `let's refine task ${formatTodoId(todoId)} "${title}": ` +
1113
+ "Ask me for the missing details needed to refine the todo together. Do not rewrite the todo yet and do not make assumptions. " +
1114
+ "Ask clear, concrete questions and wait for my answers before drafting any structured description.\n\n"
1115
+ );
1116
+ }
1117
+
1118
+ function splitTodosByAssignment(todos: TodoFrontMatter[]): {
1119
+ assignedTodos: TodoFrontMatter[];
1120
+ openTodos: TodoFrontMatter[];
1121
+ closedTodos: TodoFrontMatter[];
1122
+ } {
1123
+ const assignedTodos: TodoFrontMatter[] = [];
1124
+ const openTodos: TodoFrontMatter[] = [];
1125
+ const closedTodos: TodoFrontMatter[] = [];
1126
+ for (const todo of todos) {
1127
+ if (isTodoClosed(getTodoStatus(todo))) {
1128
+ closedTodos.push(todo);
1129
+ continue;
1130
+ }
1131
+ if (todo.assigned_to_session) {
1132
+ assignedTodos.push(todo);
1133
+ } else {
1134
+ openTodos.push(todo);
1135
+ }
1136
+ }
1137
+ return { assignedTodos, openTodos, closedTodos };
1138
+ }
1139
+
1140
+ function formatTodoList(todos: TodoFrontMatter[]): string {
1141
+ if (!todos.length) return "No todos.";
1142
+
1143
+ const { assignedTodos, openTodos, closedTodos } = splitTodosByAssignment(todos);
1144
+ const lines: string[] = [];
1145
+ const pushSection = (label: string, sectionTodos: TodoFrontMatter[]) => {
1146
+ lines.push(`${label} (${sectionTodos.length}):`);
1147
+ if (!sectionTodos.length) {
1148
+ lines.push(" none");
1149
+ return;
1150
+ }
1151
+ for (const todo of sectionTodos) {
1152
+ lines.push(` ${formatTodoHeading(todo)}`);
1153
+ }
1154
+ };
1155
+
1156
+ pushSection("Assigned todos", assignedTodos);
1157
+ pushSection("Open todos", openTodos);
1158
+ pushSection("Closed todos", closedTodos);
1159
+ return lines.join("\n");
1160
+ }
1161
+
1162
+ function serializeTodoForAgent(todo: TodoRecord): string {
1163
+ const payload = { ...todo, id: formatTodoId(todo.id) };
1164
+ return JSON.stringify(payload, null, 2);
1165
+ }
1166
+
1167
+ function serializeTodoListForAgent(todos: TodoFrontMatter[]): string {
1168
+ const { assignedTodos, openTodos, closedTodos } = splitTodosByAssignment(todos);
1169
+ const mapTodo = (todo: TodoFrontMatter) => ({ ...todo, id: formatTodoId(todo.id) });
1170
+ return JSON.stringify(
1171
+ {
1172
+ assigned: assignedTodos.map(mapTodo),
1173
+ open: openTodos.map(mapTodo),
1174
+ closed: closedTodos.map(mapTodo),
1175
+ },
1176
+ null,
1177
+ 2,
1178
+ );
1179
+ }
1180
+
1181
+ function renderTodoHeading(theme: Theme, todo: TodoFrontMatter, currentSessionId?: string): string {
1182
+ const closed = isTodoClosed(getTodoStatus(todo));
1183
+ const titleColor = closed ? "dim" : "text";
1184
+ const tagText = todo.tags.length ? theme.fg("dim", ` [${todo.tags.join(", ")}]`) : "";
1185
+ const assignmentText = renderAssignmentSuffix(theme, todo, currentSessionId);
1186
+ return (
1187
+ theme.fg("accent", formatTodoId(todo.id)) +
1188
+ " " +
1189
+ theme.fg(titleColor, getTodoTitle(todo)) +
1190
+ tagText +
1191
+ assignmentText
1192
+ );
1193
+ }
1194
+
1195
+ function renderTodoList(
1196
+ theme: Theme,
1197
+ todos: TodoFrontMatter[],
1198
+ expanded: boolean,
1199
+ currentSessionId?: string,
1200
+ ): string {
1201
+ if (!todos.length) return theme.fg("dim", "No todos");
1202
+
1203
+ const { assignedTodos, openTodos, closedTodos } = splitTodosByAssignment(todos);
1204
+ const lines: string[] = [];
1205
+ const pushSection = (label: string, sectionTodos: TodoFrontMatter[]) => {
1206
+ lines.push(theme.fg("muted", `${label} (${sectionTodos.length})`));
1207
+ if (!sectionTodos.length) {
1208
+ lines.push(theme.fg("dim", " none"));
1209
+ return;
1210
+ }
1211
+ const maxItems = expanded ? sectionTodos.length : Math.min(sectionTodos.length, 3);
1212
+ for (let i = 0; i < maxItems; i++) {
1213
+ lines.push(` ${renderTodoHeading(theme, sectionTodos[i], currentSessionId)}`);
1214
+ }
1215
+ if (!expanded && sectionTodos.length > maxItems) {
1216
+ lines.push(theme.fg("dim", ` ... ${sectionTodos.length - maxItems} more`));
1217
+ }
1218
+ };
1219
+
1220
+ const sections: Array<{ label: string; todos: TodoFrontMatter[] }> = [
1221
+ { label: "Assigned todos", todos: assignedTodos },
1222
+ { label: "Open todos", todos: openTodos },
1223
+ { label: "Closed todos", todos: closedTodos },
1224
+ ];
1225
+
1226
+ sections.forEach((section, index) => {
1227
+ if (index > 0) lines.push("");
1228
+ pushSection(section.label, section.todos);
1229
+ });
1230
+
1231
+ return lines.join("\n");
1232
+ }
1233
+
1234
+ function renderTodoDetail(theme: Theme, todo: TodoRecord, expanded: boolean): string {
1235
+ const summary = renderTodoHeading(theme, todo);
1236
+ if (!expanded) return summary;
1237
+
1238
+ const tags = todo.tags.length ? todo.tags.join(", ") : "none";
1239
+ const createdAt = todo.created_at || "unknown";
1240
+ const bodyText = todo.body?.trim() ? todo.body.trim() : "No details yet.";
1241
+ const bodyLines = bodyText.split("\n");
1242
+
1243
+ const lines = [
1244
+ summary,
1245
+ theme.fg("muted", `Status: ${getTodoStatus(todo)}`),
1246
+ theme.fg("muted", `Tags: ${tags}`),
1247
+ theme.fg("muted", `Created: ${createdAt}`),
1248
+ "",
1249
+ theme.fg("muted", "Body:"),
1250
+ ...bodyLines.map((line) => theme.fg("text", ` ${line}`)),
1251
+ ];
1252
+
1253
+ return lines.join("\n");
1254
+ }
1255
+
1256
+ function appendExpandHint(theme: Theme, text: string): string {
1257
+ return `${text}\n${theme.fg("dim", `(${keyHint("expandTools", "to expand")})`)}`;
1258
+ }
1259
+
1260
+ async function ensureTodoExists(filePath: string, id: string): Promise<TodoRecord | null> {
1261
+ if (!existsSync(filePath)) return null;
1262
+ return readTodoFile(filePath, id);
1263
+ }
1264
+
1265
+ async function appendTodoBody(filePath: string, todo: TodoRecord, text: string): Promise<TodoRecord> {
1266
+ const spacer = todo.body.trim().length ? "\n\n" : "";
1267
+ todo.body = `${todo.body.replace(/\s+$/, "")}${spacer}${text.trim()}\n`;
1268
+ await writeTodoFile(filePath, todo);
1269
+ return todo;
1270
+ }
1271
+
1272
+ async function updateTodoStatus(
1273
+ todosDir: string,
1274
+ id: string,
1275
+ status: string,
1276
+ ctx: ExtensionContext,
1277
+ ): Promise<TodoRecord | { error: string }> {
1278
+ const validated = validateTodoId(id);
1279
+ if ("error" in validated) {
1280
+ return { error: validated.error };
1281
+ }
1282
+ const normalizedId = validated.id;
1283
+ const filePath = getTodoPath(todosDir, normalizedId);
1284
+ if (!existsSync(filePath)) {
1285
+ return { error: `Todo ${displayTodoId(id)} not found` };
1286
+ }
1287
+
1288
+ const result = await withTodoLock(todosDir, normalizedId, ctx, async () => {
1289
+ const existing = await ensureTodoExists(filePath, normalizedId);
1290
+ if (!existing) return { error: `Todo ${displayTodoId(id)} not found` } as const;
1291
+ existing.status = status;
1292
+ clearAssignmentIfClosed(existing);
1293
+ await writeTodoFile(filePath, existing);
1294
+ return existing;
1295
+ });
1296
+
1297
+ if (typeof result === "object" && "error" in result) {
1298
+ return { error: result.error };
1299
+ }
1300
+
1301
+ return result;
1302
+ }
1303
+
1304
+ async function claimTodoAssignment(
1305
+ todosDir: string,
1306
+ id: string,
1307
+ ctx: ExtensionContext,
1308
+ force = false,
1309
+ ): Promise<TodoRecord | { error: string }> {
1310
+ const validated = validateTodoId(id);
1311
+ if ("error" in validated) {
1312
+ return { error: validated.error };
1313
+ }
1314
+ const normalizedId = validated.id;
1315
+ const filePath = getTodoPath(todosDir, normalizedId);
1316
+ if (!existsSync(filePath)) {
1317
+ return { error: `Todo ${displayTodoId(id)} not found` };
1318
+ }
1319
+ const sessionId = ctx.sessionManager.getSessionId();
1320
+ const result = await withTodoLock(todosDir, normalizedId, ctx, async () => {
1321
+ const existing = await ensureTodoExists(filePath, normalizedId);
1322
+ if (!existing) return { error: `Todo ${displayTodoId(id)} not found` } as const;
1323
+ if (isTodoClosed(existing.status)) {
1324
+ return { error: `Todo ${displayTodoId(id)} is closed` } as const;
1325
+ }
1326
+ const assigned = existing.assigned_to_session;
1327
+ if (assigned && assigned !== sessionId && !force) {
1328
+ return {
1329
+ error: `Todo ${displayTodoId(id)} is already assigned to session ${assigned}. Use force to override.`,
1330
+ } as const;
1331
+ }
1332
+ if (assigned !== sessionId) {
1333
+ existing.assigned_to_session = sessionId;
1334
+ await writeTodoFile(filePath, existing);
1335
+ }
1336
+ return existing;
1337
+ });
1338
+
1339
+ if (typeof result === "object" && "error" in result) {
1340
+ return { error: result.error };
1341
+ }
1342
+
1343
+ return result;
1344
+ }
1345
+
1346
+ async function releaseTodoAssignment(
1347
+ todosDir: string,
1348
+ id: string,
1349
+ ctx: ExtensionContext,
1350
+ force = false,
1351
+ ): Promise<TodoRecord | { error: string }> {
1352
+ const validated = validateTodoId(id);
1353
+ if ("error" in validated) {
1354
+ return { error: validated.error };
1355
+ }
1356
+ const normalizedId = validated.id;
1357
+ const filePath = getTodoPath(todosDir, normalizedId);
1358
+ if (!existsSync(filePath)) {
1359
+ return { error: `Todo ${displayTodoId(id)} not found` };
1360
+ }
1361
+ const sessionId = ctx.sessionManager.getSessionId();
1362
+ const result = await withTodoLock(todosDir, normalizedId, ctx, async () => {
1363
+ const existing = await ensureTodoExists(filePath, normalizedId);
1364
+ if (!existing) return { error: `Todo ${displayTodoId(id)} not found` } as const;
1365
+ const assigned = existing.assigned_to_session;
1366
+ if (!assigned) {
1367
+ return existing;
1368
+ }
1369
+ if (assigned !== sessionId && !force) {
1370
+ return {
1371
+ error: `Todo ${displayTodoId(id)} is assigned to session ${assigned}. Use force to release.`,
1372
+ } as const;
1373
+ }
1374
+ existing.assigned_to_session = undefined;
1375
+ await writeTodoFile(filePath, existing);
1376
+ return existing;
1377
+ });
1378
+
1379
+ if (typeof result === "object" && "error" in result) {
1380
+ return { error: result.error };
1381
+ }
1382
+
1383
+ return result;
1384
+ }
1385
+
1386
+ async function deleteTodo(
1387
+ todosDir: string,
1388
+ id: string,
1389
+ ctx: ExtensionContext,
1390
+ ): Promise<TodoRecord | { error: string }> {
1391
+ const validated = validateTodoId(id);
1392
+ if ("error" in validated) {
1393
+ return { error: validated.error };
1394
+ }
1395
+ const normalizedId = validated.id;
1396
+ const filePath = getTodoPath(todosDir, normalizedId);
1397
+ if (!existsSync(filePath)) {
1398
+ return { error: `Todo ${displayTodoId(id)} not found` };
1399
+ }
1400
+
1401
+ const result = await withTodoLock(todosDir, normalizedId, ctx, async () => {
1402
+ const existing = await ensureTodoExists(filePath, normalizedId);
1403
+ if (!existing) return { error: `Todo ${displayTodoId(id)} not found` } as const;
1404
+ await fs.unlink(filePath);
1405
+ return existing;
1406
+ });
1407
+
1408
+ if (typeof result === "object" && "error" in result) {
1409
+ return { error: result.error };
1410
+ }
1411
+
1412
+ return result;
1413
+ }
1414
+
1415
+ export default function todosExtension(pi: ExtensionAPI) {
1416
+ pi.on("session_start", async (_event, ctx) => {
1417
+ const todosDir = getTodosDir(ctx.cwd);
1418
+ await ensureTodosDir(todosDir);
1419
+ const settings = await readTodoSettings(todosDir);
1420
+ await garbageCollectTodos(todosDir, settings);
1421
+ });
1422
+
1423
+ const todosDirLabel = getTodosDirLabel(process.cwd());
1424
+
1425
+ pi.registerTool({
1426
+ name: "todo",
1427
+ label: "Todo",
1428
+ description:
1429
+ `Manage file-based todos in ${todosDirLabel} (list, list-all, get, create, update, append, delete, claim, release). ` +
1430
+ "Title is the short summary; body is long-form markdown notes (update replaces, append adds). " +
1431
+ "Todo ids are shown as TODO-<hex>; id parameters accept TODO-<hex> or the raw hex filename. " +
1432
+ "Close todos when the work is done.",
1433
+ parameters: TodoParams,
1434
+
1435
+ async execute(_toolCallId, params, _onUpdate, ctx) {
1436
+ const todosDir = getTodosDir(ctx.cwd);
1437
+ const action: TodoAction = params.action;
1438
+
1439
+ switch (action) {
1440
+ case "list": {
1441
+ const todos = await listTodos(todosDir);
1442
+ const { assignedTodos, openTodos } = splitTodosByAssignment(todos);
1443
+ const listedTodos = [...assignedTodos, ...openTodos];
1444
+ const currentSessionId = ctx.sessionManager.getSessionId();
1445
+ return {
1446
+ content: [{ type: "text", text: serializeTodoListForAgent(listedTodos) }],
1447
+ details: { action: "list", todos: listedTodos, currentSessionId },
1448
+ };
1449
+ }
1450
+
1451
+ case "list-all": {
1452
+ const todos = await listTodos(todosDir);
1453
+ const currentSessionId = ctx.sessionManager.getSessionId();
1454
+ return {
1455
+ content: [{ type: "text", text: serializeTodoListForAgent(todos) }],
1456
+ details: { action: "list-all", todos, currentSessionId },
1457
+ };
1458
+ }
1459
+
1460
+ case "get": {
1461
+ if (!params.id) {
1462
+ return {
1463
+ content: [{ type: "text", text: "Error: id required" }],
1464
+ details: { action: "get", error: "id required" },
1465
+ };
1466
+ }
1467
+ const validated = validateTodoId(params.id);
1468
+ if ("error" in validated) {
1469
+ return {
1470
+ content: [{ type: "text", text: validated.error }],
1471
+ details: { action: "get", error: validated.error },
1472
+ };
1473
+ }
1474
+ const normalizedId = validated.id;
1475
+ const displayId = formatTodoId(normalizedId);
1476
+ const filePath = getTodoPath(todosDir, normalizedId);
1477
+ const todo = await ensureTodoExists(filePath, normalizedId);
1478
+ if (!todo) {
1479
+ return {
1480
+ content: [{ type: "text", text: `Todo ${displayId} not found` }],
1481
+ details: { action: "get", error: "not found" },
1482
+ };
1483
+ }
1484
+ return {
1485
+ content: [{ type: "text", text: serializeTodoForAgent(todo) }],
1486
+ details: { action: "get", todo },
1487
+ };
1488
+ }
1489
+
1490
+ case "create": {
1491
+ if (!params.title) {
1492
+ return {
1493
+ content: [{ type: "text", text: "Error: title required" }],
1494
+ details: { action: "create", error: "title required" },
1495
+ };
1496
+ }
1497
+ await ensureTodosDir(todosDir);
1498
+ const id = await generateTodoId(todosDir);
1499
+ const filePath = getTodoPath(todosDir, id);
1500
+ const todo: TodoRecord = {
1501
+ id,
1502
+ title: params.title,
1503
+ tags: params.tags ?? [],
1504
+ status: params.status ?? "open",
1505
+ created_at: new Date().toISOString(),
1506
+ body: params.body ?? "",
1507
+ };
1508
+
1509
+ const result = await withTodoLock(todosDir, id, ctx, async () => {
1510
+ await writeTodoFile(filePath, todo);
1511
+ return todo;
1512
+ });
1513
+
1514
+ if (typeof result === "object" && "error" in result) {
1515
+ return {
1516
+ content: [{ type: "text", text: result.error }],
1517
+ details: { action: "create", error: result.error },
1518
+ };
1519
+ }
1520
+
1521
+ return {
1522
+ content: [{ type: "text", text: serializeTodoForAgent(todo) }],
1523
+ details: { action: "create", todo },
1524
+ };
1525
+ }
1526
+
1527
+ case "update": {
1528
+ if (!params.id) {
1529
+ return {
1530
+ content: [{ type: "text", text: "Error: id required" }],
1531
+ details: { action: "update", error: "id required" },
1532
+ };
1533
+ }
1534
+ const validated = validateTodoId(params.id);
1535
+ if ("error" in validated) {
1536
+ return {
1537
+ content: [{ type: "text", text: validated.error }],
1538
+ details: { action: "update", error: validated.error },
1539
+ };
1540
+ }
1541
+ const normalizedId = validated.id;
1542
+ const displayId = formatTodoId(normalizedId);
1543
+ const filePath = getTodoPath(todosDir, normalizedId);
1544
+ if (!existsSync(filePath)) {
1545
+ return {
1546
+ content: [{ type: "text", text: `Todo ${displayId} not found` }],
1547
+ details: { action: "update", error: "not found" },
1548
+ };
1549
+ }
1550
+ const result = await withTodoLock(todosDir, normalizedId, ctx, async () => {
1551
+ const existing = await ensureTodoExists(filePath, normalizedId);
1552
+ if (!existing) return { error: `Todo ${displayId} not found` } as const;
1553
+
1554
+ existing.id = normalizedId;
1555
+ if (params.title !== undefined) existing.title = params.title;
1556
+ if (params.status !== undefined) existing.status = params.status;
1557
+ if (params.tags !== undefined) existing.tags = params.tags;
1558
+ if (params.body !== undefined) existing.body = params.body;
1559
+ if (!existing.created_at) existing.created_at = new Date().toISOString();
1560
+ clearAssignmentIfClosed(existing);
1561
+
1562
+ await writeTodoFile(filePath, existing);
1563
+ return existing;
1564
+ });
1565
+
1566
+ if (typeof result === "object" && "error" in result) {
1567
+ return {
1568
+ content: [{ type: "text", text: result.error }],
1569
+ details: { action: "update", error: result.error },
1570
+ };
1571
+ }
1572
+
1573
+ const updatedTodo = result as TodoRecord;
1574
+ return {
1575
+ content: [{ type: "text", text: serializeTodoForAgent(updatedTodo) }],
1576
+ details: { action: "update", todo: updatedTodo },
1577
+ };
1578
+ }
1579
+
1580
+ case "append": {
1581
+ if (!params.id) {
1582
+ return {
1583
+ content: [{ type: "text", text: "Error: id required" }],
1584
+ details: { action: "append", error: "id required" },
1585
+ };
1586
+ }
1587
+ const validated = validateTodoId(params.id);
1588
+ if ("error" in validated) {
1589
+ return {
1590
+ content: [{ type: "text", text: validated.error }],
1591
+ details: { action: "append", error: validated.error },
1592
+ };
1593
+ }
1594
+ const normalizedId = validated.id;
1595
+ const displayId = formatTodoId(normalizedId);
1596
+ const filePath = getTodoPath(todosDir, normalizedId);
1597
+ if (!existsSync(filePath)) {
1598
+ return {
1599
+ content: [{ type: "text", text: `Todo ${displayId} not found` }],
1600
+ details: { action: "append", error: "not found" },
1601
+ };
1602
+ }
1603
+ const result = await withTodoLock(todosDir, normalizedId, ctx, async () => {
1604
+ const existing = await ensureTodoExists(filePath, normalizedId);
1605
+ if (!existing) return { error: `Todo ${displayId} not found` } as const;
1606
+ if (!params.body || !params.body.trim()) {
1607
+ return existing;
1608
+ }
1609
+ const updated = await appendTodoBody(filePath, existing, params.body);
1610
+ return updated;
1611
+ });
1612
+
1613
+ if (typeof result === "object" && "error" in result) {
1614
+ return {
1615
+ content: [{ type: "text", text: result.error }],
1616
+ details: { action: "append", error: result.error },
1617
+ };
1618
+ }
1619
+
1620
+ const updatedTodo = result as TodoRecord;
1621
+ return {
1622
+ content: [{ type: "text", text: serializeTodoForAgent(updatedTodo) }],
1623
+ details: { action: "append", todo: updatedTodo },
1624
+ };
1625
+ }
1626
+
1627
+ case "claim": {
1628
+ if (!params.id) {
1629
+ return {
1630
+ content: [{ type: "text", text: "Error: id required" }],
1631
+ details: { action: "claim", error: "id required" },
1632
+ };
1633
+ }
1634
+ const result = await claimTodoAssignment(
1635
+ todosDir,
1636
+ params.id,
1637
+ ctx,
1638
+ Boolean(params.force),
1639
+ );
1640
+ if (typeof result === "object" && "error" in result) {
1641
+ return {
1642
+ content: [{ type: "text", text: result.error }],
1643
+ details: { action: "claim", error: result.error },
1644
+ };
1645
+ }
1646
+ const updatedTodo = result as TodoRecord;
1647
+ return {
1648
+ content: [{ type: "text", text: serializeTodoForAgent(updatedTodo) }],
1649
+ details: { action: "claim", todo: updatedTodo },
1650
+ };
1651
+ }
1652
+
1653
+ case "release": {
1654
+ if (!params.id) {
1655
+ return {
1656
+ content: [{ type: "text", text: "Error: id required" }],
1657
+ details: { action: "release", error: "id required" },
1658
+ };
1659
+ }
1660
+ const result = await releaseTodoAssignment(
1661
+ todosDir,
1662
+ params.id,
1663
+ ctx,
1664
+ Boolean(params.force),
1665
+ );
1666
+ if (typeof result === "object" && "error" in result) {
1667
+ return {
1668
+ content: [{ type: "text", text: result.error }],
1669
+ details: { action: "release", error: result.error },
1670
+ };
1671
+ }
1672
+ const updatedTodo = result as TodoRecord;
1673
+ return {
1674
+ content: [{ type: "text", text: serializeTodoForAgent(updatedTodo) }],
1675
+ details: { action: "release", todo: updatedTodo },
1676
+ };
1677
+ }
1678
+
1679
+ case "delete": {
1680
+ if (!params.id) {
1681
+ return {
1682
+ content: [{ type: "text", text: "Error: id required" }],
1683
+ details: { action: "delete", error: "id required" },
1684
+ };
1685
+ }
1686
+
1687
+ const validated = validateTodoId(params.id);
1688
+ if ("error" in validated) {
1689
+ return {
1690
+ content: [{ type: "text", text: validated.error }],
1691
+ details: { action: "delete", error: validated.error },
1692
+ };
1693
+ }
1694
+ const result = await deleteTodo(todosDir, validated.id, ctx);
1695
+ if (typeof result === "object" && "error" in result) {
1696
+ return {
1697
+ content: [{ type: "text", text: result.error }],
1698
+ details: { action: "delete", error: result.error },
1699
+ };
1700
+ }
1701
+
1702
+ return {
1703
+ content: [{ type: "text", text: serializeTodoForAgent(result as TodoRecord) }],
1704
+ details: { action: "delete", todo: result as TodoRecord },
1705
+ };
1706
+ }
1707
+ }
1708
+ },
1709
+
1710
+
1711
+ renderCall(args, theme) {
1712
+ const action = typeof args.action === "string" ? args.action : "";
1713
+ const id = typeof args.id === "string" ? args.id : "";
1714
+ const normalizedId = id ? normalizeTodoId(id) : "";
1715
+ const title = typeof args.title === "string" ? args.title : "";
1716
+ let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", action);
1717
+ if (normalizedId) {
1718
+ text += " " + theme.fg("accent", formatTodoId(normalizedId));
1719
+ }
1720
+ if (title) {
1721
+ text += " " + theme.fg("dim", `"${title}"`);
1722
+ }
1723
+ return new Text(text, 0, 0);
1724
+ },
1725
+
1726
+ renderResult(result, { expanded, isPartial }, theme) {
1727
+ const details = result.details as TodoToolDetails | undefined;
1728
+ if (isPartial) {
1729
+ return new Text(theme.fg("warning", "Processing..."), 0, 0);
1730
+ }
1731
+ if (!details) {
1732
+ const text = result.content[0];
1733
+ return new Text(text?.type === "text" ? text.text : "", 0, 0);
1734
+ }
1735
+
1736
+ if (details.error) {
1737
+ return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
1738
+ }
1739
+
1740
+ if (details.action === "list" || details.action === "list-all") {
1741
+ let text = renderTodoList(theme, details.todos, expanded, details.currentSessionId);
1742
+ if (!expanded) {
1743
+ const { closedTodos } = splitTodosByAssignment(details.todos);
1744
+ if (closedTodos.length) {
1745
+ text = appendExpandHint(theme, text);
1746
+ }
1747
+ }
1748
+ return new Text(text, 0, 0);
1749
+ }
1750
+
1751
+ if (!details.todo) {
1752
+ const text = result.content[0];
1753
+ return new Text(text?.type === "text" ? text.text : "", 0, 0);
1754
+ }
1755
+
1756
+ let text = renderTodoDetail(theme, details.todo, expanded);
1757
+ const actionLabel =
1758
+ details.action === "create"
1759
+ ? "Created"
1760
+ : details.action === "update"
1761
+ ? "Updated"
1762
+ : details.action === "append"
1763
+ ? "Appended to"
1764
+ : details.action === "delete"
1765
+ ? "Deleted"
1766
+ : details.action === "claim"
1767
+ ? "Claimed"
1768
+ : details.action === "release"
1769
+ ? "Released"
1770
+ : null;
1771
+ if (actionLabel) {
1772
+ const lines = text.split("\n");
1773
+ lines[0] = theme.fg("success", "✓ ") + theme.fg("muted", `${actionLabel} `) + lines[0];
1774
+ text = lines.join("\n");
1775
+ }
1776
+ if (!expanded) {
1777
+ text = appendExpandHint(theme, text);
1778
+ }
1779
+ return new Text(text, 0, 0);
1780
+ },
1781
+ });
1782
+
1783
+ pi.registerCommand("todos", {
1784
+ description: "List todos from .pi/todos",
1785
+ getArgumentCompletions: (argumentPrefix: string) => {
1786
+ const todos = listTodosSync(getTodosDir(process.cwd()));
1787
+ if (!todos.length) return null;
1788
+ const matches = filterTodos(todos, argumentPrefix);
1789
+ if (!matches.length) return null;
1790
+ return matches.map((todo) => {
1791
+ const title = todo.title || "(untitled)";
1792
+ const tags = todo.tags.length ? ` • ${todo.tags.join(", ")}` : "";
1793
+ return {
1794
+ value: title,
1795
+ label: `${formatTodoId(todo.id)} ${title}`,
1796
+ description: `${todo.status || "open"}${tags}`,
1797
+ };
1798
+ });
1799
+ },
1800
+ handler: async (args, ctx) => {
1801
+ const todosDir = getTodosDir(ctx.cwd);
1802
+ const todos = await listTodos(todosDir);
1803
+ const currentSessionId = ctx.sessionManager.getSessionId();
1804
+ const searchTerm = (args ?? "").trim();
1805
+
1806
+ if (!ctx.hasUI) {
1807
+ const text = formatTodoList(todos);
1808
+ console.log(text);
1809
+ return;
1810
+ }
1811
+
1812
+ let nextPrompt: string | null = null;
1813
+ let rootTui: TUI | null = null;
1814
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
1815
+ rootTui = tui;
1816
+ let selector: TodoSelectorComponent | null = null;
1817
+ let actionMenu: TodoActionMenuComponent | null = null;
1818
+ let deleteConfirm: TodoDeleteConfirmComponent | null = null;
1819
+ let activeComponent:
1820
+ | {
1821
+ render: (width: number) => string[];
1822
+ invalidate: () => void;
1823
+ handleInput?: (data: string) => void;
1824
+ focused?: boolean;
1825
+ }
1826
+ | null = null;
1827
+ let wrapperFocused = false;
1828
+
1829
+ const setActiveComponent = (
1830
+ component:
1831
+ | {
1832
+ render: (width: number) => string[];
1833
+ invalidate: () => void;
1834
+ handleInput?: (data: string) => void;
1835
+ focused?: boolean;
1836
+ }
1837
+ | null,
1838
+ ) => {
1839
+ if (activeComponent && "focused" in activeComponent) {
1840
+ activeComponent.focused = false;
1841
+ }
1842
+ activeComponent = component;
1843
+ if (activeComponent && "focused" in activeComponent) {
1844
+ activeComponent.focused = wrapperFocused;
1845
+ }
1846
+ tui.requestRender();
1847
+ };
1848
+
1849
+ const copyTodoPathToClipboard = (todoId: string) => {
1850
+ const filePath = getTodoPath(todosDir, todoId);
1851
+ const relativePath = path.relative(ctx.cwd, filePath);
1852
+ const displayPath =
1853
+ relativePath && !relativePath.startsWith("..") ? relativePath : filePath;
1854
+ try {
1855
+ copyToClipboard(displayPath);
1856
+ ctx.ui.notify(`Copied ${displayPath} to clipboard`, "info");
1857
+ } catch (error) {
1858
+ const message = error instanceof Error ? error.message : String(error);
1859
+ ctx.ui.notify(message, "error");
1860
+ }
1861
+ };
1862
+
1863
+ const resolveTodoRecord = async (todo: TodoFrontMatter): Promise<TodoRecord | null> => {
1864
+ const filePath = getTodoPath(todosDir, todo.id);
1865
+ const record = await ensureTodoExists(filePath, todo.id);
1866
+ if (!record) {
1867
+ ctx.ui.notify(`Todo ${formatTodoId(todo.id)} not found`, "error");
1868
+ return null;
1869
+ }
1870
+ return record;
1871
+ };
1872
+
1873
+ const openTodoOverlay = async (record: TodoRecord): Promise<TodoOverlayAction> => {
1874
+ const action = await ctx.ui.custom<TodoOverlayAction>(
1875
+ (overlayTui, overlayTheme, _overlayKb, overlayDone) =>
1876
+ new TodoDetailOverlayComponent(overlayTui, overlayTheme, record, overlayDone),
1877
+ {
1878
+ overlay: true,
1879
+ overlayOptions: { width: "80%", maxHeight: "80%", anchor: "center" },
1880
+ },
1881
+ );
1882
+
1883
+ return action ?? "back";
1884
+ };
1885
+
1886
+ const applyTodoAction = async (
1887
+ record: TodoRecord,
1888
+ action: TodoMenuAction,
1889
+ ): Promise<"stay" | "exit"> => {
1890
+ if (action === "refine") {
1891
+ const title = record.title || "(untitled)";
1892
+ nextPrompt = buildRefinePrompt(record.id, title);
1893
+ done();
1894
+ return "exit";
1895
+ }
1896
+ if (action === "work") {
1897
+ const title = record.title || "(untitled)";
1898
+ nextPrompt = `work on todo ${formatTodoId(record.id)} "${title}"`;
1899
+ done();
1900
+ return "exit";
1901
+ }
1902
+ if (action === "view") {
1903
+ return "stay";
1904
+ }
1905
+ if (action === "copy") {
1906
+ copyTodoPathToClipboard(record.id);
1907
+ return "stay";
1908
+ }
1909
+
1910
+ if (action === "release") {
1911
+ const result = await releaseTodoAssignment(todosDir, record.id, ctx, true);
1912
+ if ("error" in result) {
1913
+ ctx.ui.notify(result.error, "error");
1914
+ return "stay";
1915
+ }
1916
+ const updatedTodos = await listTodos(todosDir);
1917
+ selector?.setTodos(updatedTodos);
1918
+ ctx.ui.notify(`Released todo ${formatTodoId(record.id)}`, "info");
1919
+ return "stay";
1920
+ }
1921
+
1922
+ if (action === "delete") {
1923
+ const result = await deleteTodo(todosDir, record.id, ctx);
1924
+ if ("error" in result) {
1925
+ ctx.ui.notify(result.error, "error");
1926
+ return "stay";
1927
+ }
1928
+ const updatedTodos = await listTodos(todosDir);
1929
+ selector?.setTodos(updatedTodos);
1930
+ ctx.ui.notify(`Deleted todo ${formatTodoId(record.id)}`, "info");
1931
+ return "stay";
1932
+ }
1933
+
1934
+ const nextStatus = action === "close" ? "closed" : "open";
1935
+ const result = await updateTodoStatus(todosDir, record.id, nextStatus, ctx);
1936
+ if ("error" in result) {
1937
+ ctx.ui.notify(result.error, "error");
1938
+ return "stay";
1939
+ }
1940
+
1941
+ const updatedTodos = await listTodos(todosDir);
1942
+ selector?.setTodos(updatedTodos);
1943
+ ctx.ui.notify(
1944
+ `${action === "close" ? "Closed" : "Reopened"} todo ${formatTodoId(record.id)}`,
1945
+ "info",
1946
+ );
1947
+ return "stay";
1948
+ };
1949
+
1950
+ const handleActionSelection = async (record: TodoRecord, action: TodoMenuAction) => {
1951
+ if (action === "view") {
1952
+ const overlayAction = await openTodoOverlay(record);
1953
+ if (overlayAction === "work") {
1954
+ await applyTodoAction(record, "work");
1955
+ return;
1956
+ }
1957
+ if (actionMenu) {
1958
+ setActiveComponent(actionMenu);
1959
+ }
1960
+ return;
1961
+ }
1962
+
1963
+ if (action === "delete") {
1964
+ const message = `Delete todo ${formatTodoId(record.id)}? This cannot be undone.`;
1965
+ deleteConfirm = new TodoDeleteConfirmComponent(theme, message, (confirmed) => {
1966
+ if (!confirmed) {
1967
+ setActiveComponent(actionMenu);
1968
+ return;
1969
+ }
1970
+ void (async () => {
1971
+ await applyTodoAction(record, "delete");
1972
+ setActiveComponent(selector);
1973
+ })();
1974
+ });
1975
+ setActiveComponent(deleteConfirm);
1976
+ return;
1977
+ }
1978
+
1979
+ const result = await applyTodoAction(record, action);
1980
+ if (result === "stay") {
1981
+ setActiveComponent(selector);
1982
+ }
1983
+ };
1984
+
1985
+ const showActionMenu = async (todo: TodoFrontMatter | TodoRecord) => {
1986
+ const record = "body" in todo ? todo : await resolveTodoRecord(todo);
1987
+ if (!record) return;
1988
+ actionMenu = new TodoActionMenuComponent(
1989
+ theme,
1990
+ record,
1991
+ (action) => {
1992
+ void handleActionSelection(record, action);
1993
+ },
1994
+ () => {
1995
+ setActiveComponent(selector);
1996
+ },
1997
+ );
1998
+ setActiveComponent(actionMenu);
1999
+ };
2000
+
2001
+ const handleSelect = async (todo: TodoFrontMatter) => {
2002
+ await showActionMenu(todo);
2003
+ };
2004
+
2005
+ selector = new TodoSelectorComponent(
2006
+ tui,
2007
+ theme,
2008
+ todos,
2009
+ (todo) => {
2010
+ void handleSelect(todo);
2011
+ },
2012
+ () => done(),
2013
+ searchTerm || undefined,
2014
+ currentSessionId,
2015
+ (todo, action) => {
2016
+ const title = todo.title || "(untitled)";
2017
+ nextPrompt =
2018
+ action === "refine"
2019
+ ? buildRefinePrompt(todo.id, title)
2020
+ : `work on todo ${formatTodoId(todo.id)} "${title}"`;
2021
+ done();
2022
+ },
2023
+ );
2024
+
2025
+ setActiveComponent(selector);
2026
+
2027
+ const rootComponent = {
2028
+ get focused() {
2029
+ return wrapperFocused;
2030
+ },
2031
+ set focused(value: boolean) {
2032
+ wrapperFocused = value;
2033
+ if (activeComponent && "focused" in activeComponent) {
2034
+ activeComponent.focused = value;
2035
+ }
2036
+ },
2037
+ render(width: number) {
2038
+ return activeComponent ? activeComponent.render(width) : [];
2039
+ },
2040
+ invalidate() {
2041
+ activeComponent?.invalidate();
2042
+ },
2043
+ handleInput(data: string) {
2044
+ activeComponent?.handleInput?.(data);
2045
+ },
2046
+ };
2047
+
2048
+ return rootComponent;
2049
+ });
2050
+
2051
+ if (nextPrompt) {
2052
+ ctx.ui.setEditorText(nextPrompt);
2053
+ rootTui?.requestRender();
2054
+ }
2055
+ },
2056
+ });
2057
+
2058
+ }