@zhushanwen/pi-todo 0.1.2 → 0.1.4
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/package.json +13 -2
- package/src/index.ts +317 -315
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-todo",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "AI-driven todo list for Pi — stateful task management with session persistence and /todos command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -22,7 +22,18 @@
|
|
|
22
22
|
"index.ts"
|
|
23
23
|
],
|
|
24
24
|
"peerDependencies": {
|
|
25
|
-
"@mariozechner/pi-coding-agent": "*"
|
|
25
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
26
|
+
"@earendil-works/pi-tui": "*",
|
|
27
|
+
"@earendil-works/pi-ai": "*",
|
|
28
|
+
"@sinclair/typebox": "*"
|
|
29
|
+
},
|
|
30
|
+
"peerDependenciesMeta": {
|
|
31
|
+
"@earendil-works/pi-tui": {
|
|
32
|
+
"optional": true
|
|
33
|
+
},
|
|
34
|
+
"@earendil-works/pi-ai": {
|
|
35
|
+
"optional": true
|
|
36
|
+
}
|
|
26
37
|
},
|
|
27
38
|
"scripts": {
|
|
28
39
|
"typecheck": "npx tsc --noEmit"
|
package/src/index.ts
CHANGED
|
@@ -41,12 +41,12 @@ const VALID_STATUSES = ["pending", "in_progress", "completed"] as const;
|
|
|
41
41
|
|
|
42
42
|
const TodoParams = Type.Object({
|
|
43
43
|
action: StringEnum(["list", "add", "update", "delete", "clear"] as const),
|
|
44
|
-
text: Type.Optional(Type.String({ description: "Todo
|
|
45
|
-
id: Type.Optional(Type.Number({ description: "Todo ID
|
|
46
|
-
texts: Type.Optional(Type.Array(Type.String(), { description: "Todo
|
|
47
|
-
ids: Type.Optional(Type.Array(Type.Number(), { description: "Todo ID
|
|
44
|
+
text: Type.Optional(Type.String({ description: "Todo text (for update action)" })),
|
|
45
|
+
id: Type.Optional(Type.Number({ description: "Todo ID (for update action)" })),
|
|
46
|
+
texts: Type.Optional(Type.Array(Type.String(), { description: "Todo text list (for add action)" })),
|
|
47
|
+
ids: Type.Optional(Type.Array(Type.Number(), { description: "Todo ID list (for delete action)" })),
|
|
48
48
|
status: Type.Optional(
|
|
49
|
-
StringEnum(VALID_STATUSES, { description: "
|
|
49
|
+
StringEnum(VALID_STATUSES, { description: "Target status (for update action)" }),
|
|
50
50
|
),
|
|
51
51
|
});
|
|
52
52
|
|
|
@@ -93,11 +93,11 @@ class TodoListComponent {
|
|
|
93
93
|
lines.push("");
|
|
94
94
|
|
|
95
95
|
if (this.todos.length === 0) {
|
|
96
|
-
lines.push(truncateToWidth(` ${th.fg("dim", "
|
|
96
|
+
lines.push(truncateToWidth(` ${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, width));
|
|
97
97
|
} else {
|
|
98
98
|
const completed = this.todos.filter((t) => t.status === "completed").length;
|
|
99
99
|
const total = this.todos.length;
|
|
100
|
-
lines.push(truncateToWidth(` ${th.fg("muted", `${completed}/${total}
|
|
100
|
+
lines.push(truncateToWidth(` ${th.fg("muted", `${completed}/${total} completed`)}`, width));
|
|
101
101
|
lines.push("");
|
|
102
102
|
|
|
103
103
|
for (const todo of this.todos) {
|
|
@@ -114,7 +114,7 @@ class TodoListComponent {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
lines.push("");
|
|
117
|
-
lines.push(truncateToWidth(` ${th.fg("dim", "
|
|
117
|
+
lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
|
|
118
118
|
lines.push("");
|
|
119
119
|
|
|
120
120
|
this.cachedWidth = width;
|
|
@@ -186,45 +186,13 @@ function renderWidgetLines(todoList: Todo[], th: Theme): string[] {
|
|
|
186
186
|
return lines;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
/** 更新状态栏和 widget */
|
|
190
|
-
function refreshDisplay(ctx: ExtensionContext): void {
|
|
191
|
-
const statusText = renderStatusText(todos, ctx.ui.theme);
|
|
192
|
-
ctx.ui.setStatus("todo", statusText || undefined);
|
|
193
|
-
|
|
194
|
-
if (todos.length === 0) {
|
|
195
|
-
ctx.ui.setWidget("todo", undefined);
|
|
196
|
-
} else {
|
|
197
|
-
ctx.ui.setWidget("todo", renderWidgetLines(todos, ctx.ui.theme));
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// ── 模块级状态 ───────────────────────────────────────
|
|
202
|
-
|
|
203
|
-
let todos: Todo[] = [];
|
|
204
|
-
let nextId = 1;
|
|
205
|
-
|
|
206
|
-
// v3: 用户消息轮数与提醒追踪
|
|
207
|
-
let userMessageCount: number = 0;
|
|
208
|
-
// null 表示未全部完成;设置后保留 AUTO_CLEAR_DELAY_ROUNDS 轮再清空
|
|
209
|
-
let allCompletedAtCount: number | null = null;
|
|
210
|
-
// 均用 number(初始 0),与 userMessageCount 直接做差值比较
|
|
211
|
-
let lastTodoCallCount: number = 0;
|
|
212
|
-
let lastReminderCount: number = 0;
|
|
213
|
-
|
|
214
|
-
/** v3: 自动清空延迟轮数(全部完成后保留 N 轮用户消息) */
|
|
215
|
-
const AUTO_CLEAR_DELAY_ROUNDS = 2;
|
|
216
|
-
/** v3: Verification Nudge 触发阈值(完成 N 个任务以上时检查) */
|
|
217
|
-
const VERIFICATION_NUDGE_THRESHOLD = 3;
|
|
218
|
-
/** v3: Todo Reminder 触发间隔(N 轮未调用 todo 工具时提醒) */
|
|
219
|
-
const TODO_REMINDER_INTERVAL = 10;
|
|
220
|
-
|
|
221
189
|
/** 构建 _render 描述符 */
|
|
222
190
|
function buildRender(todoList: Todo[]): TodoDetails["_render"] {
|
|
223
191
|
const completed = todoList.filter((t) => t.status === "completed").length;
|
|
224
192
|
const total = todoList.length;
|
|
225
193
|
return {
|
|
226
194
|
type: "task-list" as const,
|
|
227
|
-
summary: `${completed}/${total}
|
|
195
|
+
summary: `${completed}/${total} completed`,
|
|
228
196
|
data: {
|
|
229
197
|
items: todoList.map((t) => ({ id: t.id, text: t.text, status: t.status })),
|
|
230
198
|
meta: {},
|
|
@@ -232,259 +200,20 @@ function buildRender(todoList: Todo[]): TodoDetails["_render"] {
|
|
|
232
200
|
};
|
|
233
201
|
}
|
|
234
202
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
lastTodoCallCount = userMessageCount;
|
|
242
|
-
|
|
243
|
-
switch (params.action) {
|
|
244
|
-
case "list": {
|
|
245
|
-
resultText = todos.length
|
|
246
|
-
? todos
|
|
247
|
-
.map((t) => {
|
|
248
|
-
const mark =
|
|
249
|
-
t.status === "completed"
|
|
250
|
-
? "x"
|
|
251
|
-
: t.status === "in_progress"
|
|
252
|
-
? "~"
|
|
253
|
-
: " ";
|
|
254
|
-
return `[${mark}] #${t.id}: ${t.text}`;
|
|
255
|
-
})
|
|
256
|
-
.join("\n")
|
|
257
|
-
: "\u6682\u65e0 todo";
|
|
258
|
-
break;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
case "add": {
|
|
262
|
-
if (!params.texts || params.texts.length === 0) {
|
|
263
|
-
return {
|
|
264
|
-
content: [{ type: "text" as const, text: "\u9519\u8bef\uff1aadd \u9700\u8981 texts \u53c2\u6570\uff08\u975e\u7a7a\u6570\u7ec4\uff09" }],
|
|
265
|
-
details: {
|
|
266
|
-
action: "add" as const,
|
|
267
|
-
todos: [...todos],
|
|
268
|
-
nextId,
|
|
269
|
-
error: "texts required",
|
|
270
|
-
_render: buildRender(todos),
|
|
271
|
-
} as TodoDetails,
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
const trimmed = params.texts.map((t) => t.trim()).filter((t) => t.length > 0);
|
|
275
|
-
if (trimmed.length === 0) {
|
|
276
|
-
return {
|
|
277
|
-
content: [{ type: "text" as const, text: "\u9519\u8bef\uff1atexts \u4e2d\u81f3\u5c11\u9700\u8981\u4e00\u4e2a\u975e\u7a7a\u5b57\u7b26\u4e32" }],
|
|
278
|
-
details: {
|
|
279
|
-
action: "add" as const,
|
|
280
|
-
todos: [...todos],
|
|
281
|
-
nextId,
|
|
282
|
-
error: "all texts empty",
|
|
283
|
-
_render: buildRender(todos),
|
|
284
|
-
} as TodoDetails,
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
const startId = nextId;
|
|
288
|
-
for (const t of trimmed) {
|
|
289
|
-
todos.push({ id: nextId++, text: t, status: "pending" });
|
|
290
|
-
}
|
|
291
|
-
const endId = nextId - 1;
|
|
292
|
-
resultText = `\u5df2\u6dfb\u52a0 ${trimmed.length} \u9879 todo (#${startId}-#${endId})`;
|
|
293
|
-
// v3: 新增 todo 表示未全部完成
|
|
294
|
-
allCompletedAtCount = null;
|
|
295
|
-
break;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
case "update": {
|
|
299
|
-
if (params.id === undefined) {
|
|
300
|
-
return {
|
|
301
|
-
content: [{ type: "text" as const, text: "\u9519\u8bef\uff1aupdate \u9700\u8981 id \u53c2\u6570" }],
|
|
302
|
-
details: {
|
|
303
|
-
action: "update" as const,
|
|
304
|
-
todos: [...todos],
|
|
305
|
-
nextId,
|
|
306
|
-
error: "id required",
|
|
307
|
-
_render: buildRender(todos),
|
|
308
|
-
} as TodoDetails,
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
|
-
if (params.status === undefined && params.text === undefined) {
|
|
312
|
-
return {
|
|
313
|
-
content: [{ type: "text" as const, text: "\u9519\u8bef\uff1aupdate \u81f3\u5c11\u9700\u8981 status \u6216 text \u53c2\u6570" }],
|
|
314
|
-
details: {
|
|
315
|
-
action: "update" as const,
|
|
316
|
-
todos: [...todos],
|
|
317
|
-
nextId,
|
|
318
|
-
error: "need status or text",
|
|
319
|
-
_render: buildRender(todos),
|
|
320
|
-
} as TodoDetails,
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
if (params.text !== undefined && params.text === "") {
|
|
324
|
-
return {
|
|
325
|
-
content: [{ type: "text" as const, text: "\u9519\u8bef\uff1atext \u4e0d\u80fd\u4e3a\u7a7a\u5b57\u7b26\u4e32" }],
|
|
326
|
-
details: {
|
|
327
|
-
action: "update" as const,
|
|
328
|
-
todos: [...todos],
|
|
329
|
-
nextId,
|
|
330
|
-
error: "text empty",
|
|
331
|
-
_render: buildRender(todos),
|
|
332
|
-
} as TodoDetails,
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
if (
|
|
336
|
-
params.status !== undefined &&
|
|
337
|
-
!VALID_STATUSES.includes(params.status as (typeof VALID_STATUSES)[number])
|
|
338
|
-
) {
|
|
339
|
-
return {
|
|
340
|
-
content: [
|
|
341
|
-
{
|
|
342
|
-
type: "text" as const,
|
|
343
|
-
text: `\u9519\u8bef\uff1astatus \u53ea\u63a5\u53d7 ${VALID_STATUSES.join(" / ")}`,
|
|
344
|
-
},
|
|
345
|
-
],
|
|
346
|
-
details: {
|
|
347
|
-
action: "update" as const,
|
|
348
|
-
todos: [...todos],
|
|
349
|
-
nextId,
|
|
350
|
-
error: `invalid status: ${params.status}`,
|
|
351
|
-
_render: buildRender(todos),
|
|
352
|
-
} as TodoDetails,
|
|
353
|
-
};
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
const todo = todos.find((t) => t.id === params.id);
|
|
357
|
-
if (!todo) {
|
|
358
|
-
return {
|
|
359
|
-
content: [{ type: "text" as const, text: `Todo #${params.id} \u4e0d\u5b58\u5728` }],
|
|
360
|
-
details: {
|
|
361
|
-
action: "update" as const,
|
|
362
|
-
todos: [...todos],
|
|
363
|
-
nextId,
|
|
364
|
-
error: `#${params.id} not found`,
|
|
365
|
-
_render: buildRender(todos),
|
|
366
|
-
} as TodoDetails,
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// T5 完成引导:判断是否是最后一个 pending 即将完成
|
|
371
|
-
const incompleteBefore = todos.filter(
|
|
372
|
-
(t) => t.status !== "completed",
|
|
373
|
-
);
|
|
374
|
-
const isLastCompletion =
|
|
375
|
-
params.status === "completed" &&
|
|
376
|
-
incompleteBefore.length === 1 &&
|
|
377
|
-
incompleteBefore[0].id === todo.id;
|
|
378
|
-
|
|
379
|
-
if (params.status !== undefined) {
|
|
380
|
-
todo.status = params.status as Todo["status"];
|
|
381
|
-
}
|
|
382
|
-
if (params.text !== undefined) {
|
|
383
|
-
todo.text = params.text;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
const parts: string[] = [`\u5df2\u66f4\u65b0 todo #${todo.id}`];
|
|
387
|
-
if (params.status !== undefined) parts.push(`\u72b6\u6001 \u2192 ${params.status}`);
|
|
388
|
-
if (params.text !== undefined) parts.push(`\u6587\u672c \u2192 "${todo.text}"`);
|
|
389
|
-
resultText = parts.join("\uff0c");
|
|
390
|
-
|
|
391
|
-
if (isLastCompletion) {
|
|
392
|
-
resultText += "\n\n\u6240\u6709 todo \u5df2\u5b8c\u6210\u3002\u8bf7\u603b\u7ed3\u5de5\u4f5c\u6210\u679c\u3002";
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// v3: 检查是否所有 todo 已完成
|
|
396
|
-
const allCompleted = todos.every((t) => t.status === "completed");
|
|
397
|
-
if (allCompleted && todos.length > 0) {
|
|
398
|
-
allCompletedAtCount = userMessageCount;
|
|
399
|
-
} else {
|
|
400
|
-
allCompletedAtCount = null;
|
|
401
|
-
}
|
|
402
|
-
break;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
case "delete": {
|
|
406
|
-
if (!params.ids || params.ids.length === 0) {
|
|
407
|
-
return {
|
|
408
|
-
content: [{ type: "text" as const, text: "\u9519\u8bef\uff1adelete \u9700\u8981 ids \u53c2\u6570\uff08\u975e\u7a7a\u6570\u7ec4\uff09" }],
|
|
409
|
-
details: {
|
|
410
|
-
action: "delete" as const,
|
|
411
|
-
todos: [...todos],
|
|
412
|
-
nextId,
|
|
413
|
-
error: "ids required",
|
|
414
|
-
_render: buildRender(todos),
|
|
415
|
-
} as TodoDetails,
|
|
416
|
-
};
|
|
417
|
-
}
|
|
418
|
-
const uniqueIds = [...new Set(params.ids)];
|
|
419
|
-
const missing = uniqueIds.filter((id) => !todos.some((t) => t.id === id));
|
|
420
|
-
if (missing.length > 0) {
|
|
421
|
-
const missingStr = missing.map((id) => `#${id}`).join(", ");
|
|
422
|
-
return {
|
|
423
|
-
content: [{ type: "text" as const, text: `\u9519\u8bef\uff1aTodo ${missingStr} \u4e0d\u5b58\u5728` }],
|
|
424
|
-
details: {
|
|
425
|
-
action: "delete" as const,
|
|
426
|
-
todos: [...todos],
|
|
427
|
-
nextId,
|
|
428
|
-
error: `#${missing.map((id) => id).join(", #")} not found`,
|
|
429
|
-
_render: buildRender(todos),
|
|
430
|
-
} as TodoDetails,
|
|
431
|
-
};
|
|
432
|
-
}
|
|
433
|
-
const removedIds: number[] = [];
|
|
434
|
-
for (const id of uniqueIds) {
|
|
435
|
-
const idx = todos.findIndex((t) => t.id === id);
|
|
436
|
-
if (idx !== -1) {
|
|
437
|
-
todos.splice(idx, 1);
|
|
438
|
-
removedIds.push(id);
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
resultText = `\u5df2\u5220\u9664 ${removedIds.length} \u9879 (#${removedIds.join(", #")})\uff0c\u5269\u4f59 ${todos.length} \u9879`;
|
|
442
|
-
break;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
case "clear": {
|
|
446
|
-
const count = todos.length;
|
|
447
|
-
todos = [];
|
|
448
|
-
nextId = 1;
|
|
449
|
-
resultText = count > 0 ? `\u5df2\u6e05\u7a7a ${count} \u9879 todo` : "\u6682\u65e0 todo\uff0c\u65e0\u9700\u6e05\u7a7a";
|
|
450
|
-
// v3: 手动清空后重置
|
|
451
|
-
allCompletedAtCount = null;
|
|
452
|
-
break;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
default:
|
|
456
|
-
return {
|
|
457
|
-
content: [{ type: "text" as const, text: `\u672a\u77e5 action: ${params.action}` }],
|
|
458
|
-
details: {
|
|
459
|
-
action: "list" as const,
|
|
460
|
-
todos: [...todos],
|
|
461
|
-
nextId,
|
|
462
|
-
error: `unknown action: ${params.action}`,
|
|
463
|
-
_render: buildRender(todos),
|
|
464
|
-
} as TodoDetails,
|
|
465
|
-
};
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
refreshDisplay(ctx);
|
|
469
|
-
|
|
470
|
-
return {
|
|
471
|
-
content: [{ type: "text" as const, text: resultText }],
|
|
472
|
-
details: {
|
|
473
|
-
action: params.action as TodoDetails["action"],
|
|
474
|
-
todos: [...todos],
|
|
475
|
-
nextId,
|
|
476
|
-
_render: buildRender(todos),
|
|
477
|
-
} as TodoDetails,
|
|
478
|
-
};
|
|
479
|
-
}
|
|
203
|
+
/** v3: 自动清空延迟轮数(全部完成后保留 N 轮用户消息) */
|
|
204
|
+
const AUTO_CLEAR_DELAY_ROUNDS = 2;
|
|
205
|
+
/** v3: Verification Nudge 触发阈值(完成 N 个任务以上时检查) */
|
|
206
|
+
const VERIFICATION_NUDGE_THRESHOLD = 3;
|
|
207
|
+
/** v3: Todo Reminder 触发间隔(N 轮未调用 todo 工具时提醒) */
|
|
208
|
+
const TODO_REMINDER_INTERVAL = 10;
|
|
480
209
|
|
|
481
210
|
// ── 列表渲染辅助函数 ─────────────────────────────────
|
|
482
211
|
|
|
483
212
|
function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, theme: Theme): string {
|
|
484
213
|
if (todoList.length === 0) {
|
|
485
|
-
return theme.fg("dim", "
|
|
214
|
+
return theme.fg("dim", "No todos");
|
|
486
215
|
}
|
|
487
|
-
let listText = theme.fg("muted", `${todoList.length}
|
|
216
|
+
let listText = theme.fg("muted", `${todoList.length} todos:`);
|
|
488
217
|
const display = options.expanded ? todoList : todoList.slice(0, MAX_COLLAPSED_ITEMS);
|
|
489
218
|
for (const t of display) {
|
|
490
219
|
const status = getDisplayStatus(t);
|
|
@@ -499,7 +228,7 @@ function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, the
|
|
|
499
228
|
listText += `\n${mark} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
|
|
500
229
|
}
|
|
501
230
|
if (!options.expanded && todoList.length > MAX_COLLAPSED_ITEMS) {
|
|
502
|
-
listText += `\n${theme.fg("dim", `...
|
|
231
|
+
listText += `\n${theme.fg("dim", `... ${todoList.length - MAX_COLLAPSED_ITEMS} more`)}`;
|
|
503
232
|
}
|
|
504
233
|
return listText;
|
|
505
234
|
}
|
|
@@ -515,7 +244,7 @@ function renderTodoResult(result: unknown, options: { expanded: boolean }, theme
|
|
|
515
244
|
}
|
|
516
245
|
|
|
517
246
|
if (details.error) {
|
|
518
|
-
return new Text(theme.fg("error",
|
|
247
|
+
return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
|
519
248
|
}
|
|
520
249
|
|
|
521
250
|
const todoList = details.todos;
|
|
@@ -552,7 +281,7 @@ function renderTodoResult(result: unknown, options: { expanded: boolean }, theme
|
|
|
552
281
|
default: {
|
|
553
282
|
const text = r.content[0];
|
|
554
283
|
const msg = text?.type === "text" ? (text.text ?? "") : "";
|
|
555
|
-
return new Text(theme.fg("dim", msg || "
|
|
284
|
+
return new Text(theme.fg("dim", msg || "Done"), 0, 0);
|
|
556
285
|
}
|
|
557
286
|
}
|
|
558
287
|
}
|
|
@@ -560,7 +289,277 @@ function renderTodoResult(result: unknown, options: { expanded: boolean }, theme
|
|
|
560
289
|
// ── 扩展入口 ─────────────────────────────────────────
|
|
561
290
|
|
|
562
291
|
export default function (pi: ExtensionAPI) {
|
|
563
|
-
|
|
292
|
+
// ── 闭包内状态(session 隔离) ─────────────────────
|
|
293
|
+
let todos: Todo[] = [];
|
|
294
|
+
let nextId = 1;
|
|
295
|
+
|
|
296
|
+
// v3: 用户消息轮数与提醒追踪
|
|
297
|
+
let userMessageCount = 0;
|
|
298
|
+
let allCompletedAtCount: number | null = null;
|
|
299
|
+
let lastTodoCallCount = 0;
|
|
300
|
+
let lastReminderCount = 0;
|
|
301
|
+
|
|
302
|
+
// ── 刷新显示(依赖闭包 state) ─────────────────────
|
|
303
|
+
function refreshDisplay(ctx: ExtensionContext): void {
|
|
304
|
+
const statusText = renderStatusText(todos, ctx.ui.theme);
|
|
305
|
+
ctx.ui.setStatus("todo", statusText || undefined);
|
|
306
|
+
if (todos.length === 0) {
|
|
307
|
+
ctx.ui.setWidget("todo", undefined);
|
|
308
|
+
} else {
|
|
309
|
+
ctx.ui.setWidget("todo", renderWidgetLines(todos, ctx.ui.theme));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Tool execute handler ─────────────────────────────
|
|
314
|
+
function executeTodoAction(
|
|
315
|
+
params: { action: string; text?: string; id?: number; texts?: string[]; ids?: number[]; status?: string },
|
|
316
|
+
ctx: ExtensionContext,
|
|
317
|
+
) {
|
|
318
|
+
let resultText = "";
|
|
319
|
+
|
|
320
|
+
// v3: 追踪 todo 工具调用轮数
|
|
321
|
+
lastTodoCallCount = userMessageCount;
|
|
322
|
+
|
|
323
|
+
switch (params.action) {
|
|
324
|
+
case "list": {
|
|
325
|
+
resultText = todos.length
|
|
326
|
+
? todos
|
|
327
|
+
.map((t) => {
|
|
328
|
+
const mark =
|
|
329
|
+
t.status === "completed"
|
|
330
|
+
? "x"
|
|
331
|
+
: t.status === "in_progress"
|
|
332
|
+
? "~"
|
|
333
|
+
: " ";
|
|
334
|
+
return `[${mark}] #${t.id}: ${t.text}`;
|
|
335
|
+
})
|
|
336
|
+
.join("\n")
|
|
337
|
+
: "No todos";
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
case "add": {
|
|
342
|
+
if (!params.texts || params.texts.length === 0) {
|
|
343
|
+
return {
|
|
344
|
+
content: [{ type: "text" as const, text: "Error: add requires texts parameter (non-empty array)" }],
|
|
345
|
+
details: {
|
|
346
|
+
action: "add" as const,
|
|
347
|
+
todos: [...todos],
|
|
348
|
+
nextId,
|
|
349
|
+
error: "texts required",
|
|
350
|
+
_render: buildRender(todos),
|
|
351
|
+
} as TodoDetails,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
const trimmed = params.texts.map((t) => t.trim()).filter((t) => t.length > 0);
|
|
355
|
+
if (trimmed.length === 0) {
|
|
356
|
+
return {
|
|
357
|
+
content: [{ type: "text" as const, text: "Error: texts must contain at least one non-empty string" }],
|
|
358
|
+
details: {
|
|
359
|
+
action: "add" as const,
|
|
360
|
+
todos: [...todos],
|
|
361
|
+
nextId,
|
|
362
|
+
error: "all texts empty",
|
|
363
|
+
_render: buildRender(todos),
|
|
364
|
+
} as TodoDetails,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
const startId = nextId;
|
|
368
|
+
for (const t of trimmed) {
|
|
369
|
+
todos.push({ id: nextId++, text: t, status: "pending" });
|
|
370
|
+
}
|
|
371
|
+
const endId = nextId - 1;
|
|
372
|
+
resultText = `Added ${trimmed.length} todos (#${startId}-#${endId})`;
|
|
373
|
+
// v3: 新增 todo 表示未全部完成
|
|
374
|
+
allCompletedAtCount = null;
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
case "update": {
|
|
379
|
+
if (params.id === undefined) {
|
|
380
|
+
return {
|
|
381
|
+
content: [{ type: "text" as const, text: "Error: update requires id parameter" }],
|
|
382
|
+
details: {
|
|
383
|
+
action: "update" as const,
|
|
384
|
+
todos: [...todos],
|
|
385
|
+
nextId,
|
|
386
|
+
error: "id required",
|
|
387
|
+
_render: buildRender(todos),
|
|
388
|
+
} as TodoDetails,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
if (params.status === undefined && params.text === undefined) {
|
|
392
|
+
return {
|
|
393
|
+
content: [{ type: "text" as const, text: "Error: update requires at least status or text parameter" }],
|
|
394
|
+
details: {
|
|
395
|
+
action: "update" as const,
|
|
396
|
+
todos: [...todos],
|
|
397
|
+
nextId,
|
|
398
|
+
error: "need status or text",
|
|
399
|
+
_render: buildRender(todos),
|
|
400
|
+
} as TodoDetails,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
if (params.text !== undefined && params.text === "") {
|
|
404
|
+
return {
|
|
405
|
+
content: [{ type: "text" as const, text: "Error: text cannot be empty string" }],
|
|
406
|
+
details: {
|
|
407
|
+
action: "update" as const,
|
|
408
|
+
todos: [...todos],
|
|
409
|
+
nextId,
|
|
410
|
+
error: "text empty",
|
|
411
|
+
_render: buildRender(todos),
|
|
412
|
+
} as TodoDetails,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
if (
|
|
416
|
+
params.status !== undefined &&
|
|
417
|
+
!VALID_STATUSES.includes(params.status as (typeof VALID_STATUSES)[number])
|
|
418
|
+
) {
|
|
419
|
+
return {
|
|
420
|
+
content: [
|
|
421
|
+
{
|
|
422
|
+
type: "text" as const,
|
|
423
|
+
text: `Error: status only accepts ${VALID_STATUSES.join(" / ")}`,
|
|
424
|
+
},
|
|
425
|
+
],
|
|
426
|
+
details: {
|
|
427
|
+
action: "update" as const,
|
|
428
|
+
todos: [...todos],
|
|
429
|
+
nextId,
|
|
430
|
+
error: `invalid status: ${params.status}`,
|
|
431
|
+
_render: buildRender(todos),
|
|
432
|
+
} as TodoDetails,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const todo = todos.find((t) => t.id === params.id);
|
|
437
|
+
if (!todo) {
|
|
438
|
+
return {
|
|
439
|
+
content: [{ type: "text" as const, text: `Todo #${params.id} not found` }],
|
|
440
|
+
details: {
|
|
441
|
+
action: "update" as const,
|
|
442
|
+
todos: [...todos],
|
|
443
|
+
nextId,
|
|
444
|
+
error: `#${params.id} not found`,
|
|
445
|
+
_render: buildRender(todos),
|
|
446
|
+
} as TodoDetails,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// T5 完成引导:判断是否是最后一个 pending 即将完成
|
|
451
|
+
const incompleteBefore = todos.filter(
|
|
452
|
+
(t) => t.status !== "completed",
|
|
453
|
+
);
|
|
454
|
+
const isLastCompletion =
|
|
455
|
+
params.status === "completed" &&
|
|
456
|
+
incompleteBefore.length === 1 &&
|
|
457
|
+
incompleteBefore[0].id === todo.id;
|
|
458
|
+
|
|
459
|
+
if (params.status !== undefined) {
|
|
460
|
+
todo.status = params.status as Todo["status"];
|
|
461
|
+
}
|
|
462
|
+
if (params.text !== undefined) {
|
|
463
|
+
todo.text = params.text;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const parts: string[] = [`Updated todo #${todo.id}`];
|
|
467
|
+
if (params.status !== undefined) parts.push(`status → ${params.status}`);
|
|
468
|
+
if (params.text !== undefined) parts.push(`text → "${todo.text}"`);
|
|
469
|
+
resultText = parts.join(", ");
|
|
470
|
+
|
|
471
|
+
if (isLastCompletion) {
|
|
472
|
+
resultText += "\n\nAll todos completed. Please summarize your work.";
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// v3: 检查是否所有 todo 已完成
|
|
476
|
+
const allCompleted = todos.every((t) => t.status === "completed");
|
|
477
|
+
if (allCompleted && todos.length > 0) {
|
|
478
|
+
allCompletedAtCount = userMessageCount;
|
|
479
|
+
} else {
|
|
480
|
+
allCompletedAtCount = null;
|
|
481
|
+
}
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
case "delete": {
|
|
486
|
+
if (!params.ids || params.ids.length === 0) {
|
|
487
|
+
return {
|
|
488
|
+
content: [{ type: "text" as const, text: "Error: delete requires ids parameter (non-empty array)" }],
|
|
489
|
+
details: {
|
|
490
|
+
action: "delete" as const,
|
|
491
|
+
todos: [...todos],
|
|
492
|
+
nextId,
|
|
493
|
+
error: "ids required",
|
|
494
|
+
_render: buildRender(todos),
|
|
495
|
+
} as TodoDetails,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
const uniqueIds = [...new Set(params.ids)];
|
|
499
|
+
const missing = uniqueIds.filter((id) => !todos.some((t) => t.id === id));
|
|
500
|
+
if (missing.length > 0) {
|
|
501
|
+
const missingStr = missing.map((id) => `#${id}`).join(", ");
|
|
502
|
+
return {
|
|
503
|
+
content: [{ type: "text" as const, text: `Error: Todo ${missingStr} not found` }],
|
|
504
|
+
details: {
|
|
505
|
+
action: "delete" as const,
|
|
506
|
+
todos: [...todos],
|
|
507
|
+
nextId,
|
|
508
|
+
error: `#${missing.map((id) => id).join(", #")} not found`,
|
|
509
|
+
_render: buildRender(todos),
|
|
510
|
+
} as TodoDetails,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
const removedIds: number[] = [];
|
|
514
|
+
for (const id of uniqueIds) {
|
|
515
|
+
const idx = todos.findIndex((t) => t.id === id);
|
|
516
|
+
if (idx !== -1) {
|
|
517
|
+
todos.splice(idx, 1);
|
|
518
|
+
removedIds.push(id);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
resultText = `Deleted ${removedIds.length} items (#${removedIds.join(", #")}), ${todos.length} remaining`;
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
case "clear": {
|
|
526
|
+
const count = todos.length;
|
|
527
|
+
todos = [];
|
|
528
|
+
nextId = 1;
|
|
529
|
+
resultText = count > 0 ? `Cleared ${count} todos` : "No todos to clear";
|
|
530
|
+
// v3: 手动清空后重置
|
|
531
|
+
allCompletedAtCount = null;
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
default:
|
|
536
|
+
return {
|
|
537
|
+
content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }],
|
|
538
|
+
details: {
|
|
539
|
+
action: "list" as const,
|
|
540
|
+
todos: [...todos],
|
|
541
|
+
nextId,
|
|
542
|
+
error: `unknown action: ${params.action}`,
|
|
543
|
+
_render: buildRender(todos),
|
|
544
|
+
} as TodoDetails,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
refreshDisplay(ctx);
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
content: [{ type: "text" as const, text: resultText }],
|
|
552
|
+
details: {
|
|
553
|
+
action: params.action as TodoDetails["action"],
|
|
554
|
+
todos: [...todos],
|
|
555
|
+
nextId,
|
|
556
|
+
_render: buildRender(todos),
|
|
557
|
+
} as TodoDetails,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ── 状态重建 ───────────────────────────────────────
|
|
562
|
+
function reconstructState(ctx: ExtensionContext) {
|
|
564
563
|
todos = [];
|
|
565
564
|
nextId = 1;
|
|
566
565
|
|
|
@@ -601,8 +600,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
601
600
|
entries.splice(staleIndices[j], 1);
|
|
602
601
|
}
|
|
603
602
|
}
|
|
604
|
-
}
|
|
603
|
+
}
|
|
605
604
|
|
|
605
|
+
// ── 事件处理器 ──────────────────────────────────────
|
|
606
606
|
pi.on("session_start", async (_event: any, ctx: ExtensionContext) => {
|
|
607
607
|
reconstructState(ctx);
|
|
608
608
|
refreshDisplay(ctx);
|
|
@@ -630,7 +630,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
630
630
|
return {
|
|
631
631
|
message: {
|
|
632
632
|
customType: "todo-auto-clear",
|
|
633
|
-
content:
|
|
633
|
+
content: `All ${count} todos completed, list auto-cleared.`,
|
|
634
634
|
display: true,
|
|
635
635
|
},
|
|
636
636
|
};
|
|
@@ -646,7 +646,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
646
646
|
return {
|
|
647
647
|
message: {
|
|
648
648
|
customType: "todo-verification-nudge",
|
|
649
|
-
content: "
|
|
649
|
+
content: "You completed 3+ tasks without a verification step. Consider adding a verification task before summarizing.",
|
|
650
650
|
display: true,
|
|
651
651
|
},
|
|
652
652
|
};
|
|
@@ -663,7 +663,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
663
663
|
return {
|
|
664
664
|
message: {
|
|
665
665
|
customType: "todo-reminder",
|
|
666
|
-
content: "
|
|
666
|
+
content: "The todo tool hasn't been used recently. If working on tasks, consider using it to track progress.",
|
|
667
667
|
display: true,
|
|
668
668
|
},
|
|
669
669
|
};
|
|
@@ -676,32 +676,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
676
676
|
}
|
|
677
677
|
});
|
|
678
678
|
|
|
679
|
+
// ── Tool: todo ──────────────────────────────────────
|
|
679
680
|
pi.registerTool({
|
|
680
681
|
name: "todo",
|
|
681
682
|
label: "Todo",
|
|
682
683
|
description:
|
|
683
|
-
"
|
|
684
|
-
"\n\
|
|
685
|
-
"\n- list
|
|
686
|
-
"\n- add
|
|
687
|
-
"\n- update
|
|
688
|
-
"\n- delete
|
|
689
|
-
"\n- clear
|
|
690
|
-
promptSnippet: "
|
|
684
|
+
"Manage a todo list." +
|
|
685
|
+
"\n\nAvailable actions:" +
|
|
686
|
+
"\n- list: View all todos" +
|
|
687
|
+
"\n- add: Batch add todos (requires texts array)" +
|
|
688
|
+
"\n- update: Update a todo (requires id, optional status/text)" +
|
|
689
|
+
"\n- delete: Batch delete todos (requires ids array)" +
|
|
690
|
+
"\n- clear: Clear all todos and reset IDs",
|
|
691
|
+
promptSnippet: "Lightweight task list for tracking progress on multi-step work, without requiring /goal mode",
|
|
691
692
|
promptGuidelines: [
|
|
692
|
-
"[
|
|
693
|
-
"[
|
|
694
|
-
"[
|
|
695
|
-
"[
|
|
696
|
-
"[
|
|
697
|
-
"[
|
|
698
|
-
"[
|
|
699
|
-
"[
|
|
693
|
+
"[Usage] Use for multi-step tasks (3+ steps), progress tracking, or when explicitly requested",
|
|
694
|
+
"[Not for] Single-step operations, trivial tasks, or when goal_manager is already active",
|
|
695
|
+
"[Timing] Create before starting work, mark completed immediately when done",
|
|
696
|
+
"[Status] At most one in_progress at a time; mark completed immediately",
|
|
697
|
+
"[Granularity] One todo per verifiable work unit, 3-8 items ideal",
|
|
698
|
+
"[Completion] All todos auto-clear when completed (retained for 2 turns)",
|
|
699
|
+
"[Verification] When completing 3+ tasks, consider adding a verification step",
|
|
700
|
+
"[Scope] Do not use todo as a substitute for goal_manager — they serve different purposes",
|
|
700
701
|
],
|
|
701
702
|
parameters: TodoParams,
|
|
702
703
|
|
|
703
704
|
async execute(_toolCallId: string, params: Static<typeof TodoParams>, _signal: AbortSignal | undefined, _onUpdate: any, ctx: ExtensionContext) {
|
|
704
|
-
const result =
|
|
705
|
+
const result = executeTodoAction(params as any, ctx);
|
|
705
706
|
// Append input params to error results for debugging
|
|
706
707
|
const details = result.details as { error?: string } | undefined;
|
|
707
708
|
if (details?.error) {
|
|
@@ -729,11 +730,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
729
730
|
},
|
|
730
731
|
});
|
|
731
732
|
|
|
733
|
+
// ── Command: /todos ─────────────────────────────────
|
|
732
734
|
pi.registerCommand("todos", {
|
|
733
|
-
description: "
|
|
735
|
+
description: "View all todos for the current branch",
|
|
734
736
|
handler: async (_args: string | undefined, ctx: ExtensionCommandContext) => {
|
|
735
737
|
if (!ctx.hasUI) {
|
|
736
|
-
ctx.ui.notify("/todos
|
|
738
|
+
ctx.ui.notify("/todos requires interactive mode", "error");
|
|
737
739
|
return;
|
|
738
740
|
}
|
|
739
741
|
|