@docyrus/docyrus 0.0.34 → 0.0.36

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