@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/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 "@mariozechner/pi-ai";
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
- buildRender,
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
- return { resultText: "", error: "texts required" };
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
- state.todos = addResult.newTodos;
112
- state.nextId = addResult.newNextId;
113
- 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!;
114
87
  }
115
88
 
116
- /** update action: batch */
117
- function handleBatchUpdate(
118
- state: TodoSessionState,
119
- params: TodoActionParams,
120
- ): { resultText: string; error?: string; earlyReturn?: { content: Array<{ type: "text"; text: string }>; details: TodoDetails } } {
121
- const result = updateTodos(state.todos, params.updates ?? []);
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
- state: TodoSessionState,
145
- params: TodoActionParams,
146
- ): { resultText: string; error?: string } {
147
- if (params.id === undefined) return { resultText: "", error: "id required" };
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
- return { resultText: "", error: `invalid status: ${params.status}` };
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) return { resultText: "", error: `#${params.id} not found` };
111
+ if (!todo) throw new Error(`Todo #${params.id} not found`);
159
112
 
160
- // FR-6 不变量守卫:(a) cancelled 不可恢复;(b) 验证任务不可 cancelled
113
+ // FR-6 不变量守卫(失败抛错):(a) cancelled 不可恢复;(b) 验证任务不可 cancelled
161
114
  if (todo.status === "cancelled" && params.status !== undefined) {
162
- return { resultText: "", error: `#${params.id} is cancelled (cannot restore)` };
115
+ throw new Error(`#${params.id} is cancelled (cannot restore)`);
163
116
  }
164
117
  if (todo.isVerification && params.status === "cancelled") {
165
- return { resultText: "", error: `#${params.id} is verification todo (cannot cancel)` };
118
+ throw new Error(`#${params.id} is verification todo (cannot cancel)`);
166
119
  }
167
120
 
168
- if (params.status !== undefined) {
169
- todo.status = params.status as Todo["status"];
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 { resultText: parts.join(", ") + "\n\nAll todos completed. Please summarize your work." };
131
+ return parts.join(", ") + "\n\nAll todos completed. Please summarize your work.";
183
132
  }
184
- return { resultText: parts.join(", ") };
133
+ return parts.join(", ");
185
134
  }
186
135
 
187
- /** update action: dispatcher */
188
- function handleUpdate(
189
- state: TodoSessionState,
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
- return { resultText: "", error: "ids required" };
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
- return { resultText: "", error: `#${missing.map((id) => id).join(", #")} not found` };
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 { resultText: `Deleted ${removedIds.length} items (#${removedIds.join(", #")}), ${state.todos.length} remaining` };
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
- return errorResult("list", state, `Unknown action: ${params.action}`, `unknown action: ${params.action}`);
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 a todo (requires id, optional status/text)" +
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
- return {
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) {