mitsupi 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/package.json +1 -1
- package/pi-extensions/answer.ts +9 -4
- package/pi-extensions/todos.ts +686 -279
- package/pi-extensions/commit.ts +0 -248
package/pi-extensions/todos.ts
CHANGED
|
@@ -1,13 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* This extension stores todo items as files under <todo-dir> (defaults to .pi/todos,
|
|
3
|
+
* or the path in PI_TODO_PATH). Each todo is a standalone markdown file named
|
|
4
|
+
* <id>.md and an optional <id>.lock file is used while a session is editing it.
|
|
5
|
+
*
|
|
6
|
+
* File format in .pi/todos:
|
|
7
|
+
* - The file starts with a JSON object (not YAML) containing the front matter:
|
|
8
|
+
* { id, title, tags, status, created_at, assigned_to_session }
|
|
9
|
+
* - After the JSON block comes optional markdown body text separated by a blank line.
|
|
10
|
+
* - Example:
|
|
11
|
+
* {
|
|
12
|
+
* "id": "deadbeef",
|
|
13
|
+
* "title": "Add tests",
|
|
14
|
+
* "tags": ["qa"],
|
|
15
|
+
* "status": "open",
|
|
16
|
+
* "created_at": "2026-01-25T17:00:00.000Z",
|
|
17
|
+
* "assigned_to_session": "session.json"
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* Notes about the work go here.
|
|
3
21
|
*
|
|
22
|
+
* Todo storage settings are kept in <todo-dir>/settings.json.
|
|
4
23
|
* Defaults:
|
|
5
24
|
* {
|
|
6
25
|
* "gc": true, // delete closed todos older than gcDays on startup
|
|
7
26
|
* "gcDays": 7 // age threshold for GC (days since created_at)
|
|
8
27
|
* }
|
|
28
|
+
*
|
|
29
|
+
* Use `/todos` to bring up the visual todo manager or just let the LLM use them
|
|
30
|
+
* naturally.
|
|
9
31
|
*/
|
|
10
|
-
import { DynamicBorder, getMarkdownTheme, keyHint, type ExtensionAPI, type ExtensionContext, type Theme } from "@mariozechner/pi-coding-agent";
|
|
32
|
+
import { DynamicBorder, copyToClipboard, getMarkdownTheme, keyHint, type ExtensionAPI, type ExtensionContext, type Theme } from "@mariozechner/pi-coding-agent";
|
|
11
33
|
import { StringEnum } from "@mariozechner/pi-ai";
|
|
12
34
|
import { Type } from "@sinclair/typebox";
|
|
13
35
|
import path from "node:path";
|
|
@@ -33,9 +55,10 @@ import {
|
|
|
33
55
|
} from "@mariozechner/pi-tui";
|
|
34
56
|
|
|
35
57
|
const TODO_DIR_NAME = ".pi/todos";
|
|
36
|
-
const TODO_PATH_ENV = "
|
|
58
|
+
const TODO_PATH_ENV = "PI_TODO_PATH";
|
|
37
59
|
const TODO_SETTINGS_NAME = "settings.json";
|
|
38
60
|
const TODO_ID_PREFIX = "TODO-";
|
|
61
|
+
const TODO_ID_PATTERN = /^[a-f0-9]{8}$/i;
|
|
39
62
|
const DEFAULT_TODO_SETTINGS = {
|
|
40
63
|
gc: true,
|
|
41
64
|
gcDays: 7,
|
|
@@ -48,6 +71,7 @@ interface TodoFrontMatter {
|
|
|
48
71
|
tags: string[];
|
|
49
72
|
status: string;
|
|
50
73
|
created_at: string;
|
|
74
|
+
assigned_to_session?: string;
|
|
51
75
|
}
|
|
52
76
|
|
|
53
77
|
interface TodoRecord extends TodoFrontMatter {
|
|
@@ -67,7 +91,17 @@ interface TodoSettings {
|
|
|
67
91
|
}
|
|
68
92
|
|
|
69
93
|
const TodoParams = Type.Object({
|
|
70
|
-
action: StringEnum([
|
|
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),
|
|
71
105
|
id: Type.Optional(
|
|
72
106
|
Type.String({ description: "Todo id (TODO-<hex> or raw hex filename)" }),
|
|
73
107
|
),
|
|
@@ -77,17 +111,39 @@ const TodoParams = Type.Object({
|
|
|
77
111
|
body: Type.Optional(
|
|
78
112
|
Type.String({ description: "Long-form details (markdown). Update replaces; append adds." }),
|
|
79
113
|
),
|
|
114
|
+
force: Type.Optional(Type.Boolean({ description: "Override another session's assignment" })),
|
|
80
115
|
});
|
|
81
116
|
|
|
82
|
-
type TodoAction =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
117
|
+
type TodoAction =
|
|
118
|
+
| "list"
|
|
119
|
+
| "list-all"
|
|
120
|
+
| "get"
|
|
121
|
+
| "create"
|
|
122
|
+
| "update"
|
|
123
|
+
| "append"
|
|
124
|
+
| "delete"
|
|
125
|
+
| "claim"
|
|
126
|
+
| "release";
|
|
127
|
+
|
|
128
|
+
type TodoOverlayAction = "back" | "work";
|
|
129
|
+
|
|
130
|
+
type TodoMenuAction =
|
|
131
|
+
| "work"
|
|
132
|
+
| "refine"
|
|
133
|
+
| "close"
|
|
134
|
+
| "reopen"
|
|
135
|
+
| "release"
|
|
136
|
+
| "delete"
|
|
137
|
+
| "copy"
|
|
138
|
+
| "view";
|
|
87
139
|
|
|
88
140
|
type TodoToolDetails =
|
|
89
|
-
| { action: "list" | "list-all"; todos: TodoFrontMatter[]; error?: string }
|
|
90
|
-
| {
|
|
141
|
+
| { action: "list" | "list-all"; todos: TodoFrontMatter[]; currentSessionId?: string; error?: string }
|
|
142
|
+
| {
|
|
143
|
+
action: "get" | "create" | "update" | "append" | "delete" | "claim" | "release";
|
|
144
|
+
todo: TodoRecord;
|
|
145
|
+
error?: string;
|
|
146
|
+
};
|
|
91
147
|
|
|
92
148
|
function formatTodoId(id: string): string {
|
|
93
149
|
return `${TODO_ID_PREFIX}${id}`;
|
|
@@ -104,6 +160,14 @@ function normalizeTodoId(id: string): string {
|
|
|
104
160
|
return trimmed;
|
|
105
161
|
}
|
|
106
162
|
|
|
163
|
+
function validateTodoId(id: string): { id: string } | { error: string } {
|
|
164
|
+
const normalized = normalizeTodoId(id);
|
|
165
|
+
if (!normalized || !TODO_ID_PATTERN.test(normalized)) {
|
|
166
|
+
return { error: "Invalid todo id. Expected TODO-<hex>." };
|
|
167
|
+
}
|
|
168
|
+
return { id: normalized.toLowerCase() };
|
|
169
|
+
}
|
|
170
|
+
|
|
107
171
|
function displayTodoId(id: string): string {
|
|
108
172
|
return formatTodoId(normalizeTodoId(id));
|
|
109
173
|
}
|
|
@@ -112,18 +176,28 @@ function isTodoClosed(status: string): boolean {
|
|
|
112
176
|
return ["closed", "done"].includes(status.toLowerCase());
|
|
113
177
|
}
|
|
114
178
|
|
|
179
|
+
function clearAssignmentIfClosed(todo: TodoFrontMatter): void {
|
|
180
|
+
if (isTodoClosed(getTodoStatus(todo))) {
|
|
181
|
+
todo.assigned_to_session = undefined;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
115
185
|
function sortTodos(todos: TodoFrontMatter[]): TodoFrontMatter[] {
|
|
116
186
|
return [...todos].sort((a, b) => {
|
|
117
187
|
const aClosed = isTodoClosed(a.status);
|
|
118
188
|
const bClosed = isTodoClosed(b.status);
|
|
119
189
|
if (aClosed !== bClosed) return aClosed ? 1 : -1;
|
|
190
|
+
const aAssigned = !aClosed && Boolean(a.assigned_to_session);
|
|
191
|
+
const bAssigned = !bClosed && Boolean(b.assigned_to_session);
|
|
192
|
+
if (aAssigned !== bAssigned) return aAssigned ? -1 : 1;
|
|
120
193
|
return (a.created_at || "").localeCompare(b.created_at || "");
|
|
121
194
|
});
|
|
122
195
|
}
|
|
123
196
|
|
|
124
197
|
function buildTodoSearchText(todo: TodoFrontMatter): string {
|
|
125
198
|
const tags = todo.tags.join(" ");
|
|
126
|
-
|
|
199
|
+
const assignment = todo.assigned_to_session ? `assigned:${todo.assigned_to_session}` : "";
|
|
200
|
+
return `${formatTodoId(todo.id)} ${todo.id} ${todo.title} ${tags} ${todo.status} ${assignment}`.trim();
|
|
127
201
|
}
|
|
128
202
|
|
|
129
203
|
function filterTodos(todos: TodoFrontMatter[], query: string): TodoFrontMatter[] {
|
|
@@ -160,6 +234,9 @@ function filterTodos(todos: TodoFrontMatter[], query: string): TodoFrontMatter[]
|
|
|
160
234
|
const aClosed = isTodoClosed(a.todo.status);
|
|
161
235
|
const bClosed = isTodoClosed(b.todo.status);
|
|
162
236
|
if (aClosed !== bClosed) return aClosed ? 1 : -1;
|
|
237
|
+
const aAssigned = !aClosed && Boolean(a.todo.assigned_to_session);
|
|
238
|
+
const bAssigned = !bClosed && Boolean(b.todo.assigned_to_session);
|
|
239
|
+
if (aAssigned !== bAssigned) return aAssigned ? -1 : 1;
|
|
163
240
|
return a.score - b.score;
|
|
164
241
|
})
|
|
165
242
|
.map((match) => match.todo);
|
|
@@ -177,6 +254,7 @@ class TodoSelectorComponent extends Container implements Focusable {
|
|
|
177
254
|
private theme: Theme;
|
|
178
255
|
private headerText: Text;
|
|
179
256
|
private hintText: Text;
|
|
257
|
+
private currentSessionId?: string;
|
|
180
258
|
|
|
181
259
|
private _focused = false;
|
|
182
260
|
get focused(): boolean {
|
|
@@ -194,11 +272,13 @@ class TodoSelectorComponent extends Container implements Focusable {
|
|
|
194
272
|
onSelect: (todo: TodoFrontMatter) => void,
|
|
195
273
|
onCancel: () => void,
|
|
196
274
|
initialSearchInput?: string,
|
|
275
|
+
currentSessionId?: string,
|
|
197
276
|
private onQuickAction?: (todo: TodoFrontMatter, action: "work" | "refine") => void,
|
|
198
277
|
) {
|
|
199
278
|
super();
|
|
200
279
|
this.tui = tui;
|
|
201
280
|
this.theme = theme;
|
|
281
|
+
this.currentSessionId = currentSessionId;
|
|
202
282
|
this.allTodos = todos;
|
|
203
283
|
this.filteredTodos = todos;
|
|
204
284
|
this.onSelectCallback = onSelect;
|
|
@@ -293,12 +373,14 @@ class TodoSelectorComponent extends Container implements Focusable {
|
|
|
293
373
|
const titleColor = isSelected ? "accent" : closed ? "dim" : "text";
|
|
294
374
|
const statusColor = closed ? "dim" : "success";
|
|
295
375
|
const tagText = todo.tags.length ? ` [${todo.tags.join(", ")}]` : "";
|
|
376
|
+
const assignmentText = renderAssignmentSuffix(this.theme, todo, this.currentSessionId);
|
|
296
377
|
const line =
|
|
297
378
|
prefix +
|
|
298
379
|
this.theme.fg("accent", formatTodoId(todo.id)) +
|
|
299
380
|
" " +
|
|
300
381
|
this.theme.fg(titleColor, todo.title || "(untitled)") +
|
|
301
382
|
this.theme.fg("muted", tagText) +
|
|
383
|
+
assignmentText +
|
|
302
384
|
" " +
|
|
303
385
|
this.theme.fg(statusColor, `(${todo.status || "open"})`);
|
|
304
386
|
this.listContainer.addChild(new Text(line, 0, 0));
|
|
@@ -358,6 +440,113 @@ class TodoSelectorComponent extends Container implements Focusable {
|
|
|
358
440
|
}
|
|
359
441
|
}
|
|
360
442
|
|
|
443
|
+
class TodoActionMenuComponent extends Container {
|
|
444
|
+
private selectList: SelectList;
|
|
445
|
+
private onSelectCallback: (action: TodoMenuAction) => void;
|
|
446
|
+
private onCancelCallback: () => void;
|
|
447
|
+
|
|
448
|
+
constructor(
|
|
449
|
+
theme: Theme,
|
|
450
|
+
todo: TodoRecord,
|
|
451
|
+
onSelect: (action: TodoMenuAction) => void,
|
|
452
|
+
onCancel: () => void,
|
|
453
|
+
) {
|
|
454
|
+
super();
|
|
455
|
+
this.onSelectCallback = onSelect;
|
|
456
|
+
this.onCancelCallback = onCancel;
|
|
457
|
+
|
|
458
|
+
const closed = isTodoClosed(todo.status);
|
|
459
|
+
const title = todo.title || "(untitled)";
|
|
460
|
+
const options: SelectItem[] = [
|
|
461
|
+
{ value: "view", label: "view", description: "View todo" },
|
|
462
|
+
{ value: "work", label: "work", description: "Work on todo" },
|
|
463
|
+
{ value: "refine", label: "refine", description: "Refine task" },
|
|
464
|
+
...(closed
|
|
465
|
+
? [{ value: "reopen", label: "reopen", description: "Reopen todo" }]
|
|
466
|
+
: [{ value: "close", label: "close", description: "Close todo" }]),
|
|
467
|
+
...(todo.assigned_to_session
|
|
468
|
+
? [{ value: "release", label: "release", description: "Release assignment" }]
|
|
469
|
+
: []),
|
|
470
|
+
{ value: "copy", label: "copy", description: "Copy path to clipboard" },
|
|
471
|
+
{ value: "delete", label: "delete", description: "Delete todo" },
|
|
472
|
+
];
|
|
473
|
+
|
|
474
|
+
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
475
|
+
this.addChild(
|
|
476
|
+
new Text(
|
|
477
|
+
theme.fg(
|
|
478
|
+
"accent",
|
|
479
|
+
theme.bold(`Actions for ${formatTodoId(todo.id)} "${title}"`),
|
|
480
|
+
),
|
|
481
|
+
),
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
this.selectList = new SelectList(options, options.length, {
|
|
485
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
486
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
487
|
+
description: (text) => theme.fg("muted", text),
|
|
488
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
489
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
this.selectList.onSelect = (item) => this.onSelectCallback(item.value as TodoMenuAction);
|
|
493
|
+
this.selectList.onCancel = () => this.onCancelCallback();
|
|
494
|
+
|
|
495
|
+
this.addChild(this.selectList);
|
|
496
|
+
this.addChild(new Text(theme.fg("dim", "Enter to confirm • Esc back")));
|
|
497
|
+
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
handleInput(keyData: string): void {
|
|
501
|
+
this.selectList.handleInput(keyData);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
override invalidate(): void {
|
|
505
|
+
super.invalidate();
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
class TodoDeleteConfirmComponent extends Container {
|
|
510
|
+
private selectList: SelectList;
|
|
511
|
+
private onConfirm: (confirmed: boolean) => void;
|
|
512
|
+
|
|
513
|
+
constructor(theme: Theme, message: string, onConfirm: (confirmed: boolean) => void) {
|
|
514
|
+
super();
|
|
515
|
+
this.onConfirm = onConfirm;
|
|
516
|
+
|
|
517
|
+
const options: SelectItem[] = [
|
|
518
|
+
{ value: "yes", label: "Yes" },
|
|
519
|
+
{ value: "no", label: "No" },
|
|
520
|
+
];
|
|
521
|
+
|
|
522
|
+
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
523
|
+
this.addChild(new Text(theme.fg("accent", message)));
|
|
524
|
+
|
|
525
|
+
this.selectList = new SelectList(options, options.length, {
|
|
526
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
527
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
528
|
+
description: (text) => theme.fg("muted", text),
|
|
529
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
530
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
this.selectList.onSelect = (item) => this.onConfirm(item.value === "yes");
|
|
534
|
+
this.selectList.onCancel = () => this.onConfirm(false);
|
|
535
|
+
|
|
536
|
+
this.addChild(this.selectList);
|
|
537
|
+
this.addChild(new Text(theme.fg("dim", "Enter to confirm • Esc back")));
|
|
538
|
+
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
handleInput(keyData: string): void {
|
|
542
|
+
this.selectList.handleInput(keyData);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
override invalidate(): void {
|
|
546
|
+
super.invalidate();
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
361
550
|
class TodoDetailOverlayComponent {
|
|
362
551
|
private todo: TodoRecord;
|
|
363
552
|
private theme: Theme;
|
|
@@ -384,7 +573,11 @@ class TodoDetailOverlayComponent {
|
|
|
384
573
|
handleInput(keyData: string): void {
|
|
385
574
|
const kb = getEditorKeybindings();
|
|
386
575
|
if (kb.matches(keyData, "selectCancel")) {
|
|
387
|
-
this.onAction("
|
|
576
|
+
this.onAction("back");
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (kb.matches(keyData, "selectConfirm")) {
|
|
580
|
+
this.onAction("work");
|
|
388
581
|
return;
|
|
389
582
|
}
|
|
390
583
|
if (kb.matches(keyData, "selectUp")) {
|
|
@@ -403,30 +596,6 @@ class TodoDetailOverlayComponent {
|
|
|
403
596
|
this.scrollBy(this.viewHeight || 1);
|
|
404
597
|
return;
|
|
405
598
|
}
|
|
406
|
-
if (keyData === "r" || keyData === "R") {
|
|
407
|
-
this.onAction("refine");
|
|
408
|
-
return;
|
|
409
|
-
}
|
|
410
|
-
if (keyData === "c" || keyData === "C") {
|
|
411
|
-
this.onAction("close");
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
414
|
-
if (keyData === "o" || keyData === "O") {
|
|
415
|
-
this.onAction("reopen");
|
|
416
|
-
return;
|
|
417
|
-
}
|
|
418
|
-
if (keyData === "w" || keyData === "W") {
|
|
419
|
-
this.onAction("work");
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
if (keyData === "a" || keyData === "A") {
|
|
423
|
-
this.onAction("actions");
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
if (keyData === "d" || keyData === "D") {
|
|
427
|
-
this.onAction("delete");
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
599
|
}
|
|
431
600
|
|
|
432
601
|
render(width: number): string[] {
|
|
@@ -512,16 +681,9 @@ class TodoDetailOverlayComponent {
|
|
|
512
681
|
}
|
|
513
682
|
|
|
514
683
|
private buildActionLine(width: number): string {
|
|
515
|
-
const
|
|
516
|
-
const refine = this.theme.fg("accent", "r") + this.theme.fg("muted", " refine task");
|
|
517
|
-
const work = this.theme.fg("accent", "w") + this.theme.fg("muted", " work on todo");
|
|
518
|
-
const close = this.theme.fg("accent", "c") + this.theme.fg("muted", " close task");
|
|
519
|
-
const reopen = this.theme.fg("accent", "o") + this.theme.fg("muted", " reopen task");
|
|
520
|
-
const statusAction = closed ? reopen : close;
|
|
521
|
-
const actions = this.theme.fg("accent", "a") + this.theme.fg("muted", " actions");
|
|
522
|
-
const del = this.theme.fg("error", "d") + this.theme.fg("muted", " delete todo");
|
|
684
|
+
const work = this.theme.fg("accent", "enter") + this.theme.fg("muted", " work on todo");
|
|
523
685
|
const back = this.theme.fg("dim", "esc back");
|
|
524
|
-
const pieces = [work,
|
|
686
|
+
const pieces = [work, back];
|
|
525
687
|
|
|
526
688
|
let line = pieces.join(this.theme.fg("muted", " • "));
|
|
527
689
|
if (this.totalLines > this.viewHeight) {
|
|
@@ -548,6 +710,14 @@ function getTodosDir(cwd: string): string {
|
|
|
548
710
|
return path.resolve(cwd, TODO_DIR_NAME);
|
|
549
711
|
}
|
|
550
712
|
|
|
713
|
+
function getTodosDirLabel(cwd: string): string {
|
|
714
|
+
const overridePath = process.env[TODO_PATH_ENV];
|
|
715
|
+
if (overridePath && overridePath.trim()) {
|
|
716
|
+
return path.resolve(cwd, overridePath.trim());
|
|
717
|
+
}
|
|
718
|
+
return TODO_DIR_NAME;
|
|
719
|
+
}
|
|
720
|
+
|
|
551
721
|
function getTodoSettingsPath(todosDir: string): string {
|
|
552
722
|
return path.join(todosDir, TODO_SETTINGS_NAME);
|
|
553
723
|
}
|
|
@@ -564,26 +734,15 @@ function normalizeTodoSettings(raw: Partial<TodoSettings>): TodoSettings {
|
|
|
564
734
|
async function readTodoSettings(todosDir: string): Promise<TodoSettings> {
|
|
565
735
|
const settingsPath = getTodoSettingsPath(todosDir);
|
|
566
736
|
let data: Partial<TodoSettings> = {};
|
|
567
|
-
let shouldWrite = false;
|
|
568
737
|
|
|
569
738
|
try {
|
|
570
739
|
const raw = await fs.readFile(settingsPath, "utf8");
|
|
571
740
|
data = JSON.parse(raw) as Partial<TodoSettings>;
|
|
572
741
|
} catch {
|
|
573
|
-
|
|
742
|
+
data = {};
|
|
574
743
|
}
|
|
575
744
|
|
|
576
|
-
|
|
577
|
-
if (
|
|
578
|
-
shouldWrite ||
|
|
579
|
-
data.gc === undefined ||
|
|
580
|
-
data.gcDays === undefined ||
|
|
581
|
-
!Number.isFinite(data.gcDays)
|
|
582
|
-
) {
|
|
583
|
-
await fs.writeFile(settingsPath, JSON.stringify(normalized, null, 2) + "\n", "utf8");
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
return normalized;
|
|
745
|
+
return normalizeTodoSettings(data);
|
|
587
746
|
}
|
|
588
747
|
|
|
589
748
|
async function garbageCollectTodos(todosDir: string, settings: TodoSettings): Promise<void> {
|
|
@@ -628,24 +787,6 @@ function getLockPath(todosDir: string, id: string): string {
|
|
|
628
787
|
return path.join(todosDir, `${id}.lock`);
|
|
629
788
|
}
|
|
630
789
|
|
|
631
|
-
function stripQuotes(value: string): string {
|
|
632
|
-
const trimmed = value.trim();
|
|
633
|
-
if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
634
|
-
return trimmed.slice(1, -1);
|
|
635
|
-
}
|
|
636
|
-
return trimmed;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
function parseTagsInline(value: string): string[] {
|
|
640
|
-
const inner = value.trim().slice(1, -1);
|
|
641
|
-
if (!inner.trim()) return [];
|
|
642
|
-
return inner
|
|
643
|
-
.split(",")
|
|
644
|
-
.map((item) => stripQuotes(item))
|
|
645
|
-
.map((item) => item.trim())
|
|
646
|
-
.filter(Boolean);
|
|
647
|
-
}
|
|
648
|
-
|
|
649
790
|
function parseFrontMatter(text: string, idFallback: string): TodoFrontMatter {
|
|
650
791
|
const data: TodoFrontMatter = {
|
|
651
792
|
id: idFallback,
|
|
@@ -653,67 +794,86 @@ function parseFrontMatter(text: string, idFallback: string): TodoFrontMatter {
|
|
|
653
794
|
tags: [],
|
|
654
795
|
status: "open",
|
|
655
796
|
created_at: "",
|
|
797
|
+
assigned_to_session: undefined,
|
|
656
798
|
};
|
|
657
799
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
const line = rawLine.trim();
|
|
661
|
-
if (!line) continue;
|
|
800
|
+
const trimmed = text.trim();
|
|
801
|
+
if (!trimmed) return data;
|
|
662
802
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
803
|
+
try {
|
|
804
|
+
const parsed = JSON.parse(trimmed) as Partial<TodoFrontMatter> | null;
|
|
805
|
+
if (!parsed || typeof parsed !== "object") return data;
|
|
806
|
+
if (typeof parsed.id === "string" && parsed.id) data.id = parsed.id;
|
|
807
|
+
if (typeof parsed.title === "string") data.title = parsed.title;
|
|
808
|
+
if (typeof parsed.status === "string" && parsed.status) data.status = parsed.status;
|
|
809
|
+
if (typeof parsed.created_at === "string") data.created_at = parsed.created_at;
|
|
810
|
+
if (typeof parsed.assigned_to_session === "string" && parsed.assigned_to_session.trim()) {
|
|
811
|
+
data.assigned_to_session = parsed.assigned_to_session;
|
|
812
|
+
}
|
|
813
|
+
if (Array.isArray(parsed.tags)) {
|
|
814
|
+
data.tags = parsed.tags.filter((tag): tag is string => typeof tag === "string");
|
|
667
815
|
}
|
|
816
|
+
} catch {
|
|
817
|
+
return data;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
return data;
|
|
821
|
+
}
|
|
668
822
|
|
|
669
|
-
|
|
670
|
-
|
|
823
|
+
function findJsonObjectEnd(content: string): number {
|
|
824
|
+
let depth = 0;
|
|
825
|
+
let inString = false;
|
|
826
|
+
let escaped = false;
|
|
671
827
|
|
|
672
|
-
|
|
673
|
-
const
|
|
674
|
-
currentKey = null;
|
|
828
|
+
for (let i = 0; i < content.length; i += 1) {
|
|
829
|
+
const char = content[i];
|
|
675
830
|
|
|
676
|
-
if (
|
|
677
|
-
if (
|
|
678
|
-
|
|
831
|
+
if (inString) {
|
|
832
|
+
if (escaped) {
|
|
833
|
+
escaped = false;
|
|
679
834
|
continue;
|
|
680
835
|
}
|
|
681
|
-
if (
|
|
682
|
-
|
|
836
|
+
if (char === "\\") {
|
|
837
|
+
escaped = true;
|
|
683
838
|
continue;
|
|
684
839
|
}
|
|
685
|
-
|
|
840
|
+
if (char === "\"") {
|
|
841
|
+
inString = false;
|
|
842
|
+
}
|
|
686
843
|
continue;
|
|
687
844
|
}
|
|
688
845
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
default:
|
|
703
|
-
break;
|
|
846
|
+
if (char === "\"") {
|
|
847
|
+
inString = true;
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (char === "{") {
|
|
852
|
+
depth += 1;
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (char === "}") {
|
|
857
|
+
depth -= 1;
|
|
858
|
+
if (depth === 0) return i;
|
|
704
859
|
}
|
|
705
860
|
}
|
|
706
861
|
|
|
707
|
-
return
|
|
862
|
+
return -1;
|
|
708
863
|
}
|
|
709
864
|
|
|
710
865
|
function splitFrontMatter(content: string): { frontMatter: string; body: string } {
|
|
711
|
-
|
|
712
|
-
if (!match) {
|
|
866
|
+
if (!content.startsWith("{")) {
|
|
713
867
|
return { frontMatter: "", body: content };
|
|
714
868
|
}
|
|
715
|
-
|
|
716
|
-
const
|
|
869
|
+
|
|
870
|
+
const endIndex = findJsonObjectEnd(content);
|
|
871
|
+
if (endIndex === -1) {
|
|
872
|
+
return { frontMatter: "", body: content };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const frontMatter = content.slice(0, endIndex + 1);
|
|
876
|
+
const body = content.slice(endIndex + 1).replace(/^\r?\n+/, "");
|
|
717
877
|
return { frontMatter, body };
|
|
718
878
|
}
|
|
719
879
|
|
|
@@ -726,31 +886,29 @@ function parseTodoContent(content: string, idFallback: string): TodoRecord {
|
|
|
726
886
|
tags: parsed.tags ?? [],
|
|
727
887
|
status: parsed.status,
|
|
728
888
|
created_at: parsed.created_at,
|
|
889
|
+
assigned_to_session: parsed.assigned_to_session,
|
|
729
890
|
body: body ?? "",
|
|
730
891
|
};
|
|
731
892
|
}
|
|
732
893
|
|
|
733
|
-
function escapeYaml(value: string): string {
|
|
734
|
-
return value.replace(/\\/g, "\\\\").replace(/\"/g, "\\\"");
|
|
735
|
-
}
|
|
736
|
-
|
|
737
894
|
function serializeTodo(todo: TodoRecord): string {
|
|
738
|
-
const
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
895
|
+
const frontMatter = JSON.stringify(
|
|
896
|
+
{
|
|
897
|
+
id: todo.id,
|
|
898
|
+
title: todo.title,
|
|
899
|
+
tags: todo.tags ?? [],
|
|
900
|
+
status: todo.status,
|
|
901
|
+
created_at: todo.created_at,
|
|
902
|
+
assigned_to_session: todo.assigned_to_session || undefined,
|
|
903
|
+
},
|
|
904
|
+
null,
|
|
905
|
+
2,
|
|
906
|
+
);
|
|
750
907
|
|
|
751
908
|
const body = todo.body ?? "";
|
|
752
909
|
const trimmedBody = body.replace(/^\n+/, "").replace(/\s+$/, "");
|
|
753
|
-
|
|
910
|
+
if (!trimmedBody) return `${frontMatter}\n`;
|
|
911
|
+
return `${frontMatter}\n\n${trimmedBody}\n`;
|
|
754
912
|
}
|
|
755
913
|
|
|
756
914
|
async function ensureTodosDir(todosDir: string) {
|
|
@@ -877,6 +1035,7 @@ async function listTodos(todosDir: string): Promise<TodoFrontMatter[]> {
|
|
|
877
1035
|
tags: parsed.tags ?? [],
|
|
878
1036
|
status: parsed.status,
|
|
879
1037
|
created_at: parsed.created_at,
|
|
1038
|
+
assigned_to_session: parsed.assigned_to_session,
|
|
880
1039
|
});
|
|
881
1040
|
} catch {
|
|
882
1041
|
// ignore unreadable todo
|
|
@@ -909,6 +1068,7 @@ function listTodosSync(todosDir: string): TodoFrontMatter[] {
|
|
|
909
1068
|
tags: parsed.tags ?? [],
|
|
910
1069
|
status: parsed.status,
|
|
911
1070
|
created_at: parsed.created_at,
|
|
1071
|
+
assigned_to_session: parsed.assigned_to_session,
|
|
912
1072
|
});
|
|
913
1073
|
} catch {
|
|
914
1074
|
// ignore
|
|
@@ -926,36 +1086,61 @@ function getTodoStatus(todo: TodoFrontMatter): string {
|
|
|
926
1086
|
return todo.status || "open";
|
|
927
1087
|
}
|
|
928
1088
|
|
|
1089
|
+
function formatAssignmentSuffix(todo: TodoFrontMatter): string {
|
|
1090
|
+
return todo.assigned_to_session ? ` (assigned: ${todo.assigned_to_session})` : "";
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function renderAssignmentSuffix(
|
|
1094
|
+
theme: Theme,
|
|
1095
|
+
todo: TodoFrontMatter,
|
|
1096
|
+
currentSessionId?: string,
|
|
1097
|
+
): string {
|
|
1098
|
+
if (!todo.assigned_to_session) return "";
|
|
1099
|
+
const isCurrent = todo.assigned_to_session === currentSessionId;
|
|
1100
|
+
const color = isCurrent ? "success" : "dim";
|
|
1101
|
+
const suffix = isCurrent ? ", current" : "";
|
|
1102
|
+
return theme.fg(color, ` (assigned: ${todo.assigned_to_session}${suffix})`);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
929
1105
|
function formatTodoHeading(todo: TodoFrontMatter): string {
|
|
930
1106
|
const tagText = todo.tags.length ? ` [${todo.tags.join(", ")}]` : "";
|
|
931
|
-
return `${formatTodoId(todo.id)} ${getTodoTitle(todo)}${tagText}`;
|
|
1107
|
+
return `${formatTodoId(todo.id)} ${getTodoTitle(todo)}${tagText}${formatAssignmentSuffix(todo)}`;
|
|
932
1108
|
}
|
|
933
1109
|
|
|
934
1110
|
function buildRefinePrompt(todoId: string, title: string): string {
|
|
935
1111
|
return (
|
|
936
1112
|
`let's refine task ${formatTodoId(todoId)} "${title}": ` +
|
|
937
|
-
"
|
|
938
|
-
"
|
|
1113
|
+
"Ask me for the missing details needed to refine the todo together. Do not rewrite the todo yet and do not make assumptions. " +
|
|
1114
|
+
"Ask clear, concrete questions and wait for my answers before drafting any structured description.\n\n"
|
|
939
1115
|
);
|
|
940
1116
|
}
|
|
941
1117
|
|
|
942
|
-
function
|
|
1118
|
+
function splitTodosByAssignment(todos: TodoFrontMatter[]): {
|
|
1119
|
+
assignedTodos: TodoFrontMatter[];
|
|
1120
|
+
openTodos: TodoFrontMatter[];
|
|
1121
|
+
closedTodos: TodoFrontMatter[];
|
|
1122
|
+
} {
|
|
1123
|
+
const assignedTodos: TodoFrontMatter[] = [];
|
|
943
1124
|
const openTodos: TodoFrontMatter[] = [];
|
|
944
1125
|
const closedTodos: TodoFrontMatter[] = [];
|
|
945
1126
|
for (const todo of todos) {
|
|
946
1127
|
if (isTodoClosed(getTodoStatus(todo))) {
|
|
947
1128
|
closedTodos.push(todo);
|
|
1129
|
+
continue;
|
|
1130
|
+
}
|
|
1131
|
+
if (todo.assigned_to_session) {
|
|
1132
|
+
assignedTodos.push(todo);
|
|
948
1133
|
} else {
|
|
949
1134
|
openTodos.push(todo);
|
|
950
1135
|
}
|
|
951
1136
|
}
|
|
952
|
-
return { openTodos, closedTodos };
|
|
1137
|
+
return { assignedTodos, openTodos, closedTodos };
|
|
953
1138
|
}
|
|
954
1139
|
|
|
955
1140
|
function formatTodoList(todos: TodoFrontMatter[]): string {
|
|
956
1141
|
if (!todos.length) return "No todos.";
|
|
957
1142
|
|
|
958
|
-
const { openTodos } =
|
|
1143
|
+
const { assignedTodos, openTodos, closedTodos } = splitTodosByAssignment(todos);
|
|
959
1144
|
const lines: string[] = [];
|
|
960
1145
|
const pushSection = (label: string, sectionTodos: TodoFrontMatter[]) => {
|
|
961
1146
|
lines.push(`${label} (${sectionTodos.length}):`);
|
|
@@ -968,7 +1153,9 @@ function formatTodoList(todos: TodoFrontMatter[]): string {
|
|
|
968
1153
|
}
|
|
969
1154
|
};
|
|
970
1155
|
|
|
1156
|
+
pushSection("Assigned todos", assignedTodos);
|
|
971
1157
|
pushSection("Open todos", openTodos);
|
|
1158
|
+
pushSection("Closed todos", closedTodos);
|
|
972
1159
|
return lines.join("\n");
|
|
973
1160
|
}
|
|
974
1161
|
|
|
@@ -978,10 +1165,11 @@ function serializeTodoForAgent(todo: TodoRecord): string {
|
|
|
978
1165
|
}
|
|
979
1166
|
|
|
980
1167
|
function serializeTodoListForAgent(todos: TodoFrontMatter[]): string {
|
|
981
|
-
const { openTodos, closedTodos } =
|
|
1168
|
+
const { assignedTodos, openTodos, closedTodos } = splitTodosByAssignment(todos);
|
|
982
1169
|
const mapTodo = (todo: TodoFrontMatter) => ({ ...todo, id: formatTodoId(todo.id) });
|
|
983
1170
|
return JSON.stringify(
|
|
984
1171
|
{
|
|
1172
|
+
assigned: assignedTodos.map(mapTodo),
|
|
985
1173
|
open: openTodos.map(mapTodo),
|
|
986
1174
|
closed: closedTodos.map(mapTodo),
|
|
987
1175
|
},
|
|
@@ -990,22 +1178,29 @@ function serializeTodoListForAgent(todos: TodoFrontMatter[]): string {
|
|
|
990
1178
|
);
|
|
991
1179
|
}
|
|
992
1180
|
|
|
993
|
-
function renderTodoHeading(theme: Theme, todo: TodoFrontMatter): string {
|
|
1181
|
+
function renderTodoHeading(theme: Theme, todo: TodoFrontMatter, currentSessionId?: string): string {
|
|
994
1182
|
const closed = isTodoClosed(getTodoStatus(todo));
|
|
995
1183
|
const titleColor = closed ? "dim" : "text";
|
|
996
1184
|
const tagText = todo.tags.length ? theme.fg("dim", ` [${todo.tags.join(", ")}]`) : "";
|
|
1185
|
+
const assignmentText = renderAssignmentSuffix(theme, todo, currentSessionId);
|
|
997
1186
|
return (
|
|
998
1187
|
theme.fg("accent", formatTodoId(todo.id)) +
|
|
999
1188
|
" " +
|
|
1000
1189
|
theme.fg(titleColor, getTodoTitle(todo)) +
|
|
1001
|
-
tagText
|
|
1190
|
+
tagText +
|
|
1191
|
+
assignmentText
|
|
1002
1192
|
);
|
|
1003
1193
|
}
|
|
1004
1194
|
|
|
1005
|
-
function renderTodoList(
|
|
1195
|
+
function renderTodoList(
|
|
1196
|
+
theme: Theme,
|
|
1197
|
+
todos: TodoFrontMatter[],
|
|
1198
|
+
expanded: boolean,
|
|
1199
|
+
currentSessionId?: string,
|
|
1200
|
+
): string {
|
|
1006
1201
|
if (!todos.length) return theme.fg("dim", "No todos");
|
|
1007
1202
|
|
|
1008
|
-
const { openTodos, closedTodos } =
|
|
1203
|
+
const { assignedTodos, openTodos, closedTodos } = splitTodosByAssignment(todos);
|
|
1009
1204
|
const lines: string[] = [];
|
|
1010
1205
|
const pushSection = (label: string, sectionTodos: TodoFrontMatter[]) => {
|
|
1011
1206
|
lines.push(theme.fg("muted", `${label} (${sectionTodos.length})`));
|
|
@@ -1015,18 +1210,24 @@ function renderTodoList(theme: Theme, todos: TodoFrontMatter[], expanded: boolea
|
|
|
1015
1210
|
}
|
|
1016
1211
|
const maxItems = expanded ? sectionTodos.length : Math.min(sectionTodos.length, 3);
|
|
1017
1212
|
for (let i = 0; i < maxItems; i++) {
|
|
1018
|
-
lines.push(` ${renderTodoHeading(theme, sectionTodos[i])}`);
|
|
1213
|
+
lines.push(` ${renderTodoHeading(theme, sectionTodos[i], currentSessionId)}`);
|
|
1019
1214
|
}
|
|
1020
1215
|
if (!expanded && sectionTodos.length > maxItems) {
|
|
1021
1216
|
lines.push(theme.fg("dim", ` ... ${sectionTodos.length - maxItems} more`));
|
|
1022
1217
|
}
|
|
1023
1218
|
};
|
|
1024
1219
|
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1220
|
+
const sections: Array<{ label: string; todos: TodoFrontMatter[] }> = [
|
|
1221
|
+
{ label: "Assigned todos", todos: assignedTodos },
|
|
1222
|
+
{ label: "Open todos", todos: openTodos },
|
|
1223
|
+
{ label: "Closed todos", todos: closedTodos },
|
|
1224
|
+
];
|
|
1225
|
+
|
|
1226
|
+
sections.forEach((section, index) => {
|
|
1227
|
+
if (index > 0) lines.push("");
|
|
1228
|
+
pushSection(section.label, section.todos);
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1030
1231
|
return lines.join("\n");
|
|
1031
1232
|
}
|
|
1032
1233
|
|
|
@@ -1074,7 +1275,11 @@ async function updateTodoStatus(
|
|
|
1074
1275
|
status: string,
|
|
1075
1276
|
ctx: ExtensionContext,
|
|
1076
1277
|
): Promise<TodoRecord | { error: string }> {
|
|
1077
|
-
const
|
|
1278
|
+
const validated = validateTodoId(id);
|
|
1279
|
+
if ("error" in validated) {
|
|
1280
|
+
return { error: validated.error };
|
|
1281
|
+
}
|
|
1282
|
+
const normalizedId = validated.id;
|
|
1078
1283
|
const filePath = getTodoPath(todosDir, normalizedId);
|
|
1079
1284
|
if (!existsSync(filePath)) {
|
|
1080
1285
|
return { error: `Todo ${displayTodoId(id)} not found` };
|
|
@@ -1084,6 +1289,89 @@ async function updateTodoStatus(
|
|
|
1084
1289
|
const existing = await ensureTodoExists(filePath, normalizedId);
|
|
1085
1290
|
if (!existing) return { error: `Todo ${displayTodoId(id)} not found` } as const;
|
|
1086
1291
|
existing.status = status;
|
|
1292
|
+
clearAssignmentIfClosed(existing);
|
|
1293
|
+
await writeTodoFile(filePath, existing);
|
|
1294
|
+
return existing;
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
if (typeof result === "object" && "error" in result) {
|
|
1298
|
+
return { error: result.error };
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
return result;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
async function claimTodoAssignment(
|
|
1305
|
+
todosDir: string,
|
|
1306
|
+
id: string,
|
|
1307
|
+
ctx: ExtensionContext,
|
|
1308
|
+
force = false,
|
|
1309
|
+
): Promise<TodoRecord | { error: string }> {
|
|
1310
|
+
const validated = validateTodoId(id);
|
|
1311
|
+
if ("error" in validated) {
|
|
1312
|
+
return { error: validated.error };
|
|
1313
|
+
}
|
|
1314
|
+
const normalizedId = validated.id;
|
|
1315
|
+
const filePath = getTodoPath(todosDir, normalizedId);
|
|
1316
|
+
if (!existsSync(filePath)) {
|
|
1317
|
+
return { error: `Todo ${displayTodoId(id)} not found` };
|
|
1318
|
+
}
|
|
1319
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
1320
|
+
const result = await withTodoLock(todosDir, normalizedId, ctx, async () => {
|
|
1321
|
+
const existing = await ensureTodoExists(filePath, normalizedId);
|
|
1322
|
+
if (!existing) return { error: `Todo ${displayTodoId(id)} not found` } as const;
|
|
1323
|
+
if (isTodoClosed(existing.status)) {
|
|
1324
|
+
return { error: `Todo ${displayTodoId(id)} is closed` } as const;
|
|
1325
|
+
}
|
|
1326
|
+
const assigned = existing.assigned_to_session;
|
|
1327
|
+
if (assigned && assigned !== sessionId && !force) {
|
|
1328
|
+
return {
|
|
1329
|
+
error: `Todo ${displayTodoId(id)} is already assigned to session ${assigned}. Use force to override.`,
|
|
1330
|
+
} as const;
|
|
1331
|
+
}
|
|
1332
|
+
if (assigned !== sessionId) {
|
|
1333
|
+
existing.assigned_to_session = sessionId;
|
|
1334
|
+
await writeTodoFile(filePath, existing);
|
|
1335
|
+
}
|
|
1336
|
+
return existing;
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
if (typeof result === "object" && "error" in result) {
|
|
1340
|
+
return { error: result.error };
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
return result;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
async function releaseTodoAssignment(
|
|
1347
|
+
todosDir: string,
|
|
1348
|
+
id: string,
|
|
1349
|
+
ctx: ExtensionContext,
|
|
1350
|
+
force = false,
|
|
1351
|
+
): Promise<TodoRecord | { error: string }> {
|
|
1352
|
+
const validated = validateTodoId(id);
|
|
1353
|
+
if ("error" in validated) {
|
|
1354
|
+
return { error: validated.error };
|
|
1355
|
+
}
|
|
1356
|
+
const normalizedId = validated.id;
|
|
1357
|
+
const filePath = getTodoPath(todosDir, normalizedId);
|
|
1358
|
+
if (!existsSync(filePath)) {
|
|
1359
|
+
return { error: `Todo ${displayTodoId(id)} not found` };
|
|
1360
|
+
}
|
|
1361
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
1362
|
+
const result = await withTodoLock(todosDir, normalizedId, ctx, async () => {
|
|
1363
|
+
const existing = await ensureTodoExists(filePath, normalizedId);
|
|
1364
|
+
if (!existing) return { error: `Todo ${displayTodoId(id)} not found` } as const;
|
|
1365
|
+
const assigned = existing.assigned_to_session;
|
|
1366
|
+
if (!assigned) {
|
|
1367
|
+
return existing;
|
|
1368
|
+
}
|
|
1369
|
+
if (assigned !== sessionId && !force) {
|
|
1370
|
+
return {
|
|
1371
|
+
error: `Todo ${displayTodoId(id)} is assigned to session ${assigned}. Use force to release.`,
|
|
1372
|
+
} as const;
|
|
1373
|
+
}
|
|
1374
|
+
existing.assigned_to_session = undefined;
|
|
1087
1375
|
await writeTodoFile(filePath, existing);
|
|
1088
1376
|
return existing;
|
|
1089
1377
|
});
|
|
@@ -1100,7 +1388,11 @@ async function deleteTodo(
|
|
|
1100
1388
|
id: string,
|
|
1101
1389
|
ctx: ExtensionContext,
|
|
1102
1390
|
): Promise<TodoRecord | { error: string }> {
|
|
1103
|
-
const
|
|
1391
|
+
const validated = validateTodoId(id);
|
|
1392
|
+
if ("error" in validated) {
|
|
1393
|
+
return { error: validated.error };
|
|
1394
|
+
}
|
|
1395
|
+
const normalizedId = validated.id;
|
|
1104
1396
|
const filePath = getTodoPath(todosDir, normalizedId);
|
|
1105
1397
|
if (!existsSync(filePath)) {
|
|
1106
1398
|
return { error: `Todo ${displayTodoId(id)} not found` };
|
|
@@ -1128,14 +1420,16 @@ export default function todosExtension(pi: ExtensionAPI) {
|
|
|
1128
1420
|
await garbageCollectTodos(todosDir, settings);
|
|
1129
1421
|
});
|
|
1130
1422
|
|
|
1423
|
+
const todosDirLabel = getTodosDirLabel(process.cwd());
|
|
1424
|
+
|
|
1131
1425
|
pi.registerTool({
|
|
1132
1426
|
name: "todo",
|
|
1133
1427
|
label: "Todo",
|
|
1134
1428
|
description:
|
|
1135
|
-
|
|
1429
|
+
`Manage file-based todos in ${todosDirLabel} (list, list-all, get, create, update, append, delete, claim, release). ` +
|
|
1136
1430
|
"Title is the short summary; body is long-form markdown notes (update replaces, append adds). " +
|
|
1137
1431
|
"Todo ids are shown as TODO-<hex>; id parameters accept TODO-<hex> or the raw hex filename. " +
|
|
1138
|
-
"Close todos when the work is done.
|
|
1432
|
+
"Close todos when the work is done.",
|
|
1139
1433
|
parameters: TodoParams,
|
|
1140
1434
|
|
|
1141
1435
|
async execute(_toolCallId, params, _onUpdate, ctx) {
|
|
@@ -1145,18 +1439,21 @@ export default function todosExtension(pi: ExtensionAPI) {
|
|
|
1145
1439
|
switch (action) {
|
|
1146
1440
|
case "list": {
|
|
1147
1441
|
const todos = await listTodos(todosDir);
|
|
1148
|
-
const { openTodos } =
|
|
1442
|
+
const { assignedTodos, openTodos } = splitTodosByAssignment(todos);
|
|
1443
|
+
const listedTodos = [...assignedTodos, ...openTodos];
|
|
1444
|
+
const currentSessionId = ctx.sessionManager.getSessionId();
|
|
1149
1445
|
return {
|
|
1150
|
-
content: [{ type: "text", text: serializeTodoListForAgent(
|
|
1151
|
-
details: { action: "list", todos:
|
|
1446
|
+
content: [{ type: "text", text: serializeTodoListForAgent(listedTodos) }],
|
|
1447
|
+
details: { action: "list", todos: listedTodos, currentSessionId },
|
|
1152
1448
|
};
|
|
1153
1449
|
}
|
|
1154
1450
|
|
|
1155
1451
|
case "list-all": {
|
|
1156
1452
|
const todos = await listTodos(todosDir);
|
|
1453
|
+
const currentSessionId = ctx.sessionManager.getSessionId();
|
|
1157
1454
|
return {
|
|
1158
1455
|
content: [{ type: "text", text: serializeTodoListForAgent(todos) }],
|
|
1159
|
-
details: { action: "list-all", todos },
|
|
1456
|
+
details: { action: "list-all", todos, currentSessionId },
|
|
1160
1457
|
};
|
|
1161
1458
|
}
|
|
1162
1459
|
|
|
@@ -1167,8 +1464,15 @@ export default function todosExtension(pi: ExtensionAPI) {
|
|
|
1167
1464
|
details: { action: "get", error: "id required" },
|
|
1168
1465
|
};
|
|
1169
1466
|
}
|
|
1170
|
-
const
|
|
1171
|
-
|
|
1467
|
+
const validated = validateTodoId(params.id);
|
|
1468
|
+
if ("error" in validated) {
|
|
1469
|
+
return {
|
|
1470
|
+
content: [{ type: "text", text: validated.error }],
|
|
1471
|
+
details: { action: "get", error: validated.error },
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
const normalizedId = validated.id;
|
|
1475
|
+
const displayId = formatTodoId(normalizedId);
|
|
1172
1476
|
const filePath = getTodoPath(todosDir, normalizedId);
|
|
1173
1477
|
const todo = await ensureTodoExists(filePath, normalizedId);
|
|
1174
1478
|
if (!todo) {
|
|
@@ -1227,8 +1531,15 @@ export default function todosExtension(pi: ExtensionAPI) {
|
|
|
1227
1531
|
details: { action: "update", error: "id required" },
|
|
1228
1532
|
};
|
|
1229
1533
|
}
|
|
1230
|
-
const
|
|
1231
|
-
|
|
1534
|
+
const validated = validateTodoId(params.id);
|
|
1535
|
+
if ("error" in validated) {
|
|
1536
|
+
return {
|
|
1537
|
+
content: [{ type: "text", text: validated.error }],
|
|
1538
|
+
details: { action: "update", error: validated.error },
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
const normalizedId = validated.id;
|
|
1542
|
+
const displayId = formatTodoId(normalizedId);
|
|
1232
1543
|
const filePath = getTodoPath(todosDir, normalizedId);
|
|
1233
1544
|
if (!existsSync(filePath)) {
|
|
1234
1545
|
return {
|
|
@@ -1246,6 +1557,7 @@ export default function todosExtension(pi: ExtensionAPI) {
|
|
|
1246
1557
|
if (params.tags !== undefined) existing.tags = params.tags;
|
|
1247
1558
|
if (params.body !== undefined) existing.body = params.body;
|
|
1248
1559
|
if (!existing.created_at) existing.created_at = new Date().toISOString();
|
|
1560
|
+
clearAssignmentIfClosed(existing);
|
|
1249
1561
|
|
|
1250
1562
|
await writeTodoFile(filePath, existing);
|
|
1251
1563
|
return existing;
|
|
@@ -1272,8 +1584,15 @@ export default function todosExtension(pi: ExtensionAPI) {
|
|
|
1272
1584
|
details: { action: "append", error: "id required" },
|
|
1273
1585
|
};
|
|
1274
1586
|
}
|
|
1275
|
-
const
|
|
1276
|
-
|
|
1587
|
+
const validated = validateTodoId(params.id);
|
|
1588
|
+
if ("error" in validated) {
|
|
1589
|
+
return {
|
|
1590
|
+
content: [{ type: "text", text: validated.error }],
|
|
1591
|
+
details: { action: "append", error: validated.error },
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
const normalizedId = validated.id;
|
|
1595
|
+
const displayId = formatTodoId(normalizedId);
|
|
1277
1596
|
const filePath = getTodoPath(todosDir, normalizedId);
|
|
1278
1597
|
if (!existsSync(filePath)) {
|
|
1279
1598
|
return {
|
|
@@ -1305,6 +1624,58 @@ export default function todosExtension(pi: ExtensionAPI) {
|
|
|
1305
1624
|
};
|
|
1306
1625
|
}
|
|
1307
1626
|
|
|
1627
|
+
case "claim": {
|
|
1628
|
+
if (!params.id) {
|
|
1629
|
+
return {
|
|
1630
|
+
content: [{ type: "text", text: "Error: id required" }],
|
|
1631
|
+
details: { action: "claim", error: "id required" },
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
const result = await claimTodoAssignment(
|
|
1635
|
+
todosDir,
|
|
1636
|
+
params.id,
|
|
1637
|
+
ctx,
|
|
1638
|
+
Boolean(params.force),
|
|
1639
|
+
);
|
|
1640
|
+
if (typeof result === "object" && "error" in result) {
|
|
1641
|
+
return {
|
|
1642
|
+
content: [{ type: "text", text: result.error }],
|
|
1643
|
+
details: { action: "claim", error: result.error },
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
const updatedTodo = result as TodoRecord;
|
|
1647
|
+
return {
|
|
1648
|
+
content: [{ type: "text", text: serializeTodoForAgent(updatedTodo) }],
|
|
1649
|
+
details: { action: "claim", todo: updatedTodo },
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
case "release": {
|
|
1654
|
+
if (!params.id) {
|
|
1655
|
+
return {
|
|
1656
|
+
content: [{ type: "text", text: "Error: id required" }],
|
|
1657
|
+
details: { action: "release", error: "id required" },
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
const result = await releaseTodoAssignment(
|
|
1661
|
+
todosDir,
|
|
1662
|
+
params.id,
|
|
1663
|
+
ctx,
|
|
1664
|
+
Boolean(params.force),
|
|
1665
|
+
);
|
|
1666
|
+
if (typeof result === "object" && "error" in result) {
|
|
1667
|
+
return {
|
|
1668
|
+
content: [{ type: "text", text: result.error }],
|
|
1669
|
+
details: { action: "release", error: result.error },
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
const updatedTodo = result as TodoRecord;
|
|
1673
|
+
return {
|
|
1674
|
+
content: [{ type: "text", text: serializeTodoForAgent(updatedTodo) }],
|
|
1675
|
+
details: { action: "release", todo: updatedTodo },
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1308
1679
|
case "delete": {
|
|
1309
1680
|
if (!params.id) {
|
|
1310
1681
|
return {
|
|
@@ -1313,8 +1684,14 @@ export default function todosExtension(pi: ExtensionAPI) {
|
|
|
1313
1684
|
};
|
|
1314
1685
|
}
|
|
1315
1686
|
|
|
1316
|
-
const
|
|
1317
|
-
|
|
1687
|
+
const validated = validateTodoId(params.id);
|
|
1688
|
+
if ("error" in validated) {
|
|
1689
|
+
return {
|
|
1690
|
+
content: [{ type: "text", text: validated.error }],
|
|
1691
|
+
details: { action: "delete", error: validated.error },
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
const result = await deleteTodo(todosDir, validated.id, ctx);
|
|
1318
1695
|
if (typeof result === "object" && "error" in result) {
|
|
1319
1696
|
return {
|
|
1320
1697
|
content: [{ type: "text", text: result.error }],
|
|
@@ -1361,9 +1738,9 @@ export default function todosExtension(pi: ExtensionAPI) {
|
|
|
1361
1738
|
}
|
|
1362
1739
|
|
|
1363
1740
|
if (details.action === "list" || details.action === "list-all") {
|
|
1364
|
-
let text = renderTodoList(theme, details.todos, expanded);
|
|
1741
|
+
let text = renderTodoList(theme, details.todos, expanded, details.currentSessionId);
|
|
1365
1742
|
if (!expanded) {
|
|
1366
|
-
const { closedTodos } =
|
|
1743
|
+
const { closedTodos } = splitTodosByAssignment(details.todos);
|
|
1367
1744
|
if (closedTodos.length) {
|
|
1368
1745
|
text = appendExpandHint(theme, text);
|
|
1369
1746
|
}
|
|
@@ -1386,7 +1763,11 @@ export default function todosExtension(pi: ExtensionAPI) {
|
|
|
1386
1763
|
? "Appended to"
|
|
1387
1764
|
: details.action === "delete"
|
|
1388
1765
|
? "Deleted"
|
|
1389
|
-
:
|
|
1766
|
+
: details.action === "claim"
|
|
1767
|
+
? "Claimed"
|
|
1768
|
+
: details.action === "release"
|
|
1769
|
+
? "Released"
|
|
1770
|
+
: null;
|
|
1390
1771
|
if (actionLabel) {
|
|
1391
1772
|
const lines = text.split("\n");
|
|
1392
1773
|
lines[0] = theme.fg("success", "✓ ") + theme.fg("muted", `${actionLabel} `) + lines[0];
|
|
@@ -1419,6 +1800,7 @@ export default function todosExtension(pi: ExtensionAPI) {
|
|
|
1419
1800
|
handler: async (args, ctx) => {
|
|
1420
1801
|
const todosDir = getTodosDir(ctx.cwd);
|
|
1421
1802
|
const todos = await listTodos(todosDir);
|
|
1803
|
+
const currentSessionId = ctx.sessionManager.getSessionId();
|
|
1422
1804
|
const searchTerm = (args ?? "").trim();
|
|
1423
1805
|
|
|
1424
1806
|
if (!ctx.hasUI) {
|
|
@@ -1432,17 +1814,50 @@ export default function todosExtension(pi: ExtensionAPI) {
|
|
|
1432
1814
|
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
1433
1815
|
rootTui = tui;
|
|
1434
1816
|
let selector: TodoSelectorComponent | null = null;
|
|
1817
|
+
let actionMenu: TodoActionMenuComponent | null = null;
|
|
1818
|
+
let deleteConfirm: TodoDeleteConfirmComponent | null = null;
|
|
1819
|
+
let activeComponent:
|
|
1820
|
+
| {
|
|
1821
|
+
render: (width: number) => string[];
|
|
1822
|
+
invalidate: () => void;
|
|
1823
|
+
handleInput?: (data: string) => void;
|
|
1824
|
+
focused?: boolean;
|
|
1825
|
+
}
|
|
1826
|
+
| null = null;
|
|
1827
|
+
let wrapperFocused = false;
|
|
1828
|
+
|
|
1829
|
+
const setActiveComponent = (
|
|
1830
|
+
component:
|
|
1831
|
+
| {
|
|
1832
|
+
render: (width: number) => string[];
|
|
1833
|
+
invalidate: () => void;
|
|
1834
|
+
handleInput?: (data: string) => void;
|
|
1835
|
+
focused?: boolean;
|
|
1836
|
+
}
|
|
1837
|
+
| null,
|
|
1838
|
+
) => {
|
|
1839
|
+
if (activeComponent && "focused" in activeComponent) {
|
|
1840
|
+
activeComponent.focused = false;
|
|
1841
|
+
}
|
|
1842
|
+
activeComponent = component;
|
|
1843
|
+
if (activeComponent && "focused" in activeComponent) {
|
|
1844
|
+
activeComponent.focused = wrapperFocused;
|
|
1845
|
+
}
|
|
1846
|
+
tui.requestRender();
|
|
1847
|
+
};
|
|
1435
1848
|
|
|
1436
|
-
const
|
|
1849
|
+
const copyTodoPathToClipboard = (todoId: string) => {
|
|
1437
1850
|
const filePath = getTodoPath(todosDir, todoId);
|
|
1438
1851
|
const relativePath = path.relative(ctx.cwd, filePath);
|
|
1439
1852
|
const displayPath =
|
|
1440
1853
|
relativePath && !relativePath.startsWith("..") ? relativePath : filePath;
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1854
|
+
try {
|
|
1855
|
+
copyToClipboard(displayPath);
|
|
1856
|
+
ctx.ui.notify(`Copied ${displayPath} to clipboard`, "info");
|
|
1857
|
+
} catch (error) {
|
|
1858
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1859
|
+
ctx.ui.notify(message, "error");
|
|
1860
|
+
}
|
|
1446
1861
|
};
|
|
1447
1862
|
|
|
1448
1863
|
const resolveTodoRecord = async (todo: TodoFrontMatter): Promise<TodoRecord | null> => {
|
|
@@ -1455,7 +1870,7 @@ export default function todosExtension(pi: ExtensionAPI) {
|
|
|
1455
1870
|
return record;
|
|
1456
1871
|
};
|
|
1457
1872
|
|
|
1458
|
-
const openTodoOverlay = async (record: TodoRecord) => {
|
|
1873
|
+
const openTodoOverlay = async (record: TodoRecord): Promise<TodoOverlayAction> => {
|
|
1459
1874
|
const action = await ctx.ui.custom<TodoOverlayAction>(
|
|
1460
1875
|
(overlayTui, overlayTheme, _overlayKb, overlayDone) =>
|
|
1461
1876
|
new TodoDetailOverlayComponent(overlayTui, overlayTheme, record, overlayDone),
|
|
@@ -1465,65 +1880,62 @@ export default function todosExtension(pi: ExtensionAPI) {
|
|
|
1465
1880
|
},
|
|
1466
1881
|
);
|
|
1467
1882
|
|
|
1468
|
-
|
|
1469
|
-
if (action === "actions") {
|
|
1470
|
-
await showActionMenu(record);
|
|
1471
|
-
return;
|
|
1472
|
-
}
|
|
1473
|
-
await applyTodoAction(record, action);
|
|
1883
|
+
return action ?? "back";
|
|
1474
1884
|
};
|
|
1475
1885
|
|
|
1476
|
-
const applyTodoAction = async (
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
return;
|
|
1481
|
-
}
|
|
1886
|
+
const applyTodoAction = async (
|
|
1887
|
+
record: TodoRecord,
|
|
1888
|
+
action: TodoMenuAction,
|
|
1889
|
+
): Promise<"stay" | "exit"> => {
|
|
1482
1890
|
if (action === "refine") {
|
|
1483
1891
|
const title = record.title || "(untitled)";
|
|
1484
1892
|
nextPrompt = buildRefinePrompt(record.id, title);
|
|
1485
1893
|
done();
|
|
1486
|
-
return;
|
|
1894
|
+
return "exit";
|
|
1487
1895
|
}
|
|
1488
1896
|
if (action === "work") {
|
|
1489
1897
|
const title = record.title || "(untitled)";
|
|
1490
1898
|
nextPrompt = `work on todo ${formatTodoId(record.id)} "${title}"`;
|
|
1491
1899
|
done();
|
|
1492
|
-
return;
|
|
1900
|
+
return "exit";
|
|
1493
1901
|
}
|
|
1494
1902
|
if (action === "view") {
|
|
1495
|
-
|
|
1496
|
-
return;
|
|
1903
|
+
return "stay";
|
|
1497
1904
|
}
|
|
1498
|
-
if (action === "copy
|
|
1499
|
-
|
|
1500
|
-
return;
|
|
1905
|
+
if (action === "copy") {
|
|
1906
|
+
copyTodoPathToClipboard(record.id);
|
|
1907
|
+
return "stay";
|
|
1501
1908
|
}
|
|
1502
1909
|
|
|
1503
|
-
if (action === "
|
|
1504
|
-
const
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
if (!ok) {
|
|
1509
|
-
return;
|
|
1910
|
+
if (action === "release") {
|
|
1911
|
+
const result = await releaseTodoAssignment(todosDir, record.id, ctx, true);
|
|
1912
|
+
if ("error" in result) {
|
|
1913
|
+
ctx.ui.notify(result.error, "error");
|
|
1914
|
+
return "stay";
|
|
1510
1915
|
}
|
|
1916
|
+
const updatedTodos = await listTodos(todosDir);
|
|
1917
|
+
selector?.setTodos(updatedTodos);
|
|
1918
|
+
ctx.ui.notify(`Released todo ${formatTodoId(record.id)}`, "info");
|
|
1919
|
+
return "stay";
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
if (action === "delete") {
|
|
1511
1923
|
const result = await deleteTodo(todosDir, record.id, ctx);
|
|
1512
1924
|
if ("error" in result) {
|
|
1513
1925
|
ctx.ui.notify(result.error, "error");
|
|
1514
|
-
return;
|
|
1926
|
+
return "stay";
|
|
1515
1927
|
}
|
|
1516
1928
|
const updatedTodos = await listTodos(todosDir);
|
|
1517
1929
|
selector?.setTodos(updatedTodos);
|
|
1518
1930
|
ctx.ui.notify(`Deleted todo ${formatTodoId(record.id)}`, "info");
|
|
1519
|
-
return;
|
|
1931
|
+
return "stay";
|
|
1520
1932
|
}
|
|
1521
1933
|
|
|
1522
1934
|
const nextStatus = action === "close" ? "closed" : "open";
|
|
1523
1935
|
const result = await updateTodoStatus(todosDir, record.id, nextStatus, ctx);
|
|
1524
1936
|
if ("error" in result) {
|
|
1525
1937
|
ctx.ui.notify(result.error, "error");
|
|
1526
|
-
return;
|
|
1938
|
+
return "stay";
|
|
1527
1939
|
}
|
|
1528
1940
|
|
|
1529
1941
|
const updatedTodos = await listTodos(todosDir);
|
|
@@ -1532,87 +1944,58 @@ export default function todosExtension(pi: ExtensionAPI) {
|
|
|
1532
1944
|
`${action === "close" ? "Closed" : "Reopened"} todo ${formatTodoId(record.id)}`,
|
|
1533
1945
|
"info",
|
|
1534
1946
|
);
|
|
1947
|
+
return "stay";
|
|
1948
|
+
};
|
|
1949
|
+
|
|
1950
|
+
const handleActionSelection = async (record: TodoRecord, action: TodoMenuAction) => {
|
|
1951
|
+
if (action === "view") {
|
|
1952
|
+
const overlayAction = await openTodoOverlay(record);
|
|
1953
|
+
if (overlayAction === "work") {
|
|
1954
|
+
await applyTodoAction(record, "work");
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
if (actionMenu) {
|
|
1958
|
+
setActiveComponent(actionMenu);
|
|
1959
|
+
}
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
if (action === "delete") {
|
|
1964
|
+
const message = `Delete todo ${formatTodoId(record.id)}? This cannot be undone.`;
|
|
1965
|
+
deleteConfirm = new TodoDeleteConfirmComponent(theme, message, (confirmed) => {
|
|
1966
|
+
if (!confirmed) {
|
|
1967
|
+
setActiveComponent(actionMenu);
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
void (async () => {
|
|
1971
|
+
await applyTodoAction(record, "delete");
|
|
1972
|
+
setActiveComponent(selector);
|
|
1973
|
+
})();
|
|
1974
|
+
});
|
|
1975
|
+
setActiveComponent(deleteConfirm);
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
const result = await applyTodoAction(record, action);
|
|
1980
|
+
if (result === "stay") {
|
|
1981
|
+
setActiveComponent(selector);
|
|
1982
|
+
}
|
|
1535
1983
|
};
|
|
1536
1984
|
|
|
1537
1985
|
const showActionMenu = async (todo: TodoFrontMatter | TodoRecord) => {
|
|
1538
1986
|
const record = "body" in todo ? todo : await resolveTodoRecord(todo);
|
|
1539
1987
|
if (!record) return;
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
{ value: "reopen", label: "reopen", description: "Reopen todo" },
|
|
1546
|
-
{ value: "copy-path", label: "copy", description: "Copy todo path into prompt" },
|
|
1547
|
-
{ value: "delete", label: "delete", description: "Delete todo" },
|
|
1548
|
-
];
|
|
1549
|
-
const title = record.title || "(untitled)";
|
|
1550
|
-
const selection = await ctx.ui.custom<TodoMenuAction | null>(
|
|
1551
|
-
(overlayTui, overlayTheme, _overlayKb, overlayDone) => {
|
|
1552
|
-
const container = new Container();
|
|
1553
|
-
container.addChild(
|
|
1554
|
-
new Text(
|
|
1555
|
-
overlayTheme.fg(
|
|
1556
|
-
"accent",
|
|
1557
|
-
overlayTheme.bold(`Actions for ${formatTodoId(record.id)} "${title}"`),
|
|
1558
|
-
),
|
|
1559
|
-
),
|
|
1560
|
-
);
|
|
1561
|
-
container.addChild(new Spacer(1));
|
|
1562
|
-
|
|
1563
|
-
const selectList = new SelectList(options, options.length, {
|
|
1564
|
-
selectedPrefix: (text) => overlayTheme.fg("accent", text),
|
|
1565
|
-
selectedText: (text) => overlayTheme.fg("accent", text),
|
|
1566
|
-
description: (text) => overlayTheme.fg("muted", text),
|
|
1567
|
-
scrollInfo: (text) => overlayTheme.fg("dim", text),
|
|
1568
|
-
noMatch: (text) => overlayTheme.fg("warning", text),
|
|
1569
|
-
});
|
|
1570
|
-
|
|
1571
|
-
selectList.onSelect = (item) => overlayDone(item.value as TodoMenuAction);
|
|
1572
|
-
selectList.onCancel = () => overlayDone(null);
|
|
1573
|
-
|
|
1574
|
-
container.addChild(selectList);
|
|
1575
|
-
container.addChild(new Spacer(1));
|
|
1576
|
-
container.addChild(
|
|
1577
|
-
new Text(overlayTheme.fg("dim", "Press enter to confirm or esc to cancel")),
|
|
1578
|
-
);
|
|
1579
|
-
|
|
1580
|
-
return {
|
|
1581
|
-
render(width: number) {
|
|
1582
|
-
const innerWidth = Math.max(10, width - 2);
|
|
1583
|
-
const contentLines = container.render(innerWidth);
|
|
1584
|
-
const borderColor = (text: string) => overlayTheme.fg("accent", text);
|
|
1585
|
-
const top = borderColor(`┌${"─".repeat(innerWidth)}┐`);
|
|
1586
|
-
const bottom = borderColor(`└${"─".repeat(innerWidth)}┘`);
|
|
1587
|
-
const framed = contentLines.map((line) => {
|
|
1588
|
-
const truncated = truncateToWidth(line, innerWidth);
|
|
1589
|
-
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
1590
|
-
return (
|
|
1591
|
-
borderColor("│") + truncated + " ".repeat(padding) + borderColor("│")
|
|
1592
|
-
);
|
|
1593
|
-
});
|
|
1594
|
-
return [top, ...framed, bottom].map((line) => truncateToWidth(line, width));
|
|
1595
|
-
},
|
|
1596
|
-
invalidate() {
|
|
1597
|
-
container.invalidate();
|
|
1598
|
-
},
|
|
1599
|
-
handleInput(data: string) {
|
|
1600
|
-
selectList.handleInput(data);
|
|
1601
|
-
overlayTui.requestRender();
|
|
1602
|
-
},
|
|
1603
|
-
};
|
|
1988
|
+
actionMenu = new TodoActionMenuComponent(
|
|
1989
|
+
theme,
|
|
1990
|
+
record,
|
|
1991
|
+
(action) => {
|
|
1992
|
+
void handleActionSelection(record, action);
|
|
1604
1993
|
},
|
|
1605
|
-
{
|
|
1606
|
-
|
|
1607
|
-
overlayOptions: { width: "70%", maxHeight: "60%", anchor: "center" },
|
|
1994
|
+
() => {
|
|
1995
|
+
setActiveComponent(selector);
|
|
1608
1996
|
},
|
|
1609
1997
|
);
|
|
1610
|
-
|
|
1611
|
-
if (!selection) {
|
|
1612
|
-
tui.requestRender();
|
|
1613
|
-
return;
|
|
1614
|
-
}
|
|
1615
|
-
await applyTodoAction(record, selection);
|
|
1998
|
+
setActiveComponent(actionMenu);
|
|
1616
1999
|
};
|
|
1617
2000
|
|
|
1618
2001
|
const handleSelect = async (todo: TodoFrontMatter) => {
|
|
@@ -1628,6 +2011,7 @@ export default function todosExtension(pi: ExtensionAPI) {
|
|
|
1628
2011
|
},
|
|
1629
2012
|
() => done(),
|
|
1630
2013
|
searchTerm || undefined,
|
|
2014
|
+
currentSessionId,
|
|
1631
2015
|
(todo, action) => {
|
|
1632
2016
|
const title = todo.title || "(untitled)";
|
|
1633
2017
|
nextPrompt =
|
|
@@ -1638,7 +2022,30 @@ export default function todosExtension(pi: ExtensionAPI) {
|
|
|
1638
2022
|
},
|
|
1639
2023
|
);
|
|
1640
2024
|
|
|
1641
|
-
|
|
2025
|
+
setActiveComponent(selector);
|
|
2026
|
+
|
|
2027
|
+
const rootComponent = {
|
|
2028
|
+
get focused() {
|
|
2029
|
+
return wrapperFocused;
|
|
2030
|
+
},
|
|
2031
|
+
set focused(value: boolean) {
|
|
2032
|
+
wrapperFocused = value;
|
|
2033
|
+
if (activeComponent && "focused" in activeComponent) {
|
|
2034
|
+
activeComponent.focused = value;
|
|
2035
|
+
}
|
|
2036
|
+
},
|
|
2037
|
+
render(width: number) {
|
|
2038
|
+
return activeComponent ? activeComponent.render(width) : [];
|
|
2039
|
+
},
|
|
2040
|
+
invalidate() {
|
|
2041
|
+
activeComponent?.invalidate();
|
|
2042
|
+
},
|
|
2043
|
+
handleInput(data: string) {
|
|
2044
|
+
activeComponent?.handleInput?.(data);
|
|
2045
|
+
},
|
|
2046
|
+
};
|
|
2047
|
+
|
|
2048
|
+
return rootComponent;
|
|
1642
2049
|
});
|
|
1643
2050
|
|
|
1644
2051
|
if (nextPrompt) {
|