@zhushanwen/pi-todo 0.2.0 → 0.4.0
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 +87 -24
- package/package.json +4 -1
- package/src/__tests__/gui.test.ts +39 -0
- package/src/__tests__/steer.test.ts +254 -0
- package/src/__tests__/todo.test.ts +72 -22
- package/src/__tests__/tool-rpc.test.ts +285 -0
- package/src/component.ts +1 -1
- package/src/handlers.ts +6 -11
- package/src/index.ts +20 -18
- package/src/model.ts +68 -34
- package/src/render.ts +14 -15
- package/src/tool.ts +96 -212
package/src/tool.ts
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* Todo tool 注册 + execute dispatcher + 5 个 action handler。
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { StringEnum } from "@
|
|
5
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
6
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
6
7
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
7
|
-
import { Text } from "@mariozechner/pi-tui";
|
|
8
8
|
import { type Static, Type } from "typebox";
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
addTodos,
|
|
12
|
-
|
|
12
|
+
buildGui,
|
|
13
13
|
formatTodoLine,
|
|
14
14
|
type Todo,
|
|
15
15
|
type TodoDetails,
|
|
@@ -28,58 +28,44 @@ export interface TodoActionParams {
|
|
|
28
28
|
texts?: string[];
|
|
29
29
|
ids?: number[];
|
|
30
30
|
status?: string;
|
|
31
|
+
isVerification?: boolean;
|
|
31
32
|
updates?: Array<{ id: number; status?: string; text?: string }>;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
// ── TodoParams schema ────────────────────────────────
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
const TodoParams = Type.Object({
|
|
37
38
|
action: StringEnum(["list", "add", "update", "delete", "clear"] as const),
|
|
38
39
|
text: Type.Optional(Type.String({ description: "Todo text (for update action)" })),
|
|
39
40
|
id: Type.Optional(Type.Number({ description: "Todo ID (for update action)" })),
|
|
40
41
|
texts: Type.Optional(Type.Array(Type.String(), { description: "Todo text list (for add action)" })),
|
|
41
42
|
ids: Type.Optional(Type.Array(Type.Number(), { description: "Todo ID list (for delete action)" })),
|
|
42
43
|
status: Type.Optional(
|
|
43
|
-
|
|
44
|
+
StringEnum(VALID_STATUSES, { description: "Target status (for update action)" }),
|
|
45
|
+
),
|
|
46
|
+
isVerification: Type.Optional(
|
|
47
|
+
Type.Boolean({
|
|
48
|
+
description: "Mark added todos as verification tasks (for add action). Verification todos must be completed (not cancelled) before goal completion.",
|
|
49
|
+
}),
|
|
44
50
|
),
|
|
45
51
|
updates: Type.Optional(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
Type.Array(
|
|
53
|
+
Type.Object({
|
|
54
|
+
id: Type.Number({ description: "Todo ID to update" }),
|
|
55
|
+
status: Type.Optional(
|
|
56
|
+
Type.String({ description: "Target status; one of pending/in_progress/completed/cancelled" }),
|
|
57
|
+
),
|
|
58
|
+
text: Type.Optional(Type.String({ description: "New todo text" })),
|
|
59
|
+
}),
|
|
60
|
+
{ description: "Batch updates array (takes priority over single id/status/text)" },
|
|
61
|
+
),
|
|
55
62
|
),
|
|
56
|
-
)
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
// ── 错误结果构造 helper ──────────────────────────────
|
|
60
|
-
|
|
61
|
-
function errorResult(
|
|
62
|
-
action: TodoDetails["action"],
|
|
63
|
-
state: TodoSessionState,
|
|
64
|
-
errorText: string,
|
|
65
|
-
errorCode: string,
|
|
66
|
-
): {
|
|
67
|
-
content: Array<{ type: "text"; text: string }>;
|
|
68
|
-
details: TodoDetails;
|
|
69
|
-
} {
|
|
70
|
-
return {
|
|
71
|
-
content: [{ type: "text" as const, text: errorText }],
|
|
72
|
-
details: {
|
|
73
|
-
action,
|
|
74
|
-
todos: [...state.todos],
|
|
75
|
-
nextId: state.nextId,
|
|
76
|
-
error: errorCode,
|
|
77
|
-
_render: buildRender(state.todos),
|
|
78
|
-
} as TodoDetails,
|
|
79
|
-
};
|
|
80
|
-
}
|
|
63
|
+
});
|
|
81
64
|
|
|
82
65
|
// ── 5 个 action handler ──────────────────────────────
|
|
66
|
+
// 错误处理约定(见 CLAUDE.md「Tool 设计」):handler 失败直接 throw,
|
|
67
|
+
// 不返回「错误成功模式」。model 层纯函数返回 Result 对象(合法),
|
|
68
|
+
// 由 dispatcher 在拿到 error 时 throw,把友好文案交给 Pi 框架展示。
|
|
83
69
|
|
|
84
70
|
/** list action */
|
|
85
71
|
function handleList(state: TodoSessionState): string {
|
|
@@ -88,76 +74,53 @@ function handleList(state: TodoSessionState): string {
|
|
|
88
74
|
: "No todos";
|
|
89
75
|
}
|
|
90
76
|
|
|
91
|
-
/** add action */
|
|
92
|
-
function handleAdd(
|
|
93
|
-
state: TodoSessionState,
|
|
94
|
-
params: TodoActionParams,
|
|
95
|
-
): { resultText: string; error?: string } {
|
|
77
|
+
/** add action — 失败抛错 */
|
|
78
|
+
function handleAdd(state: TodoSessionState, params: TodoActionParams): string {
|
|
96
79
|
if (!params.texts || params.texts.length === 0) {
|
|
97
|
-
|
|
80
|
+
throw new Error("add requires texts parameter (non-empty array)");
|
|
98
81
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
state.todos = addResult.newTodos;
|
|
106
|
-
state.nextId = addResult.newNextId;
|
|
107
|
-
return { resultText: addResult.resultText || "" };
|
|
82
|
+
const r = addTodos(state.todos, state.nextId, params.texts, params.isVerification);
|
|
83
|
+
if (r.error) throw new Error(r.resultText);
|
|
84
|
+
state.todos = r.newTodos;
|
|
85
|
+
state.nextId = r.newNextId;
|
|
86
|
+
return r.resultText!;
|
|
108
87
|
}
|
|
109
88
|
|
|
110
|
-
/** update action: batch */
|
|
111
|
-
function handleBatchUpdate(
|
|
112
|
-
state
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (result.error) {
|
|
117
|
-
return {
|
|
118
|
-
resultText: result.resultText || "",
|
|
119
|
-
error: result.error,
|
|
120
|
-
earlyReturn: {
|
|
121
|
-
content: [{ type: "text" as const, text: result.resultText || "" }],
|
|
122
|
-
details: {
|
|
123
|
-
action: "update" as const,
|
|
124
|
-
todos: [...state.todos],
|
|
125
|
-
nextId: state.nextId,
|
|
126
|
-
error: result.error,
|
|
127
|
-
_render: buildRender(state.todos),
|
|
128
|
-
} as TodoDetails,
|
|
129
|
-
},
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
state.todos = result.updatedTodos;
|
|
133
|
-
return { resultText: result.resultText || "" };
|
|
89
|
+
/** update action: batch — 失败抛错 */
|
|
90
|
+
function handleBatchUpdate(state: TodoSessionState, params: TodoActionParams): string {
|
|
91
|
+
const r = updateTodos(state.todos, params.updates ?? []);
|
|
92
|
+
if (r.error) throw new Error(r.resultText);
|
|
93
|
+
state.todos = r.updatedTodos;
|
|
94
|
+
return r.resultText!;
|
|
134
95
|
}
|
|
135
96
|
|
|
136
|
-
/** update action: single */
|
|
137
|
-
function handleSingleUpdate(
|
|
138
|
-
|
|
139
|
-
params
|
|
140
|
-
|
|
141
|
-
if (params.
|
|
142
|
-
if (params.status === undefined && params.text === undefined) return { resultText: "", error: "need status or text" };
|
|
143
|
-
if (params.text !== undefined && params.text === "") return { resultText: "", error: "text empty" };
|
|
97
|
+
/** update action: single — 失败抛错 */
|
|
98
|
+
export function handleSingleUpdate(state: TodoSessionState, params: TodoActionParams): string {
|
|
99
|
+
if (params.id === undefined) throw new Error("update requires id parameter");
|
|
100
|
+
if (params.status === undefined && params.text === undefined)
|
|
101
|
+
throw new Error("update requires at least status or text parameter");
|
|
102
|
+
if (params.text !== undefined && params.text === "") throw new Error("text cannot be empty string");
|
|
144
103
|
if (
|
|
145
104
|
params.status !== undefined &&
|
|
146
105
|
!VALID_STATUSES.includes(params.status as (typeof VALID_STATUSES)[number])
|
|
147
106
|
) {
|
|
148
|
-
|
|
107
|
+
throw new Error(`status only accepts ${VALID_STATUSES.join(" / ")}`);
|
|
149
108
|
}
|
|
150
109
|
|
|
151
110
|
const todo = state.todos.find((t) => t.id === params.id);
|
|
152
|
-
if (!todo)
|
|
111
|
+
if (!todo) throw new Error(`Todo #${params.id} not found`);
|
|
153
112
|
|
|
154
|
-
|
|
155
|
-
|
|
113
|
+
// FR-6 不变量守卫(失败抛错):(a) cancelled 不可恢复;(b) 验证任务不可 cancelled
|
|
114
|
+
if (todo.status === "cancelled" && params.status !== undefined) {
|
|
115
|
+
throw new Error(`#${params.id} is cancelled (cannot restore)`);
|
|
156
116
|
}
|
|
157
|
-
if (params.
|
|
158
|
-
|
|
117
|
+
if (todo.isVerification && params.status === "cancelled") {
|
|
118
|
+
throw new Error(`#${params.id} is verification todo (cannot cancel)`);
|
|
159
119
|
}
|
|
160
120
|
|
|
121
|
+
if (params.status !== undefined) todo.status = params.status as Todo["status"];
|
|
122
|
+
if (params.text !== undefined) todo.text = params.text;
|
|
123
|
+
|
|
161
124
|
const parts: string[] = [`Updated todo #${todo.id}`];
|
|
162
125
|
if (params.status !== undefined) parts.push(`status → ${params.status}`);
|
|
163
126
|
if (params.text !== undefined) parts.push(`text → "${todo.text}"`);
|
|
@@ -165,36 +128,26 @@ function handleSingleUpdate(
|
|
|
165
128
|
// 最后一个完成提示
|
|
166
129
|
const incompleteAfter = state.todos.filter((t) => t.status !== "completed");
|
|
167
130
|
if (params.status === "completed" && incompleteAfter.length === 0) {
|
|
168
|
-
return
|
|
131
|
+
return parts.join(", ") + "\n\nAll todos completed. Please summarize your work.";
|
|
169
132
|
}
|
|
170
|
-
return
|
|
133
|
+
return parts.join(", ");
|
|
171
134
|
}
|
|
172
135
|
|
|
173
|
-
/** update action: dispatcher */
|
|
174
|
-
function handleUpdate(
|
|
175
|
-
state
|
|
176
|
-
params: TodoActionParams,
|
|
177
|
-
):
|
|
178
|
-
| { resultText: string; error?: string; earlyReturn?: { content: Array<{ type: "text"; text: string }>; details: TodoDetails } }
|
|
179
|
-
| undefined {
|
|
180
|
-
if (params.updates && params.updates.length > 0) {
|
|
181
|
-
return handleBatchUpdate(state, params);
|
|
182
|
-
}
|
|
136
|
+
/** update action: dispatcher — batch 优先于 single */
|
|
137
|
+
function handleUpdate(state: TodoSessionState, params: TodoActionParams): string {
|
|
138
|
+
if (params.updates && params.updates.length > 0) return handleBatchUpdate(state, params);
|
|
183
139
|
return handleSingleUpdate(state, params);
|
|
184
140
|
}
|
|
185
141
|
|
|
186
|
-
/** delete action */
|
|
187
|
-
function handleDelete(
|
|
188
|
-
state: TodoSessionState,
|
|
189
|
-
params: TodoActionParams,
|
|
190
|
-
): { resultText: string; error?: string } {
|
|
142
|
+
/** delete action — 失败抛错;部分 id 缺失则整体拒绝(原子性) */
|
|
143
|
+
function handleDelete(state: TodoSessionState, params: TodoActionParams): string {
|
|
191
144
|
if (!params.ids || params.ids.length === 0) {
|
|
192
|
-
|
|
145
|
+
throw new Error("delete requires ids parameter (non-empty array)");
|
|
193
146
|
}
|
|
194
147
|
const uniqueIds = [...new Set(params.ids)];
|
|
195
148
|
const missing = uniqueIds.filter((id) => !state.todos.some((t) => t.id === id));
|
|
196
149
|
if (missing.length > 0) {
|
|
197
|
-
|
|
150
|
+
throw new Error(`Todo #${missing.join(", #")} not found`);
|
|
198
151
|
}
|
|
199
152
|
const removedIds: number[] = [];
|
|
200
153
|
for (const id of uniqueIds) {
|
|
@@ -204,7 +157,7 @@ function handleDelete(
|
|
|
204
157
|
removedIds.push(id);
|
|
205
158
|
}
|
|
206
159
|
}
|
|
207
|
-
return
|
|
160
|
+
return `Deleted ${removedIds.length} items (#${removedIds.join(", #")}), ${state.todos.length} remaining`;
|
|
208
161
|
}
|
|
209
162
|
|
|
210
163
|
/** clear action */
|
|
@@ -219,7 +172,7 @@ function handleClear(state: TodoSessionState): string {
|
|
|
219
172
|
|
|
220
173
|
// ── Dispatcher ───────────────────────────────────────
|
|
221
174
|
|
|
222
|
-
|
|
175
|
+
function executeTodoAction(
|
|
223
176
|
params: TodoActionParams,
|
|
224
177
|
state: TodoSessionState,
|
|
225
178
|
ctx: ExtensionContext,
|
|
@@ -231,94 +184,45 @@ export function executeTodoAction(
|
|
|
231
184
|
state.lastTodoCallCount = state.userMessageCount;
|
|
232
185
|
state.stallNotified = false;
|
|
233
186
|
|
|
234
|
-
let resultText
|
|
235
|
-
|
|
187
|
+
let resultText: string;
|
|
236
188
|
switch (params.action) {
|
|
237
|
-
case "list":
|
|
189
|
+
case "list":
|
|
238
190
|
resultText = handleList(state);
|
|
239
191
|
break;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
case "add": {
|
|
243
|
-
const r = handleAdd(state, params);
|
|
244
|
-
if (r.error === "texts required") {
|
|
245
|
-
return errorResult("add", state, "Error: add requires texts parameter (non-empty array)", r.error);
|
|
246
|
-
}
|
|
247
|
-
if (r.error) {
|
|
248
|
-
return errorResult("add", state, r.resultText, r.error);
|
|
249
|
-
}
|
|
250
|
-
resultText = r.resultText;
|
|
192
|
+
case "add":
|
|
193
|
+
resultText = handleAdd(state, params);
|
|
251
194
|
break;
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
case "update": {
|
|
255
|
-
const r = handleUpdate(state, params);
|
|
256
|
-
if (!r) {
|
|
257
|
-
resultText = "Unknown error";
|
|
258
|
-
break;
|
|
259
|
-
}
|
|
260
|
-
if (r.earlyReturn) return r.earlyReturn;
|
|
261
|
-
if (r.error) {
|
|
262
|
-
const errorText = mapUpdateErrorText(state, params, r.error);
|
|
263
|
-
return errorResult("update", state, errorText, r.error);
|
|
264
|
-
}
|
|
265
|
-
resultText = r.resultText;
|
|
195
|
+
case "update":
|
|
196
|
+
resultText = handleUpdate(state, params);
|
|
266
197
|
break;
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
case "delete": {
|
|
270
|
-
const r = handleDelete(state, params);
|
|
271
|
-
if (r.error === "ids required") {
|
|
272
|
-
return errorResult("delete", state, "Error: delete requires ids parameter (non-empty array)", r.error);
|
|
273
|
-
}
|
|
274
|
-
if (r.error) {
|
|
275
|
-
return errorResult("delete", state, `Error: Todo ${r.error.replace(/^#/, "#")}`, r.error);
|
|
276
|
-
}
|
|
277
|
-
resultText = r.resultText;
|
|
198
|
+
case "delete":
|
|
199
|
+
resultText = handleDelete(state, params);
|
|
278
200
|
break;
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
case "clear": {
|
|
201
|
+
case "clear":
|
|
282
202
|
resultText = handleClear(state);
|
|
283
203
|
break;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
204
|
default:
|
|
287
|
-
|
|
205
|
+
throw new Error(`Unknown action: ${params.action}`);
|
|
288
206
|
}
|
|
289
207
|
|
|
290
208
|
refreshDisplay(ctx);
|
|
291
209
|
|
|
210
|
+
const details: TodoDetails = {
|
|
211
|
+
action: params.action as TodoDetails["action"],
|
|
212
|
+
todos: [...state.todos],
|
|
213
|
+
nextId: state.nextId,
|
|
214
|
+
};
|
|
215
|
+
// RPC 模式(xyz-agent GUI)附加 __gui__,前端按 list-tree 渲染。
|
|
216
|
+
// TUI/print/json 模式走原生文本渲染(resultText 已在 content 中)。
|
|
217
|
+
if (ctx.mode === "rpc") {
|
|
218
|
+
details.__gui__ = buildGui(state.todos);
|
|
219
|
+
}
|
|
292
220
|
return {
|
|
293
221
|
content: [{ type: "text" as const, text: resultText }],
|
|
294
|
-
details
|
|
295
|
-
action: params.action as TodoDetails["action"],
|
|
296
|
-
todos: [...state.todos],
|
|
297
|
-
nextId: state.nextId,
|
|
298
|
-
_render: buildRender(state.todos),
|
|
299
|
-
} as TodoDetails,
|
|
222
|
+
details,
|
|
300
223
|
};
|
|
301
224
|
}
|
|
302
225
|
|
|
303
|
-
function mapUpdateErrorText(state: TodoSessionState, _params: TodoActionParams, code: string): string {
|
|
304
|
-
switch (code) {
|
|
305
|
-
case "id required":
|
|
306
|
-
return "Error: update requires id parameter";
|
|
307
|
-
case "need status or text":
|
|
308
|
-
return "Error: update requires at least status or text parameter";
|
|
309
|
-
case "text empty":
|
|
310
|
-
return "Error: text cannot be empty string";
|
|
311
|
-
default:
|
|
312
|
-
if (code.startsWith("invalid status:")) {
|
|
313
|
-
return `Error: status only accepts ${VALID_STATUSES.join(" / ")}`;
|
|
314
|
-
}
|
|
315
|
-
if (code.startsWith("#") && code.includes("not found")) {
|
|
316
|
-
return `Error: Todo ${code} not found`;
|
|
317
|
-
}
|
|
318
|
-
return `Error: ${code}`;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
226
|
// ── Tool 注册入口 ─────────────────────────────────────
|
|
323
227
|
|
|
324
228
|
export function registerTodoTool(
|
|
@@ -333,44 +237,24 @@ export function registerTodoTool(
|
|
|
333
237
|
"Manage a todo list." +
|
|
334
238
|
"\n\nAvailable actions:" +
|
|
335
239
|
"\n- list: View all todos" +
|
|
336
|
-
"\n- add: Batch add todos (requires texts array)" +
|
|
337
|
-
"\n- update: Update
|
|
240
|
+
"\n- add: Batch add todos (requires texts array; optional isVerification marks verification tasks)" +
|
|
241
|
+
"\n- update: Update todo(s) — single (id + optional status/text) or batch (updates[], takes priority)" +
|
|
338
242
|
"\n- delete: Batch delete todos (requires ids array)" +
|
|
339
|
-
"\n- clear: Clear all todos and reset IDs"
|
|
340
|
-
|
|
341
|
-
promptSnippet: "Use todo when breaking multi-step work into trackable items during normal (non-goal) conversation. Not for single-step operations.",
|
|
243
|
+
"\n- clear: Clear all todos and reset IDs",
|
|
244
|
+
promptSnippet: "Use todo when breaking multi-step work into trackable items. Add verification todos (isVerification=true) for checks like running tests.",
|
|
342
245
|
promptGuidelines: [
|
|
343
246
|
"[Usage] 多步骤工作(3+步)时使用。AI 自发创建,无需用户触发",
|
|
344
|
-
"[
|
|
247
|
+
"[验证任务] 执行任务 + 验证任务(isVerification=true,如 run tests / typecheck)一起建",
|
|
345
248
|
"[批量优先] 完成多项任务时使用 updates[] 批量更新,减少工具调用次数",
|
|
346
249
|
"[自动闭合] 全部完成后工具自动清理,无需手动 clear",
|
|
347
|
-
"[Not for]
|
|
250
|
+
"[Not for] 单步操作、简单对话",
|
|
348
251
|
],
|
|
252
|
+
executionMode: "sequential",
|
|
349
253
|
parameters: TodoParams,
|
|
350
254
|
|
|
351
255
|
async execute(_toolCallId: string, params: Static<typeof TodoParams>, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
|
|
352
|
-
if (signal?.aborted)
|
|
353
|
-
|
|
354
|
-
content: [{ type: "text" as const, text: "Todo call aborted by signal." }],
|
|
355
|
-
details: {
|
|
356
|
-
action: "list" as const,
|
|
357
|
-
todos: [],
|
|
358
|
-
nextId: 1,
|
|
359
|
-
error: "aborted",
|
|
360
|
-
_render: undefined,
|
|
361
|
-
} as TodoDetails,
|
|
362
|
-
};
|
|
363
|
-
}
|
|
364
|
-
const result = executeTodoAction(params as TodoActionParams, state, ctx, refreshDisplay);
|
|
365
|
-
const details = result.details as { error?: string } | undefined;
|
|
366
|
-
if (details?.error) {
|
|
367
|
-
const textPart = result.content[0];
|
|
368
|
-
if (textPart?.type === "text") {
|
|
369
|
-
const inputSummary = JSON.stringify(params);
|
|
370
|
-
textPart.text += `\nInput: ${inputSummary}`;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
return result;
|
|
256
|
+
if (signal?.aborted) throw new Error("Todo call aborted by signal.");
|
|
257
|
+
return executeTodoAction(params as TodoActionParams, state, ctx, refreshDisplay);
|
|
374
258
|
},
|
|
375
259
|
|
|
376
260
|
renderCall(args: Record<string, unknown>, theme: Theme, _context?: unknown) {
|