@zhushanwen/pi-todo 0.3.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 +4 -26
- package/src/__tests__/tool-rpc.test.ts +285 -0
- package/src/component.ts +1 -1
- package/src/handlers.ts +7 -12
- package/src/index.ts +17 -18
- package/src/model.ts +39 -21
- package/src/render.ts +2 -6
- package/src/tool.ts +67 -197
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,
|
|
@@ -62,30 +62,10 @@ const TodoParams = Type.Object({
|
|
|
62
62
|
),
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
// ── 错误结果构造 helper ──────────────────────────────
|
|
66
|
-
|
|
67
|
-
function errorResult(
|
|
68
|
-
action: TodoDetails["action"],
|
|
69
|
-
state: TodoSessionState,
|
|
70
|
-
errorText: string,
|
|
71
|
-
errorCode: string,
|
|
72
|
-
): {
|
|
73
|
-
content: Array<{ type: "text"; text: string }>;
|
|
74
|
-
details: TodoDetails;
|
|
75
|
-
} {
|
|
76
|
-
return {
|
|
77
|
-
content: [{ type: "text" as const, text: errorText }],
|
|
78
|
-
details: {
|
|
79
|
-
action,
|
|
80
|
-
todos: [...state.todos],
|
|
81
|
-
nextId: state.nextId,
|
|
82
|
-
error: errorCode,
|
|
83
|
-
_render: buildRender(state.todos),
|
|
84
|
-
} as TodoDetails,
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
65
|
// ── 5 个 action handler ──────────────────────────────
|
|
66
|
+
// 错误处理约定(见 CLAUDE.md「Tool 设计」):handler 失败直接 throw,
|
|
67
|
+
// 不返回「错误成功模式」。model 层纯函数返回 Result 对象(合法),
|
|
68
|
+
// 由 dispatcher 在拿到 error 时 throw,把友好文案交给 Pi 框架展示。
|
|
89
69
|
|
|
90
70
|
/** list action */
|
|
91
71
|
function handleList(state: TodoSessionState): string {
|
|
@@ -94,83 +74,52 @@ function handleList(state: TodoSessionState): string {
|
|
|
94
74
|
: "No todos";
|
|
95
75
|
}
|
|
96
76
|
|
|
97
|
-
/** add action */
|
|
98
|
-
function handleAdd(
|
|
99
|
-
state: TodoSessionState,
|
|
100
|
-
params: TodoActionParams,
|
|
101
|
-
): { resultText: string; error?: string } {
|
|
77
|
+
/** add action — 失败抛错 */
|
|
78
|
+
function handleAdd(state: TodoSessionState, params: TodoActionParams): string {
|
|
102
79
|
if (!params.texts || params.texts.length === 0) {
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const addResult = addTodos(state.todos, state.nextId, params.texts, params.isVerification);
|
|
107
|
-
if (addResult.error) {
|
|
108
|
-
return { resultText: addResult.resultText || "", error: addResult.error };
|
|
80
|
+
throw new Error("add requires texts parameter (non-empty array)");
|
|
109
81
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
state.
|
|
113
|
-
|
|
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!;
|
|
114
87
|
}
|
|
115
88
|
|
|
116
|
-
/** update action: batch */
|
|
117
|
-
function handleBatchUpdate(
|
|
118
|
-
state
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (result.error) {
|
|
123
|
-
return {
|
|
124
|
-
resultText: result.resultText || "",
|
|
125
|
-
error: result.error,
|
|
126
|
-
earlyReturn: {
|
|
127
|
-
content: [{ type: "text" as const, text: result.resultText || "" }],
|
|
128
|
-
details: {
|
|
129
|
-
action: "update" as const,
|
|
130
|
-
todos: [...state.todos],
|
|
131
|
-
nextId: state.nextId,
|
|
132
|
-
error: result.error,
|
|
133
|
-
_render: buildRender(state.todos),
|
|
134
|
-
} as TodoDetails,
|
|
135
|
-
},
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
state.todos = result.updatedTodos;
|
|
139
|
-
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!;
|
|
140
95
|
}
|
|
141
96
|
|
|
142
|
-
/** update action: single */
|
|
143
|
-
export function handleSingleUpdate(
|
|
144
|
-
|
|
145
|
-
params
|
|
146
|
-
|
|
147
|
-
if (params.
|
|
148
|
-
if (params.status === undefined && params.text === undefined) return { resultText: "", error: "need status or text" };
|
|
149
|
-
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");
|
|
150
103
|
if (
|
|
151
104
|
params.status !== undefined &&
|
|
152
105
|
!VALID_STATUSES.includes(params.status as (typeof VALID_STATUSES)[number])
|
|
153
106
|
) {
|
|
154
|
-
|
|
107
|
+
throw new Error(`status only accepts ${VALID_STATUSES.join(" / ")}`);
|
|
155
108
|
}
|
|
156
109
|
|
|
157
110
|
const todo = state.todos.find((t) => t.id === params.id);
|
|
158
|
-
if (!todo)
|
|
111
|
+
if (!todo) throw new Error(`Todo #${params.id} not found`);
|
|
159
112
|
|
|
160
|
-
// FR-6
|
|
113
|
+
// FR-6 不变量守卫(失败抛错):(a) cancelled 不可恢复;(b) 验证任务不可 cancelled
|
|
161
114
|
if (todo.status === "cancelled" && params.status !== undefined) {
|
|
162
|
-
|
|
115
|
+
throw new Error(`#${params.id} is cancelled (cannot restore)`);
|
|
163
116
|
}
|
|
164
117
|
if (todo.isVerification && params.status === "cancelled") {
|
|
165
|
-
|
|
118
|
+
throw new Error(`#${params.id} is verification todo (cannot cancel)`);
|
|
166
119
|
}
|
|
167
120
|
|
|
168
|
-
if (params.status !== undefined)
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
if (params.text !== undefined) {
|
|
172
|
-
todo.text = params.text;
|
|
173
|
-
}
|
|
121
|
+
if (params.status !== undefined) todo.status = params.status as Todo["status"];
|
|
122
|
+
if (params.text !== undefined) todo.text = params.text;
|
|
174
123
|
|
|
175
124
|
const parts: string[] = [`Updated todo #${todo.id}`];
|
|
176
125
|
if (params.status !== undefined) parts.push(`status → ${params.status}`);
|
|
@@ -179,36 +128,26 @@ export function handleSingleUpdate(
|
|
|
179
128
|
// 最后一个完成提示
|
|
180
129
|
const incompleteAfter = state.todos.filter((t) => t.status !== "completed");
|
|
181
130
|
if (params.status === "completed" && incompleteAfter.length === 0) {
|
|
182
|
-
return
|
|
131
|
+
return parts.join(", ") + "\n\nAll todos completed. Please summarize your work.";
|
|
183
132
|
}
|
|
184
|
-
return
|
|
133
|
+
return parts.join(", ");
|
|
185
134
|
}
|
|
186
135
|
|
|
187
|
-
/** update action: dispatcher */
|
|
188
|
-
function handleUpdate(
|
|
189
|
-
state
|
|
190
|
-
params: TodoActionParams,
|
|
191
|
-
):
|
|
192
|
-
| { resultText: string; error?: string; earlyReturn?: { content: Array<{ type: "text"; text: string }>; details: TodoDetails } }
|
|
193
|
-
| undefined {
|
|
194
|
-
if (params.updates && params.updates.length > 0) {
|
|
195
|
-
return handleBatchUpdate(state, params);
|
|
196
|
-
}
|
|
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);
|
|
197
139
|
return handleSingleUpdate(state, params);
|
|
198
140
|
}
|
|
199
141
|
|
|
200
|
-
/** delete action */
|
|
201
|
-
function handleDelete(
|
|
202
|
-
state: TodoSessionState,
|
|
203
|
-
params: TodoActionParams,
|
|
204
|
-
): { resultText: string; error?: string } {
|
|
142
|
+
/** delete action — 失败抛错;部分 id 缺失则整体拒绝(原子性) */
|
|
143
|
+
function handleDelete(state: TodoSessionState, params: TodoActionParams): string {
|
|
205
144
|
if (!params.ids || params.ids.length === 0) {
|
|
206
|
-
|
|
145
|
+
throw new Error("delete requires ids parameter (non-empty array)");
|
|
207
146
|
}
|
|
208
147
|
const uniqueIds = [...new Set(params.ids)];
|
|
209
148
|
const missing = uniqueIds.filter((id) => !state.todos.some((t) => t.id === id));
|
|
210
149
|
if (missing.length > 0) {
|
|
211
|
-
|
|
150
|
+
throw new Error(`Todo #${missing.join(", #")} not found`);
|
|
212
151
|
}
|
|
213
152
|
const removedIds: number[] = [];
|
|
214
153
|
for (const id of uniqueIds) {
|
|
@@ -218,7 +157,7 @@ function handleDelete(
|
|
|
218
157
|
removedIds.push(id);
|
|
219
158
|
}
|
|
220
159
|
}
|
|
221
|
-
return
|
|
160
|
+
return `Deleted ${removedIds.length} items (#${removedIds.join(", #")}), ${state.todos.length} remaining`;
|
|
222
161
|
}
|
|
223
162
|
|
|
224
163
|
/** clear action */
|
|
@@ -245,94 +184,45 @@ function executeTodoAction(
|
|
|
245
184
|
state.lastTodoCallCount = state.userMessageCount;
|
|
246
185
|
state.stallNotified = false;
|
|
247
186
|
|
|
248
|
-
let resultText
|
|
249
|
-
|
|
187
|
+
let resultText: string;
|
|
250
188
|
switch (params.action) {
|
|
251
|
-
case "list":
|
|
189
|
+
case "list":
|
|
252
190
|
resultText = handleList(state);
|
|
253
191
|
break;
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
case "add": {
|
|
257
|
-
const r = handleAdd(state, params);
|
|
258
|
-
if (r.error === "texts required") {
|
|
259
|
-
return errorResult("add", state, "Error: add requires texts parameter (non-empty array)", r.error);
|
|
260
|
-
}
|
|
261
|
-
if (r.error) {
|
|
262
|
-
return errorResult("add", state, r.resultText, r.error);
|
|
263
|
-
}
|
|
264
|
-
resultText = r.resultText;
|
|
192
|
+
case "add":
|
|
193
|
+
resultText = handleAdd(state, params);
|
|
265
194
|
break;
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
case "update": {
|
|
269
|
-
const r = handleUpdate(state, params);
|
|
270
|
-
if (!r) {
|
|
271
|
-
resultText = "Unknown error";
|
|
272
|
-
break;
|
|
273
|
-
}
|
|
274
|
-
if (r.earlyReturn) return r.earlyReturn;
|
|
275
|
-
if (r.error) {
|
|
276
|
-
const errorText = mapUpdateErrorText(state, params, r.error);
|
|
277
|
-
return errorResult("update", state, errorText, r.error);
|
|
278
|
-
}
|
|
279
|
-
resultText = r.resultText;
|
|
195
|
+
case "update":
|
|
196
|
+
resultText = handleUpdate(state, params);
|
|
280
197
|
break;
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
case "delete": {
|
|
284
|
-
const r = handleDelete(state, params);
|
|
285
|
-
if (r.error === "ids required") {
|
|
286
|
-
return errorResult("delete", state, "Error: delete requires ids parameter (non-empty array)", r.error);
|
|
287
|
-
}
|
|
288
|
-
if (r.error) {
|
|
289
|
-
return errorResult("delete", state, `Error: Todo ${r.error.replace(/^#/, "#")}`, r.error);
|
|
290
|
-
}
|
|
291
|
-
resultText = r.resultText;
|
|
198
|
+
case "delete":
|
|
199
|
+
resultText = handleDelete(state, params);
|
|
292
200
|
break;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
case "clear": {
|
|
201
|
+
case "clear":
|
|
296
202
|
resultText = handleClear(state);
|
|
297
203
|
break;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
204
|
default:
|
|
301
|
-
|
|
205
|
+
throw new Error(`Unknown action: ${params.action}`);
|
|
302
206
|
}
|
|
303
207
|
|
|
304
208
|
refreshDisplay(ctx);
|
|
305
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
|
+
}
|
|
306
220
|
return {
|
|
307
221
|
content: [{ type: "text" as const, text: resultText }],
|
|
308
|
-
details
|
|
309
|
-
action: params.action as TodoDetails["action"],
|
|
310
|
-
todos: [...state.todos],
|
|
311
|
-
nextId: state.nextId,
|
|
312
|
-
_render: buildRender(state.todos),
|
|
313
|
-
} as TodoDetails,
|
|
222
|
+
details,
|
|
314
223
|
};
|
|
315
224
|
}
|
|
316
225
|
|
|
317
|
-
function mapUpdateErrorText(state: TodoSessionState, _params: TodoActionParams, code: string): string {
|
|
318
|
-
switch (code) {
|
|
319
|
-
case "id required":
|
|
320
|
-
return "Error: update requires id parameter";
|
|
321
|
-
case "need status or text":
|
|
322
|
-
return "Error: update requires at least status or text parameter";
|
|
323
|
-
case "text empty":
|
|
324
|
-
return "Error: text cannot be empty string";
|
|
325
|
-
default:
|
|
326
|
-
if (code.startsWith("invalid status:")) {
|
|
327
|
-
return `Error: status only accepts ${VALID_STATUSES.join(" / ")}`;
|
|
328
|
-
}
|
|
329
|
-
if (code.startsWith("#") && code.includes("not found")) {
|
|
330
|
-
return `Error: Todo ${code} not found`;
|
|
331
|
-
}
|
|
332
|
-
return `Error: ${code}`;
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
226
|
// ── Tool 注册入口 ─────────────────────────────────────
|
|
337
227
|
|
|
338
228
|
export function registerTodoTool(
|
|
@@ -348,7 +238,7 @@ export function registerTodoTool(
|
|
|
348
238
|
"\n\nAvailable actions:" +
|
|
349
239
|
"\n- list: View all todos" +
|
|
350
240
|
"\n- add: Batch add todos (requires texts array; optional isVerification marks verification tasks)" +
|
|
351
|
-
"\n- update: Update
|
|
241
|
+
"\n- update: Update todo(s) — single (id + optional status/text) or batch (updates[], takes priority)" +
|
|
352
242
|
"\n- delete: Batch delete todos (requires ids array)" +
|
|
353
243
|
"\n- clear: Clear all todos and reset IDs",
|
|
354
244
|
promptSnippet: "Use todo when breaking multi-step work into trackable items. Add verification todos (isVerification=true) for checks like running tests.",
|
|
@@ -363,28 +253,8 @@ export function registerTodoTool(
|
|
|
363
253
|
parameters: TodoParams,
|
|
364
254
|
|
|
365
255
|
async execute(_toolCallId: string, params: Static<typeof TodoParams>, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
|
|
366
|
-
if (signal?.aborted)
|
|
367
|
-
|
|
368
|
-
content: [{ type: "text" as const, text: "Todo call aborted by signal." }],
|
|
369
|
-
details: {
|
|
370
|
-
action: "list" as const,
|
|
371
|
-
todos: [],
|
|
372
|
-
nextId: 1,
|
|
373
|
-
error: "aborted",
|
|
374
|
-
_render: undefined,
|
|
375
|
-
} as TodoDetails,
|
|
376
|
-
};
|
|
377
|
-
}
|
|
378
|
-
const result = executeTodoAction(params as TodoActionParams, state, ctx, refreshDisplay);
|
|
379
|
-
const details = result.details as { error?: string } | undefined;
|
|
380
|
-
if (details?.error) {
|
|
381
|
-
const textPart = result.content[0];
|
|
382
|
-
if (textPart?.type === "text") {
|
|
383
|
-
const inputSummary = JSON.stringify(params);
|
|
384
|
-
textPart.text += `\nInput: ${inputSummary}`;
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
return result;
|
|
256
|
+
if (signal?.aborted) throw new Error("Todo call aborted by signal.");
|
|
257
|
+
return executeTodoAction(params as TodoActionParams, state, ctx, refreshDisplay);
|
|
388
258
|
},
|
|
389
259
|
|
|
390
260
|
renderCall(args: Record<string, unknown>, theme: Theme, _context?: unknown) {
|