@zhushanwen/pi-todo 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -0
- package/package.json +20 -3
- package/src/index.ts +293 -291
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# todo
|
|
2
|
+
|
|
3
|
+
轻量级 AI 任务清单 — 三态(pending / in_progress / completed),支持 session 持久化、状态栏、批量操作。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- **三态任务**:`pending` → `in_progress` → `completed`
|
|
8
|
+
- **批量操作**:add / update / delete / clear
|
|
9
|
+
- **Session 持久化**:任务状态保存在 session entries 中,重启后恢复
|
|
10
|
+
- **状态栏**:底部显示任务进度(如 `2/5 done`)
|
|
11
|
+
- **自动清理**:所有任务完成后自动清空
|
|
12
|
+
|
|
13
|
+
## 安装
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# symlink 方式(开发推荐)
|
|
17
|
+
ln -s /path/to/xyz-pi-extensions-workspace/main/packages/todo \
|
|
18
|
+
~/.pi/agent/extensions/todo
|
|
19
|
+
|
|
20
|
+
# npm 方式(正式)
|
|
21
|
+
pi install npm:@zhushanwen/pi-todo
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 使用
|
|
25
|
+
|
|
26
|
+
AI 可调用 `todo` 工具:
|
|
27
|
+
|
|
28
|
+
| Action | 说明 |
|
|
29
|
+
|--------|------|
|
|
30
|
+
| `list` | 查看所有 todo |
|
|
31
|
+
| `add` | 批量添加 todo |
|
|
32
|
+
| `update` | 更新 todo(状态/文本) |
|
|
33
|
+
| `delete` | 批量删除 |
|
|
34
|
+
| `clear` | 清空所有 |
|
|
35
|
+
|
|
36
|
+
用户命令:`/todos` 交互式面板。
|
|
37
|
+
|
|
38
|
+
## 文件结构
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
todo/
|
|
42
|
+
├── index.ts
|
|
43
|
+
└── src/
|
|
44
|
+
└── index.ts # 入口 — 工具、命令、事件、状态栏
|
|
45
|
+
```
|
package/package.json
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-todo",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "AI-driven todo list for Pi — stateful task management with session persistence and /todos command.",
|
|
5
|
+
"type": "module",
|
|
5
6
|
"main": "src/index.ts",
|
|
7
|
+
"pi": {
|
|
8
|
+
"extensions": [
|
|
9
|
+
"./src/index.ts"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
6
12
|
"keywords": [
|
|
7
|
-
"pi",
|
|
13
|
+
"pi-package",
|
|
8
14
|
"extension",
|
|
9
15
|
"todo",
|
|
10
16
|
"task",
|
|
@@ -16,7 +22,18 @@
|
|
|
16
22
|
"index.ts"
|
|
17
23
|
],
|
|
18
24
|
"peerDependencies": {
|
|
19
|
-
"@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
|
+
}
|
|
20
37
|
},
|
|
21
38
|
"scripts": {
|
|
22
39
|
"typecheck": "npx tsc --noEmit"
|
package/src/index.ts
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { StringEnum } from "@mariozechner/pi-ai";
|
|
13
|
-
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
13
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
14
14
|
import { matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
|
15
|
-
import { Type } from "typebox";
|
|
15
|
+
import { Type, type Static } from "typebox";
|
|
16
16
|
|
|
17
17
|
// ── 数据模型 ─────────────────────────────────────────
|
|
18
18
|
|
|
@@ -186,38 +186,6 @@ 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;
|
|
@@ -232,251 +200,12 @@ 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
|
|
|
@@ -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
|
+
: "\u6682\u65e0 todo";
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
case "add": {
|
|
342
|
+
if (!params.texts || params.texts.length === 0) {
|
|
343
|
+
return {
|
|
344
|
+
content: [{ type: "text" as const, text: "\u9519\u8bef\uff1aadd \u9700\u8981 texts \u53c2\u6570\uff08\u975e\u7a7a\u6570\u7ec4\uff09" }],
|
|
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: "\u9519\u8bef\uff1atexts \u4e2d\u81f3\u5c11\u9700\u8981\u4e00\u4e2a\u975e\u7a7a\u5b57\u7b26\u4e32" }],
|
|
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 = `\u5df2\u6dfb\u52a0 ${trimmed.length} \u9879 todo (#${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: "\u9519\u8bef\uff1aupdate \u9700\u8981 id \u53c2\u6570" }],
|
|
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: "\u9519\u8bef\uff1aupdate \u81f3\u5c11\u9700\u8981 status \u6216 text \u53c2\u6570" }],
|
|
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: "\u9519\u8bef\uff1atext \u4e0d\u80fd\u4e3a\u7a7a\u5b57\u7b26\u4e32" }],
|
|
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: `\u9519\u8bef\uff1astatus \u53ea\u63a5\u53d7 ${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} \u4e0d\u5b58\u5728` }],
|
|
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[] = [`\u5df2\u66f4\u65b0 todo #${todo.id}`];
|
|
467
|
+
if (params.status !== undefined) parts.push(`\u72b6\u6001 \u2192 ${params.status}`);
|
|
468
|
+
if (params.text !== undefined) parts.push(`\u6587\u672c \u2192 "${todo.text}"`);
|
|
469
|
+
resultText = parts.join("\uff0c");
|
|
470
|
+
|
|
471
|
+
if (isLastCompletion) {
|
|
472
|
+
resultText += "\n\n\u6240\u6709 todo \u5df2\u5b8c\u6210\u3002\u8bf7\u603b\u7ed3\u5de5\u4f5c\u6210\u679c\u3002";
|
|
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: "\u9519\u8bef\uff1adelete \u9700\u8981 ids \u53c2\u6570\uff08\u975e\u7a7a\u6570\u7ec4\uff09" }],
|
|
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: `\u9519\u8bef\uff1aTodo ${missingStr} \u4e0d\u5b58\u5728` }],
|
|
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 = `\u5df2\u5220\u9664 ${removedIds.length} \u9879 (#${removedIds.join(", #")})\uff0c\u5269\u4f59 ${todos.length} \u9879`;
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
case "clear": {
|
|
526
|
+
const count = todos.length;
|
|
527
|
+
todos = [];
|
|
528
|
+
nextId = 1;
|
|
529
|
+
resultText = count > 0 ? `\u5df2\u6e05\u7a7a ${count} \u9879 todo` : "\u6682\u65e0 todo\uff0c\u65e0\u9700\u6e05\u7a7a";
|
|
530
|
+
// v3: 手动清空后重置
|
|
531
|
+
allCompletedAtCount = null;
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
default:
|
|
536
|
+
return {
|
|
537
|
+
content: [{ type: "text" as const, text: `\u672a\u77e5 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,24 +600,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
601
600
|
entries.splice(staleIndices[j], 1);
|
|
602
601
|
}
|
|
603
602
|
}
|
|
604
|
-
}
|
|
603
|
+
}
|
|
605
604
|
|
|
606
|
-
|
|
605
|
+
// ── 事件处理器 ──────────────────────────────────────
|
|
606
|
+
pi.on("session_start", async (_event: any, ctx: ExtensionContext) => {
|
|
607
607
|
reconstructState(ctx);
|
|
608
608
|
refreshDisplay(ctx);
|
|
609
609
|
});
|
|
610
|
-
pi.on("session_tree", async (_event, ctx) => {
|
|
610
|
+
pi.on("session_tree", async (_event: any, ctx: ExtensionContext) => {
|
|
611
611
|
reconstructState(ctx);
|
|
612
612
|
refreshDisplay(ctx);
|
|
613
613
|
});
|
|
614
614
|
|
|
615
615
|
// v3: 追踪用户消息轮数
|
|
616
|
-
pi.on("agent_start", async (_event, _ctx) => {
|
|
616
|
+
pi.on("agent_start", async (_event: any, _ctx: ExtensionContext) => {
|
|
617
617
|
userMessageCount++;
|
|
618
618
|
});
|
|
619
619
|
|
|
620
620
|
// v3: 自动清空与提醒检查
|
|
621
|
-
pi.on("before_agent_start", async (_event, ctx) => {
|
|
621
|
+
pi.on("before_agent_start", async (_event: any, ctx: ExtensionContext) => {
|
|
622
622
|
try {
|
|
623
623
|
// 1. 自动清空:全部完成后经过 2 轮用户消息
|
|
624
624
|
if (allCompletedAtCount !== null && userMessageCount - allCompletedAtCount >= AUTO_CLEAR_DELAY_ROUNDS) {
|
|
@@ -676,6 +676,7 @@ 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",
|
|
@@ -700,8 +701,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
700
701
|
],
|
|
701
702
|
parameters: TodoParams,
|
|
702
703
|
|
|
703
|
-
async execute(_toolCallId, params
|
|
704
|
-
const result =
|
|
704
|
+
async execute(_toolCallId: string, params: Static<typeof TodoParams>, _signal: AbortSignal | undefined, _onUpdate: any, ctx: ExtensionContext) {
|
|
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) {
|
|
@@ -714,7 +715,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
714
715
|
return result;
|
|
715
716
|
},
|
|
716
717
|
|
|
717
|
-
renderCall(args, theme, _context) {
|
|
718
|
+
renderCall(args: any, theme: Theme, _context?: any) {
|
|
718
719
|
let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
|
|
719
720
|
if (args.texts && args.texts.length > 0) text += ` ${theme.fg("dim", `(${args.texts.length} items)`)}`;
|
|
720
721
|
if (args.ids && args.ids.length > 0) text += ` ${theme.fg("accent", `#${args.ids.join(", #")}`)}`;
|
|
@@ -724,20 +725,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
724
725
|
return new Text(text, 0, 0);
|
|
725
726
|
},
|
|
726
727
|
|
|
727
|
-
renderResult(result, options, theme, _context) {
|
|
728
|
+
renderResult(result: any, options: any, theme: Theme, _context?: any) {
|
|
728
729
|
return renderTodoResult(result, options, theme);
|
|
729
730
|
},
|
|
730
731
|
});
|
|
731
732
|
|
|
733
|
+
// ── Command: /todos ─────────────────────────────────
|
|
732
734
|
pi.registerCommand("todos", {
|
|
733
735
|
description: "\u67e5\u770b\u5f53\u524d\u5206\u652f\u7684\u6240\u6709 todo",
|
|
734
|
-
handler: async (_args, ctx) => {
|
|
736
|
+
handler: async (_args: string | undefined, ctx: ExtensionCommandContext) => {
|
|
735
737
|
if (!ctx.hasUI) {
|
|
736
738
|
ctx.ui.notify("/todos \u9700\u8981\u4ea4\u4e92\u6a21\u5f0f", "error");
|
|
737
739
|
return;
|
|
738
740
|
}
|
|
739
741
|
|
|
740
|
-
await ctx.ui.custom
|
|
742
|
+
await ctx.ui.custom((_tui: any, theme: Theme, _kb: any, done: () => void) => {
|
|
741
743
|
return new TodoListComponent(todos, theme, () => done());
|
|
742
744
|
});
|
|
743
745
|
},
|