@zhushanwen/pi-todo 0.1.1
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/index.ts +1 -0
- package/package.json +24 -0
- package/src/index.ts +745 -0
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/index.ts";
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhushanwen/pi-todo",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "AI-driven todo list for Pi — stateful task management with session persistence and /todos command.",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi",
|
|
8
|
+
"extension",
|
|
9
|
+
"todo",
|
|
10
|
+
"task",
|
|
11
|
+
"list"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"files": [
|
|
15
|
+
"src/",
|
|
16
|
+
"index.ts"
|
|
17
|
+
],
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@mariozechner/pi-coding-agent": "*"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"typecheck": "npx tsc --noEmit"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Todo Extension v2 - 三态任务清单,支持状态栏和 entry GC
|
|
3
|
+
*
|
|
4
|
+
* 改动要点:
|
|
5
|
+
* - done: boolean → status: "pending" | "in_progress" | "completed"
|
|
6
|
+
* - toggle → update(id + 可选 status/text,带参数守卫)
|
|
7
|
+
* - 新增 delete action
|
|
8
|
+
* - 状态栏通过 ctx.ui.setStatus 显示进度
|
|
9
|
+
* - reconstructState 向后兼容旧 done 字段 + entry GC
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
13
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
14
|
+
import { matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
|
15
|
+
import { Type } from "typebox";
|
|
16
|
+
|
|
17
|
+
// ── 数据模型 ─────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
interface Todo {
|
|
20
|
+
id: number;
|
|
21
|
+
text: string;
|
|
22
|
+
status: "pending" | "in_progress" | "completed";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface TodoDetails {
|
|
26
|
+
action: "list" | "add" | "update" | "delete" | "clear";
|
|
27
|
+
todos: Todo[];
|
|
28
|
+
nextId: number;
|
|
29
|
+
error?: string;
|
|
30
|
+
_render?: {
|
|
31
|
+
type: "task-list";
|
|
32
|
+
summary?: string;
|
|
33
|
+
data: {
|
|
34
|
+
items: Array<{ id: number; text: string; status: string }>;
|
|
35
|
+
meta: Record<string, string>;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const VALID_STATUSES = ["pending", "in_progress", "completed"] as const;
|
|
41
|
+
|
|
42
|
+
const TodoParams = Type.Object({
|
|
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 ID(update 时使用)" })),
|
|
46
|
+
texts: Type.Optional(Type.Array(Type.String(), { description: "Todo 文本列表(add 时使用)" })),
|
|
47
|
+
ids: Type.Optional(Type.Array(Type.Number(), { description: "Todo ID 列表(delete 时使用)" })),
|
|
48
|
+
status: Type.Optional(
|
|
49
|
+
StringEnum(VALID_STATUSES, { description: "目标状态(update 时使用)" }),
|
|
50
|
+
),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ── 常量 ────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
const HEADER_PREFIX_DASHES = 3;
|
|
56
|
+
const HEADER_RESERVED_WIDTH = 10;
|
|
57
|
+
const MAX_COLLAPSED_ITEMS = 5;
|
|
58
|
+
|
|
59
|
+
// ── /todos 命令 TUI 组件 ─────────────────────────────
|
|
60
|
+
|
|
61
|
+
class TodoListComponent {
|
|
62
|
+
private todos: Todo[];
|
|
63
|
+
private theme: Theme;
|
|
64
|
+
private onClose: () => void;
|
|
65
|
+
private cachedWidth?: number;
|
|
66
|
+
private cachedLines?: string[];
|
|
67
|
+
|
|
68
|
+
constructor(todos: Todo[], theme: Theme, onClose: () => void) {
|
|
69
|
+
this.todos = todos;
|
|
70
|
+
this.theme = theme;
|
|
71
|
+
this.onClose = onClose;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
handleInput(data: string): void {
|
|
75
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
76
|
+
this.onClose();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
render(width: number): string[] {
|
|
81
|
+
if (this.cachedLines && this.cachedWidth === width) {
|
|
82
|
+
return this.cachedLines;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const lines: string[] = [];
|
|
86
|
+
const th = this.theme;
|
|
87
|
+
|
|
88
|
+
lines.push("");
|
|
89
|
+
const title = th.fg("accent", " Todos ");
|
|
90
|
+
const headerLine =
|
|
91
|
+
th.fg("borderMuted", "\u2500".repeat(HEADER_PREFIX_DASHES)) + title + th.fg("borderMuted", "\u2500".repeat(Math.max(0, width - HEADER_RESERVED_WIDTH)));
|
|
92
|
+
lines.push(truncateToWidth(headerLine, width));
|
|
93
|
+
lines.push("");
|
|
94
|
+
|
|
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));
|
|
97
|
+
} else {
|
|
98
|
+
const completed = this.todos.filter((t) => t.status === "completed").length;
|
|
99
|
+
const total = this.todos.length;
|
|
100
|
+
lines.push(truncateToWidth(` ${th.fg("muted", `${completed}/${total} \u5df2\u5b8c\u6210`)}`, width));
|
|
101
|
+
lines.push("");
|
|
102
|
+
|
|
103
|
+
for (const todo of this.todos) {
|
|
104
|
+
const mark =
|
|
105
|
+
todo.status === "completed"
|
|
106
|
+
? th.fg("success", "\u2713")
|
|
107
|
+
: todo.status === "in_progress"
|
|
108
|
+
? th.fg("warning", "\u25cf")
|
|
109
|
+
: th.fg("dim", "\u25cb");
|
|
110
|
+
const id = th.fg("accent", `#${todo.id}`);
|
|
111
|
+
const text = todo.status === "completed" ? th.fg("dim", todo.text) : th.fg("text", todo.text);
|
|
112
|
+
lines.push(truncateToWidth(` ${mark} ${id} ${text}`, width));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
lines.push("");
|
|
117
|
+
lines.push(truncateToWidth(` ${th.fg("dim", "\u6309 Escape \u5173\u95ed")}`, width));
|
|
118
|
+
lines.push("");
|
|
119
|
+
|
|
120
|
+
this.cachedWidth = width;
|
|
121
|
+
this.cachedLines = lines;
|
|
122
|
+
return lines;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
invalidate(): void {
|
|
126
|
+
this.cachedWidth = undefined;
|
|
127
|
+
this.cachedLines = undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── 辅助函数 ─────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/** 兼容旧格式:旧 entry 可能有 done: boolean,转换为 status */
|
|
134
|
+
function migrateTodo(raw: Todo): Todo {
|
|
135
|
+
const record = raw as unknown as Record<string, unknown>;
|
|
136
|
+
if (typeof record.status === "string" && VALID_STATUSES.includes(record.status as Todo["status"])) {
|
|
137
|
+
return raw;
|
|
138
|
+
}
|
|
139
|
+
// 旧格式兜底:done → completed,否则 pending
|
|
140
|
+
const { done, ...rest } = record as unknown as { done?: boolean; id: number; text: string };
|
|
141
|
+
return { id: rest.id, text: rest.text, status: done === true ? "completed" : "pending" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 渲染层获取状态:先 migrate 再取 status */
|
|
145
|
+
function getDisplayStatus(t: Todo): string {
|
|
146
|
+
return migrateTodo(t).status;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 渲染状态栏文本 */
|
|
150
|
+
function renderStatusText(todoList: Todo[], th: Theme): string {
|
|
151
|
+
if (todoList.length === 0) return "";
|
|
152
|
+
|
|
153
|
+
const completed = todoList.filter((t) => getDisplayStatus(t) === "completed").length;
|
|
154
|
+
const total = todoList.length;
|
|
155
|
+
|
|
156
|
+
// 全部完成
|
|
157
|
+
if (completed === total) {
|
|
158
|
+
return th.fg("success", `\u2713 ${completed}/${total}`);
|
|
159
|
+
}
|
|
160
|
+
// 有未完成
|
|
161
|
+
return th.fg("accent", "\u2611") + th.fg("muted", ` ${completed}/${total}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 渲染 widget 行 */
|
|
165
|
+
function renderWidgetLines(todoList: Todo[], th: Theme): string[] {
|
|
166
|
+
if (todoList.length === 0) return [];
|
|
167
|
+
|
|
168
|
+
const lines: string[] = [];
|
|
169
|
+
const completed = todoList.filter((t) => getDisplayStatus(t) === "completed").length;
|
|
170
|
+
const total = todoList.length;
|
|
171
|
+
|
|
172
|
+
lines.push(th.fg("accent", "\u2611") + th.fg("muted", ` ${completed}/${total}`));
|
|
173
|
+
|
|
174
|
+
for (const t of todoList) {
|
|
175
|
+
const mark =
|
|
176
|
+
t.status === "completed"
|
|
177
|
+
? th.fg("success", "\u2713")
|
|
178
|
+
: t.status === "in_progress"
|
|
179
|
+
? th.fg("warning", "\u25cf")
|
|
180
|
+
: th.fg("dim", "\u25cb");
|
|
181
|
+
const id = th.fg("accent", `#${t.id}`);
|
|
182
|
+
const text = t.status === "completed" ? th.fg("dim", t.text) : th.fg("text", t.text);
|
|
183
|
+
lines.push(` ${mark} ${id} ${text}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return lines;
|
|
187
|
+
}
|
|
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
|
+
/** 构建 _render 描述符 */
|
|
222
|
+
function buildRender(todoList: Todo[]): TodoDetails["_render"] {
|
|
223
|
+
const completed = todoList.filter((t) => t.status === "completed").length;
|
|
224
|
+
const total = todoList.length;
|
|
225
|
+
return {
|
|
226
|
+
type: "task-list" as const,
|
|
227
|
+
summary: `${completed}/${total} 已完成`,
|
|
228
|
+
data: {
|
|
229
|
+
items: todoList.map((t) => ({ id: t.id, text: t.text, status: t.status })),
|
|
230
|
+
meta: {},
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
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
|
+
}
|
|
480
|
+
|
|
481
|
+
// ── 列表渲染辅助函数 ─────────────────────────────────
|
|
482
|
+
|
|
483
|
+
function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, theme: Theme): string {
|
|
484
|
+
if (todoList.length === 0) {
|
|
485
|
+
return theme.fg("dim", "\u6682\u65e0 todo");
|
|
486
|
+
}
|
|
487
|
+
let listText = theme.fg("muted", `${todoList.length} \u9879 todo\uff1a`);
|
|
488
|
+
const display = options.expanded ? todoList : todoList.slice(0, MAX_COLLAPSED_ITEMS);
|
|
489
|
+
for (const t of display) {
|
|
490
|
+
const status = getDisplayStatus(t);
|
|
491
|
+
const mark =
|
|
492
|
+
status === "completed"
|
|
493
|
+
? theme.fg("success", "\u2713")
|
|
494
|
+
: status === "in_progress"
|
|
495
|
+
? theme.fg("warning", "\u25cf")
|
|
496
|
+
: theme.fg("dim", "\u25cb");
|
|
497
|
+
const itemText =
|
|
498
|
+
status === "completed" ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
|
|
499
|
+
listText += `\n${mark} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
|
|
500
|
+
}
|
|
501
|
+
if (!options.expanded && todoList.length > MAX_COLLAPSED_ITEMS) {
|
|
502
|
+
listText += `\n${theme.fg("dim", `... \u8fd8\u6709 ${todoList.length - MAX_COLLAPSED_ITEMS} \u9879`)}`;
|
|
503
|
+
}
|
|
504
|
+
return listText;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ── Tool renderResult handler ────────────────────────
|
|
508
|
+
|
|
509
|
+
function renderTodoResult(result: unknown, options: { expanded: boolean }, theme: Theme): Text {
|
|
510
|
+
const r = result as { content: Array<{ type: string; text?: string }>; details?: unknown };
|
|
511
|
+
const details = r.details as TodoDetails | undefined;
|
|
512
|
+
if (!details) {
|
|
513
|
+
const text = r.content[0];
|
|
514
|
+
return new Text(text?.type === "text" ? (text.text ?? "") : "", 0, 0);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (details.error) {
|
|
518
|
+
return new Text(theme.fg("error", `\u9519\u8bef: ${details.error}`), 0, 0);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const todoList = details.todos;
|
|
522
|
+
|
|
523
|
+
switch (details.action) {
|
|
524
|
+
case "list": {
|
|
525
|
+
return new Text(buildTodoListText(todoList, options, theme), 0, 0);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
case "add": {
|
|
529
|
+
const text = r.content[0];
|
|
530
|
+
const msg = text?.type === "text" ? (text.text ?? "") : "";
|
|
531
|
+
const listText = buildTodoListText(todoList, options, theme);
|
|
532
|
+
return new Text(
|
|
533
|
+
theme.fg("success", "\u2713 ") + theme.fg("muted", msg) + "\n\n" + listText,
|
|
534
|
+
0,
|
|
535
|
+
0,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
case "update":
|
|
540
|
+
case "delete":
|
|
541
|
+
case "clear": {
|
|
542
|
+
const text = r.content[0];
|
|
543
|
+
const msg = text?.type === "text" ? (text.text ?? "") : "";
|
|
544
|
+
const listText = buildTodoListText(todoList, options, theme);
|
|
545
|
+
return new Text(
|
|
546
|
+
theme.fg("success", "\u2713 ") + theme.fg("muted", msg) + "\n\n" + listText,
|
|
547
|
+
0,
|
|
548
|
+
0,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
default: {
|
|
553
|
+
const text = r.content[0];
|
|
554
|
+
const msg = text?.type === "text" ? (text.text ?? "") : "";
|
|
555
|
+
return new Text(theme.fg("dim", msg || "\u5b8c\u6210"), 0, 0);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ── 扩展入口 ─────────────────────────────────────────
|
|
561
|
+
|
|
562
|
+
export default function (pi: ExtensionAPI) {
|
|
563
|
+
const reconstructState = (ctx: ExtensionContext) => {
|
|
564
|
+
todos = [];
|
|
565
|
+
nextId = 1;
|
|
566
|
+
|
|
567
|
+
// v3: 重置提醒追踪状态
|
|
568
|
+
userMessageCount = 0;
|
|
569
|
+
allCompletedAtCount = null;
|
|
570
|
+
lastTodoCallCount = 0;
|
|
571
|
+
lastReminderCount = 0;
|
|
572
|
+
|
|
573
|
+
const entries = ctx.sessionManager.getEntries();
|
|
574
|
+
let latestIdx = -1;
|
|
575
|
+
|
|
576
|
+
for (let i = 0; i < entries.length; i++) {
|
|
577
|
+
const entry = entries[i];
|
|
578
|
+
if (entry.type !== "message") continue;
|
|
579
|
+
const msg = entry.message;
|
|
580
|
+
if (msg.role !== "toolResult" || msg.toolName !== "todo") continue;
|
|
581
|
+
|
|
582
|
+
const details = msg.details as TodoDetails | undefined;
|
|
583
|
+
if (details?.todos && Array.isArray(details.todos)) {
|
|
584
|
+
todos = details.todos.map((t) => migrateTodo(t));
|
|
585
|
+
nextId = details.nextId ?? (todos.length > 0 ? Math.max(...todos.map((t) => t.id)) + 1 : 1);
|
|
586
|
+
latestIdx = i;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (latestIdx >= 0) {
|
|
591
|
+
const staleIndices: number[] = [];
|
|
592
|
+
for (let i = 0; i < latestIdx; i++) {
|
|
593
|
+
const entry = entries[i];
|
|
594
|
+
if (entry.type !== "message") continue;
|
|
595
|
+
const msg = entry.message;
|
|
596
|
+
if (msg.role === "toolResult" && msg.toolName === "todo") {
|
|
597
|
+
staleIndices.push(i);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
for (let j = staleIndices.length - 1; j >= 0; j--) {
|
|
601
|
+
entries.splice(staleIndices[j], 1);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
607
|
+
reconstructState(ctx);
|
|
608
|
+
refreshDisplay(ctx);
|
|
609
|
+
});
|
|
610
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
611
|
+
reconstructState(ctx);
|
|
612
|
+
refreshDisplay(ctx);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// v3: 追踪用户消息轮数
|
|
616
|
+
pi.on("agent_start", async (_event, _ctx) => {
|
|
617
|
+
userMessageCount++;
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
// v3: 自动清空与提醒检查
|
|
621
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
622
|
+
try {
|
|
623
|
+
// 1. 自动清空:全部完成后经过 2 轮用户消息
|
|
624
|
+
if (allCompletedAtCount !== null && userMessageCount - allCompletedAtCount >= AUTO_CLEAR_DELAY_ROUNDS) {
|
|
625
|
+
const count = todos.length;
|
|
626
|
+
todos = [];
|
|
627
|
+
nextId = 1;
|
|
628
|
+
allCompletedAtCount = null;
|
|
629
|
+
refreshDisplay(ctx);
|
|
630
|
+
return {
|
|
631
|
+
message: {
|
|
632
|
+
customType: "todo-auto-clear",
|
|
633
|
+
content: `所有 ${count} 个 todo 已完成,列表已自动清空。`,
|
|
634
|
+
display: true,
|
|
635
|
+
},
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// 2. Verification Nudge:完成 3+ 任务且无验证步骤
|
|
640
|
+
if (
|
|
641
|
+
allCompletedAtCount !== null &&
|
|
642
|
+
todos.length >= VERIFICATION_NUDGE_THRESHOLD &&
|
|
643
|
+
!todos.some((t) => /verif|验证/i.test(t.text))
|
|
644
|
+
) {
|
|
645
|
+
lastReminderCount = userMessageCount;
|
|
646
|
+
return {
|
|
647
|
+
message: {
|
|
648
|
+
customType: "todo-verification-nudge",
|
|
649
|
+
content: "你刚完成了 3+ 个任务但没有验证步骤。建议在总结前添加验证任务。",
|
|
650
|
+
display: true,
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// 3. Todo Reminder:10 轮未调用 todo 工具
|
|
656
|
+
if (
|
|
657
|
+
todos.length > 0 &&
|
|
658
|
+
allCompletedAtCount === null &&
|
|
659
|
+
userMessageCount - lastTodoCallCount >= TODO_REMINDER_INTERVAL &&
|
|
660
|
+
userMessageCount - lastReminderCount >= TODO_REMINDER_INTERVAL
|
|
661
|
+
) {
|
|
662
|
+
lastReminderCount = userMessageCount;
|
|
663
|
+
return {
|
|
664
|
+
message: {
|
|
665
|
+
customType: "todo-reminder",
|
|
666
|
+
content: "Todo 工具最近没有被使用。如果你在处理任务,建议使用它来跟踪进度。",
|
|
667
|
+
display: true,
|
|
668
|
+
},
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return undefined;
|
|
673
|
+
} catch {
|
|
674
|
+
// v3: 提醒/清空非关键路径,异常时静默降级不影响 agent 循环
|
|
675
|
+
return undefined;
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
pi.registerTool({
|
|
680
|
+
name: "todo",
|
|
681
|
+
label: "Todo",
|
|
682
|
+
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",
|
|
691
|
+
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,两者定位不同",
|
|
700
|
+
],
|
|
701
|
+
parameters: TodoParams,
|
|
702
|
+
|
|
703
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
704
|
+
const result = await executeTodoAction(params, ctx);
|
|
705
|
+
// Append input params to error results for debugging
|
|
706
|
+
const details = result.details as { error?: string } | undefined;
|
|
707
|
+
if (details?.error) {
|
|
708
|
+
const textPart = result.content[0];
|
|
709
|
+
if (textPart?.type === "text") {
|
|
710
|
+
const inputSummary = JSON.stringify(params);
|
|
711
|
+
textPart.text += `\nInput: ${inputSummary}`;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return result;
|
|
715
|
+
},
|
|
716
|
+
|
|
717
|
+
renderCall(args, theme, _context) {
|
|
718
|
+
let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
|
|
719
|
+
if (args.texts && args.texts.length > 0) text += ` ${theme.fg("dim", `(${args.texts.length} items)`)}`;
|
|
720
|
+
if (args.ids && args.ids.length > 0) text += ` ${theme.fg("accent", `#${args.ids.join(", #")}`)}`;
|
|
721
|
+
if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
|
|
722
|
+
if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
|
|
723
|
+
if (args.status) text += ` ${theme.fg("warning", args.status)}`;
|
|
724
|
+
return new Text(text, 0, 0);
|
|
725
|
+
},
|
|
726
|
+
|
|
727
|
+
renderResult(result, options, theme, _context) {
|
|
728
|
+
return renderTodoResult(result, options, theme);
|
|
729
|
+
},
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
pi.registerCommand("todos", {
|
|
733
|
+
description: "\u67e5\u770b\u5f53\u524d\u5206\u652f\u7684\u6240\u6709 todo",
|
|
734
|
+
handler: async (_args, ctx) => {
|
|
735
|
+
if (!ctx.hasUI) {
|
|
736
|
+
ctx.ui.notify("/todos \u9700\u8981\u4ea4\u4e92\u6a21\u5f0f", "error");
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
|
|
741
|
+
return new TodoListComponent(todos, theme, () => done());
|
|
742
|
+
});
|
|
743
|
+
},
|
|
744
|
+
});
|
|
745
|
+
}
|