@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.
Files changed (2) hide show
  1. package/package.json +13 -2
  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.2",
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 文本(update 时使用)" })),
45
- id: Type.Optional(Type.Number({ description: "Todo IDupdate 时使用)" })),
46
- texts: Type.Optional(Type.Array(Type.String(), { description: "Todo 文本列表(add 时使用)" })),
47
- ids: Type.Optional(Type.Array(Type.Number(), { description: "Todo ID 列表(delete 时使用)" })),
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: "目标状态(update 时使用)" }),
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", "\u6682\u65e0 todo\u3002\u8ba9 agent \u6dfb\u52a0\u4e00\u4e9b\uff01")}`, width));
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} \u5df2\u5b8c\u6210`)}`, width));
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", "\u6309 Escape \u5173\u95ed")}`, width));
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
- // ── Tool execute handler ─────────────────────────────
236
-
237
- function executeTodoAction(params: { action: string; text?: string; id?: number; texts?: string[]; ids?: number[]; status?: string }, ctx: ExtensionContext) {
238
- let resultText = "";
239
-
240
- // v3: 追踪 todo 工具调用轮数
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", "\u6682\u65e0 todo");
214
+ return theme.fg("dim", "No todos");
486
215
  }
487
- let listText = theme.fg("muted", `${todoList.length} \u9879 todo\uff1a`);
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", `... \u8fd8\u6709 ${todoList.length - MAX_COLLAPSED_ITEMS} \u9879`)}`;
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", `\u9519\u8bef: ${details.error}`), 0, 0);
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 || "\u5b8c\u6210"), 0, 0);
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
- const reconstructState = (ctx: ExtensionContext) => {
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: `所有 ${count} todo 已完成,列表已自动清空。`,
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: "你刚完成了 3+ 个任务但没有验证步骤。建议在总结前添加验证任务。",
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: "Todo 工具最近没有被使用。如果你在处理任务,建议使用它来跟踪进度。",
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
- "\u7ba1\u7406 todo \u6e05\u5355\u3002" +
684
- "\n\n\u53ef\u7528 action\uff1a" +
685
- "\n- list\uff1a\u67e5\u770b\u6240\u6709 todo" +
686
- "\n- add\uff1a\u6279\u91cf\u6dfb\u52a0 todo\uff08\u9700\u8981 texts \u6570\u7ec4\uff09" +
687
- "\n- update\uff1a\u66f4\u65b0 todo\uff08\u9700\u8981 id\uff0c\u53ef\u9009 status/text\uff09" +
688
- "\n- delete\uff1a\u6279\u91cf\u5220\u9664 todo\uff08\u9700\u8981 ids \u6570\u7ec4\uff09" +
689
- "\n- clear\uff1a\u6e05\u7a7a\u6240\u6709 todo \u5e76\u91cd\u7f6e ID",
690
- promptSnippet: "\u8f7b\u91cf\u7ea7\u4efb\u52a1\u6e05\u5355\u3002\u591a\u6b65\u9aa4\u5de5\u4f5c\u65f6\u8ffd\u8e2a\u8fdb\u5ea6\uff0c\u4e0d\u5fc5\u7b49 /goal \u6a21\u5f0f",
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
- "[使用场景] 多步骤任务(3+步)、需要追踪进度、用户明确要求时使用 todo",
693
- "[不适用] 单步操作、任务简单可直接完成、已在用 goal_manager ",
694
- "[时机] 开始工作前创建,完成时立即标记",
695
- "[状态] 同一时间最多一个 in_progress,完成后立即标记 completed",
696
- "[粒度] 一个 todo 对应一个可验证的工作单元,3-8 项为宜",
697
- "[完成] 所有 todo 完成后会自动清空(保留 2 轮后)",
698
- "[验证] 完成 3+ 任务时建议添加验证步骤",
699
- "[定位] 不要用 todo 替代 goal_manager,两者定位不同",
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 = await executeTodoAction(params as any, ctx);
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: "\u67e5\u770b\u5f53\u524d\u5206\u652f\u7684\u6240\u6709 todo",
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 \u9700\u8981\u4ea4\u4e92\u6a21\u5f0f", "error");
738
+ ctx.ui.notify("/todos requires interactive mode", "error");
737
739
  return;
738
740
  }
739
741