@docyrus/docyrus 0.0.34 → 0.0.35
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 +25 -0
- package/agent-loader.js +3 -2
- package/agent-loader.js.map +2 -2
- package/main.js +82162 -46093
- package/main.js.map +4 -4
- package/package.json +12 -3
- package/resources/chrome-tools/browser-content.js +46 -46
- package/resources/chrome-tools/browser-cookies.js +16 -16
- package/resources/chrome-tools/browser-eval.js +27 -27
- package/resources/chrome-tools/browser-hn-scraper.js +1 -1
- package/resources/chrome-tools/browser-nav.js +23 -23
- package/resources/chrome-tools/browser-pick.js +127 -127
- package/resources/chrome-tools/browser-screenshot.js +10 -10
- package/resources/chrome-tools/browser-start.js +38 -38
- package/resources/pi-agent/extensions/answer.ts +392 -384
- package/resources/pi-agent/extensions/context.ts +415 -415
- package/resources/pi-agent/extensions/control.ts +1287 -1287
- package/resources/pi-agent/extensions/diff.ts +171 -171
- package/resources/pi-agent/extensions/files.ts +155 -155
- package/resources/pi-agent/extensions/knowledge.ts +664 -0
- package/resources/pi-agent/extensions/loop.ts +375 -375
- package/resources/pi-agent/extensions/pi-bash-live-view/index.ts +1 -1
- package/resources/pi-agent/extensions/pi-bash-live-view/package.json +22 -22
- package/resources/pi-agent/extensions/pi-bash-live-view/pty-execute.ts +2 -2
- package/resources/pi-agent/extensions/pi-bash-live-view/pty-session.ts +2 -2
- package/resources/pi-agent/extensions/pi-bash-live-view/spawn-helper.ts +2 -2
- package/resources/pi-agent/extensions/pi-bash-live-view/terminal-emulator.ts +18 -18
- package/resources/pi-agent/extensions/pi-bash-live-view/truncate.ts +1 -1
- package/resources/pi-agent/extensions/pi-bash-live-view/widget.ts +4 -4
- package/resources/pi-agent/extensions/pi-custom-compaction/package.json +4 -4
- package/resources/pi-agent/extensions/pi-mcp-adapter/app-bridge.bundle.js +14 -14
- package/resources/pi-agent/extensions/pi-mcp-adapter/commands.ts +6 -6
- package/resources/pi-agent/extensions/pi-mcp-adapter/config.ts +9 -9
- package/resources/pi-agent/extensions/pi-mcp-adapter/consent-manager.ts +4 -4
- package/resources/pi-agent/extensions/pi-mcp-adapter/direct-tools.ts +13 -13
- package/resources/pi-agent/extensions/pi-mcp-adapter/glimpse-ui.ts +5 -5
- package/resources/pi-agent/extensions/pi-mcp-adapter/host-html-template.ts +13 -13
- package/resources/pi-agent/extensions/pi-mcp-adapter/index.ts +14 -14
- package/resources/pi-agent/extensions/pi-mcp-adapter/init.ts +17 -17
- package/resources/pi-agent/extensions/pi-mcp-adapter/lifecycle.ts +2 -2
- package/resources/pi-agent/extensions/pi-mcp-adapter/logger.ts +2 -2
- package/resources/pi-agent/extensions/pi-mcp-adapter/mcp-panel.ts +17 -17
- package/resources/pi-agent/extensions/pi-mcp-adapter/metadata-cache.ts +9 -9
- package/resources/pi-agent/extensions/pi-mcp-adapter/npx-resolver.ts +35 -35
- package/resources/pi-agent/extensions/pi-mcp-adapter/oauth-handler.ts +1 -1
- package/resources/pi-agent/extensions/pi-mcp-adapter/proxy-modes.ts +12 -12
- package/resources/pi-agent/extensions/pi-mcp-adapter/server-manager.ts +6 -6
- package/resources/pi-agent/extensions/pi-mcp-adapter/tool-metadata.ts +4 -4
- package/resources/pi-agent/extensions/pi-mcp-adapter/types.ts +2 -2
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-resource-handler.ts +6 -6
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-server.ts +17 -17
- package/resources/pi-agent/extensions/pi-mcp-adapter/ui-session.ts +22 -22
- package/resources/pi-agent/extensions/pi-mcp-adapter/utils.ts +2 -2
- package/resources/pi-agent/extensions/prompt-editor.ts +900 -900
- package/resources/pi-agent/extensions/prompt-url-widget.ts +122 -122
- package/resources/pi-agent/extensions/redraws.ts +14 -14
- package/resources/pi-agent/extensions/review.ts +1533 -1533
- package/resources/pi-agent/extensions/todos.ts +1735 -1735
- package/resources/pi-agent/extensions/tps.ts +40 -40
- package/resources/pi-agent/extensions/whimsical.ts +3 -3
- package/resources/pi-agent/prompts/agent-system.md +2 -0
- package/resources/pi-agent/prompts/coder-system.md +2 -0
- package/server-loader.js +82 -1
- package/server-loader.js.map +3 -3
- package/tui.mjs +2 -0
- 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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
150
|
+
return `${TODO_ID_PREFIX}${id}`;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
function normalizeTodoId(id: string): string {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
173
|
+
return formatTodoId(normalizeTodoId(id));
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
function isTodoClosed(status: string): boolean {
|
|
177
|
-
|
|
177
|
+
return ["closed", "done"].includes(status.toLowerCase());
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
function clearAssignmentIfClosed(todo: TodoFrontMatter): void {
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
725
|
+
return path.join(todosDir, TODO_SETTINGS_NAME);
|
|
726
726
|
}
|
|
727
727
|
|
|
728
728
|
function normalizeTodoSettings(raw: Partial<TodoSettings>): TodoSettings {
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
739
|
-
|
|
738
|
+
const settingsPath = getTodoSettingsPath(todosDir);
|
|
739
|
+
let data: Partial<TodoSettings> = {};
|
|
740
740
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
748
|
+
return normalizeTodoSettings(data);
|
|
749
749
|
}
|
|
750
750
|
|
|
751
751
|
async function garbageCollectTodos(todosDir: string, settings: TodoSettings): Promise<void> {
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
|
|
786
|
+
return path.join(todosDir, `${id}.md`);
|
|
787
787
|
}
|
|
788
788
|
|
|
789
789
|
function getLockPath(todosDir: string, id: string): string {
|
|
790
|
-
|
|
790
|
+
return path.join(todosDir, `${id}.lock`);
|
|
791
791
|
}
|
|
792
792
|
|
|
793
793
|
function parseFrontMatter(text: string, idFallback: string): TodoFrontMatter {
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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
|
-
|
|
918
|
+
await fs.mkdir(todosDir, { recursive: true });
|
|
919
919
|
}
|
|
920
920
|
|
|
921
921
|
async function readTodoFile(filePath: string, idFallback: string): Promise<TodoRecord> {
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
|
|
927
|
+
await fs.writeFile(filePath, serializeTodo(todo), "utf8");
|
|
928
928
|
}
|
|
929
929
|
|
|
930
930
|
async function generateTodoId(todosDir: string): Promise<string> {
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
-
|
|
950
|
-
|
|
951
|
-
|
|
949
|
+
todosDir: string,
|
|
950
|
+
id: string,
|
|
951
|
+
ctx: ExtensionContext,
|
|
952
952
|
): Promise<(() => Promise<void>) | { error: string }> {
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
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
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1004
|
+
todosDir: string,
|
|
1005
|
+
id: string,
|
|
1006
|
+
ctx: ExtensionContext,
|
|
1007
|
+
fn: () => Promise<T>,
|
|
1008
1008
|
): Promise<T | { error: string }> {
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
-
|
|
1048
|
+
return sortTodos(todos);
|
|
1049
1049
|
}
|
|
1050
1050
|
|
|
1051
1051
|
function listTodosSync(todosDir: string): TodoFrontMatter[] {
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
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
|
-
|
|
1081
|
+
return sortTodos(todos);
|
|
1082
1082
|
}
|
|
1083
1083
|
|
|
1084
1084
|
function getTodoTitle(todo: TodoFrontMatter): string {
|
|
1085
|
-
|
|
1085
|
+
return todo.title || "(untitled)";
|
|
1086
1086
|
}
|
|
1087
1087
|
|
|
1088
1088
|
function getTodoStatus(todo: TodoFrontMatter): string {
|
|
1089
|
-
|
|
1089
|
+
return todo.status || "open";
|
|
1090
1090
|
}
|
|
1091
1091
|
|
|
1092
1092
|
function formatAssignmentSuffix(todo: TodoFrontMatter): string {
|
|
1093
|
-
|
|
1093
|
+
return todo.assigned_to_session ? ` (assigned: ${todo.assigned_to_session})` : "";
|
|
1094
1094
|
}
|
|
1095
1095
|
|
|
1096
1096
|
function renderAssignmentSuffix(
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1097
|
+
theme: Theme,
|
|
1098
|
+
todo: TodoFrontMatter,
|
|
1099
|
+
currentSessionId?: string,
|
|
1100
1100
|
): string {
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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
|
-
|
|
1110
|
-
|
|
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
|
-
|
|
1115
|
-
|
|
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
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
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
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
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
|
-
|
|
1167
|
-
|
|
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
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
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
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1199
|
+
theme: Theme,
|
|
1200
|
+
todos: TodoFrontMatter[],
|
|
1201
|
+
expanded: boolean,
|
|
1202
|
+
currentSessionId?: string,
|
|
1203
1203
|
): string {
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1265
|
-
|
|
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
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1276
|
+
todosDir: string,
|
|
1277
|
+
id: string,
|
|
1278
|
+
status: string,
|
|
1279
|
+
ctx: ExtensionContext,
|
|
1280
1280
|
): Promise<TodoRecord | { error: string }> {
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
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
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1308
|
+
todosDir: string,
|
|
1309
|
+
id: string,
|
|
1310
|
+
ctx: ExtensionContext,
|
|
1311
|
+
force = false,
|
|
1312
1312
|
): Promise<TodoRecord | { error: string }> {
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
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
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1350
|
+
todosDir: string,
|
|
1351
|
+
id: string,
|
|
1352
|
+
ctx: ExtensionContext,
|
|
1353
|
+
force = false,
|
|
1354
1354
|
): Promise<TodoRecord | { error: string }> {
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
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
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1390
|
+
todosDir: string,
|
|
1391
|
+
id: string,
|
|
1392
|
+
ctx: ExtensionContext,
|
|
1393
1393
|
): Promise<TodoRecord | { error: string }> {
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
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
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
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
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
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
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
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
|
-
|
|
1830
|
+
let wrapperFocused = false;
|
|
1831
1831
|
|
|
1832
|
-
|
|
1833
|
-
|
|
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
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
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
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
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
|
}
|