@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.
@@ -0,0 +1,285 @@
1
+ /**
2
+ * executeTodoAction handler 级测试 —— 覆盖 `if (ctx.mode === "rpc")` 分支
3
+ * 和非 RPC 模式不附加 __gui__ 的路径。对齐 ask-user R-1~R-7 handler 级范式。
4
+ *
5
+ * 策略:executeTodoAction 未导出,通过 registerTodoTool + mock pi 捕获
6
+ * 已注册 tool,再以不同 ctx.mode 调 execute。每个用例新建 state(隔离),
7
+ * 无模块级状态需重置。
8
+ */
9
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
10
+ import type { Theme } from "@mariozechner/pi-coding-agent";
11
+ import { describe, expect, it } from "vitest";
12
+
13
+ import { createTodoSessionState, type TodoSessionState } from "../state";
14
+ import { registerTodoTool } from "../tool";
15
+
16
+ // ── Types for the registered tool ───────────────────────
17
+ type TestMode = "tui" | "rpc" | "json" | "print";
18
+
19
+ interface ExecuteResult {
20
+ content: Array<{ type: "text"; text: string }>;
21
+ details: {
22
+ action: string;
23
+ todos: Array<{ id: number; text: string; status: string; isVerification?: boolean }>;
24
+ nextId: number;
25
+ __gui__?: {
26
+ v: number;
27
+ component: { type: string; props: { items: unknown[] } };
28
+ };
29
+ };
30
+ }
31
+
32
+ interface RegisteredTool {
33
+ name: string;
34
+ execute: (
35
+ toolCallId: string,
36
+ params: Record<string, unknown>,
37
+ signal: AbortSignal | undefined,
38
+ onUpdate: unknown,
39
+ ctx: { mode: TestMode },
40
+ ) => Promise<ExecuteResult>;
41
+ }
42
+
43
+ // pi-coding-agent 的 ExtensionContext 类型声明里没有 mode 字段(运行时实际有),
44
+ // 与 extension-protocol 的 GuiContext 结构化兼容。这里做最小 mock 并断言。
45
+ interface MockPi {
46
+ tool?: RegisteredTool;
47
+ registerTool(tool: RegisteredTool): void;
48
+ }
49
+
50
+ /** 捕获注册的 tool,返回 + 暴露 state 供断言。 */
51
+ function setup(): { tool: RegisteredTool; state: TodoSessionState } {
52
+ const state = createTodoSessionState();
53
+ const pi: MockPi = {
54
+ registerTool(tool) {
55
+ this.tool = tool;
56
+ },
57
+ };
58
+ registerTodoTool(pi as unknown as ExtensionAPI, state, () => {});
59
+ if (!pi.tool) throw new Error("registerTodoTool did not register a tool");
60
+ return { tool: pi.tool, state };
61
+ }
62
+
63
+ // ── Theme passthrough(与 todo.test.ts mockTheme 一致) ──
64
+ const stubTheme = {
65
+ fg: (_color: string, text: string) => text,
66
+ bg: (_color: string, text: string) => text,
67
+ bold: (text: string) => text,
68
+ italic: (text: string) => text,
69
+ underline: (text: string) => text,
70
+ inverse: (text: string) => text,
71
+ strikethrough: (text: string) => text,
72
+ getFgAnsi: (_color: string) => "",
73
+ getBgAnsi: (_color: string) => "",
74
+ getColorMode: () => "truecolor" as const,
75
+ getThinkingBorderColor: () => (text: string) => text,
76
+ getBashModeBorderColor: () => (text: string) => text,
77
+ } as unknown as Theme;
78
+
79
+ /** RPC 模式 ctx:hasUI=false,refreshDisplay 调用 ui.theme/setStatus/setWidget。 */
80
+ const makeRpcCtx = () => ({
81
+ mode: "rpc" as const,
82
+ hasUI: false,
83
+ ui: {
84
+ theme: stubTheme,
85
+ setStatus: () => {},
86
+ setWidget: () => {},
87
+ },
88
+ });
89
+
90
+ /** TUI 模式 ctx:hasUI=true。 */
91
+ const makeTuiCtx = () => ({
92
+ mode: "tui" as const,
93
+ hasUI: true,
94
+ ui: {
95
+ theme: stubTheme,
96
+ setStatus: () => {},
97
+ setWidget: () => {},
98
+ },
99
+ });
100
+
101
+ // ── RPC 模式:附加 __gui__ ────────────────────────────
102
+
103
+ describe("executeTodoAction — RPC mode attaches __gui__", () => {
104
+ it("R-1: rpc + add → details.__gui__ exists, type is list-tree", async () => {
105
+ const { tool } = setup();
106
+ const result = await tool.execute(
107
+ "id",
108
+ { action: "add", texts: ["task A", "task B"] },
109
+ undefined,
110
+ undefined,
111
+ makeRpcCtx(),
112
+ );
113
+ expect(result.details.__gui__).toBeDefined();
114
+ expect(result.details.__gui__!.v).toBe(1);
115
+ expect(result.details.__gui__!.component.type).toBe("list-tree");
116
+ // 两条新增 todo 反映在 items 中
117
+ const items = result.details.__gui__!.component.props.items as Array<{
118
+ label: string;
119
+ icon: string;
120
+ }>;
121
+ expect(items).toHaveLength(2);
122
+ expect(items[0]).toMatchObject({ label: "#1: task A", icon: "dot" });
123
+ expect(items[1]).toMatchObject({ label: "#2: task B", icon: "dot" });
124
+ });
125
+
126
+ it("R-2: rpc + list → __gui__ reflects current todo state", async () => {
127
+ const { tool, state } = setup();
128
+ // 预置状态(绕开 add,直接构造 todos)
129
+ state.todos = [
130
+ { id: 1, text: "pending task", status: "pending" },
131
+ { id: 2, text: "active task", status: "in_progress" },
132
+ { id: 3, text: "done task", status: "completed" },
133
+ ];
134
+ state.nextId = 4;
135
+ const result = await tool.execute(
136
+ "id",
137
+ { action: "list" },
138
+ undefined,
139
+ undefined,
140
+ makeRpcCtx(),
141
+ );
142
+ expect(result.details.__gui__).toBeDefined();
143
+ expect(result.details.__gui__!.component.type).toBe("list-tree");
144
+ const items = result.details.__gui__!.component.props.items as Array<{
145
+ label: string;
146
+ icon: string;
147
+ status?: string;
148
+ }>;
149
+ expect(items).toHaveLength(3);
150
+ // pending → dot 无 status
151
+ expect(items[0]).toMatchObject({ label: "#1: pending task", icon: "dot" });
152
+ expect(items[0]).not.toHaveProperty("status");
153
+ // in_progress → circle / running
154
+ expect(items[1]).toMatchObject({
155
+ label: "#2: active task",
156
+ icon: "circle",
157
+ status: "running",
158
+ });
159
+ // completed → check / done
160
+ expect(items[2]).toMatchObject({
161
+ label: "#3: done task",
162
+ icon: "check",
163
+ status: "done",
164
+ });
165
+ });
166
+
167
+ it("R-3: rpc + update → __gui__ reflects post-update status", async () => {
168
+ const { tool, state } = setup();
169
+ state.todos = [{ id: 1, text: "item", status: "pending" }];
170
+ state.nextId = 2;
171
+ const result = await tool.execute(
172
+ "id",
173
+ { action: "update", updates: [{ id: 1, status: "in_progress" }] },
174
+ undefined,
175
+ undefined,
176
+ makeRpcCtx(),
177
+ );
178
+ expect(result.details.__gui__).toBeDefined();
179
+ const items = result.details.__gui__!.component.props.items as Array<{
180
+ icon: string;
181
+ status?: string;
182
+ }>;
183
+ expect(items[0]).toMatchObject({ icon: "circle", status: "running" });
184
+ });
185
+ });
186
+
187
+ // ── TUI 模式:不附加 __gui__ ──────────────────────────
188
+
189
+ describe("executeTodoAction — non-RPC modes omit __gui__", () => {
190
+ it("T-1: tui + add → details.__gui__ is undefined", async () => {
191
+ const { tool } = setup();
192
+ const result = await tool.execute(
193
+ "id",
194
+ { action: "add", texts: ["task A"] },
195
+ undefined,
196
+ undefined,
197
+ makeTuiCtx(),
198
+ );
199
+ expect(result.details.__gui__).toBeUndefined();
200
+ // details 仍带原生文本路径数据(todos / nextId)
201
+ expect(result.details.todos).toHaveLength(1);
202
+ expect(result.content[0].text).toContain("Added");
203
+ });
204
+
205
+ it("T-2: tui + list → details.__gui__ is undefined", async () => {
206
+ const { tool, state } = setup();
207
+ state.todos = [{ id: 1, text: "x", status: "pending" }];
208
+ state.nextId = 2;
209
+ const result = await tool.execute(
210
+ "id",
211
+ { action: "list" },
212
+ undefined,
213
+ undefined,
214
+ makeTuiCtx(),
215
+ );
216
+ expect(result.details.__gui__).toBeUndefined();
217
+ // 文本内容仍可读
218
+ expect(result.content[0].text).toContain("#1");
219
+ });
220
+
221
+ it("T-3: print mode + add → details.__gui__ is undefined", async () => {
222
+ const { tool } = setup();
223
+ const result = await tool.execute(
224
+ "id",
225
+ { action: "add", texts: ["task A"] },
226
+ undefined,
227
+ undefined,
228
+ { mode: "print", hasUI: false, ui: { theme: stubTheme, setStatus: () => {}, setWidget: () => {} } },
229
+ );
230
+ expect(result.details.__gui__).toBeUndefined();
231
+ });
232
+
233
+ it("T-4: json mode + add → details.__gui__ is undefined", async () => {
234
+ const { tool } = setup();
235
+ const result = await tool.execute(
236
+ "id",
237
+ { action: "add", texts: ["task A"] },
238
+ undefined,
239
+ undefined,
240
+ { mode: "json", hasUI: false, ui: { theme: stubTheme, setStatus: () => {}, setWidget: () => {} } },
241
+ );
242
+ expect(result.details.__gui__).toBeUndefined();
243
+ });
244
+ });
245
+
246
+ // ── 共享 state:rpc 附加但 details.todos 是快照副本 ────
247
+
248
+ describe("executeTodoAction — state isolation & snapshot", () => {
249
+ it("S-1: each setup() yields independent state (no module-level leak)", async () => {
250
+ const { tool: tool1 } = setup();
251
+ await tool1.execute("id", { action: "add", texts: ["first"] }, undefined, undefined, makeRpcCtx());
252
+ // 第二个 setup 起步,不应看到第一个的 todos
253
+ const { tool: tool2, state: state2 } = setup();
254
+ expect(state2.todos).toHaveLength(0);
255
+ const result = await tool2.execute(
256
+ "id",
257
+ { action: "list" },
258
+ undefined,
259
+ undefined,
260
+ makeRpcCtx(),
261
+ );
262
+ expect(result.content[0].text).toBe("No todos");
263
+ // 空 list 仍走 buildGui([])(rpc 分支无条件 attach)
264
+ expect(result.details.__gui__).toBeDefined();
265
+ expect(result.details.__gui__!.component.props.items).toEqual([]);
266
+ });
267
+
268
+ it("S-2: details.todos is a shallow array copy (splice-safe, element-shared)", async () => {
269
+ // executeTodoAction 用 [...state.todos] 做浅拷贝:数组独立、元素共享。
270
+ // add/clear 改数组长度时旧 details.todos 不受影响;但原地改元素会共享。
271
+ const { tool } = setup();
272
+ await tool.execute("id", { action: "add", texts: ["a", "b"] }, undefined, undefined, makeRpcCtx());
273
+ const before = (await tool.execute(
274
+ "id",
275
+ { action: "list" },
276
+ undefined,
277
+ undefined,
278
+ makeRpcCtx(),
279
+ )).details.todos;
280
+ expect(before).toHaveLength(2);
281
+ // clear 改 state.todos 数组,已发出的 before 快照仍为 2 项
282
+ await tool.execute("id", { action: "clear" }, undefined, undefined, makeRpcCtx());
283
+ expect(before).toHaveLength(2);
284
+ });
285
+ });
package/src/component.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { Theme } from "@mariozechner/pi-coding-agent";
6
- import { matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
6
+ import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
7
7
 
8
8
  import type { Todo } from "./model";
9
9
  import { FALLBACK_TERM_WIDTH, renderDualColumn } from "./render";
package/src/handlers.ts CHANGED
@@ -25,7 +25,7 @@ const REMINDER_INTERVAL = 2;
25
25
  export type RefreshDisplayFn = (ctx: ExtensionContext) => void;
26
26
 
27
27
  /** 构建极简提醒:只含下一个推荐任务 */
28
- function buildMinimalReminder(state: TodoSessionState): string {
28
+ export function buildMinimalReminder(state: TodoSessionState): string {
29
29
  const pendingTodos = state.todos.filter((t) => t.status !== "completed");
30
30
  if (pendingTodos.length === 0) return "";
31
31
 
@@ -33,8 +33,7 @@ function buildMinimalReminder(state: TodoSessionState): string {
33
33
  return `<todo_context>\n[TODO] 你有 ${pendingTodos.length} 个未完成任务。下一个应处理:#${next.id} ${next.text}\n</todo_context>`;
34
34
  }
35
35
 
36
- /** 构建 before_agent_start 的 todo context */
37
- function buildBeforeAgentStartMessage(state: TodoSessionState): { message: { customType: string; content: string; display: boolean } } | undefined {
36
+ export function buildBeforeAgentStartMessage(state: TodoSessionState): { message: { customType: string; content: string; display: boolean } } | undefined {
38
37
  if (state.todos.length === 0) return undefined;
39
38
 
40
39
  const pendingTodos = state.todos.filter((t) => t.status !== "completed");
@@ -55,7 +54,7 @@ function buildBeforeAgentStartMessage(state: TodoSessionState): { message: { cus
55
54
 
56
55
  // ── 状态重建 ────────────────────────────────────────
57
56
 
58
- function reconstructState(state: TodoSessionState, ctx: ExtensionContext): void {
57
+ export function reconstructState(state: TodoSessionState, ctx: ExtensionContext): void {
59
58
  state.todos = [];
60
59
  state.nextId = 1;
61
60
  state.userMessageCount = 0;
@@ -100,8 +99,7 @@ function reconstructState(state: TodoSessionState, ctx: ExtensionContext): void
100
99
 
101
100
  // ── agent_end 子函数 ────────────────────────────────
102
101
 
103
- /** 1. Auto-clear */
104
- function handleAutoClear(state: TodoSessionState): { handled: boolean; cleared: boolean } {
102
+ export function handleAutoClear(state: TodoSessionState): { handled: boolean; cleared: boolean } {
105
103
  const allCompleted = state.todos.every((t) => t.status === "completed");
106
104
  if (!allCompleted) {
107
105
  state.allCompletedAtCount = null;
@@ -120,8 +118,7 @@ function handleAutoClear(state: TodoSessionState): { handled: boolean; cleared:
120
118
  return { handled: true, cleared: false };
121
119
  }
122
120
 
123
- /** 2. 全部 completed 时设置延迟 steer(仅一次),由 before_agent_start 消费 */
124
- function handleCompletionSteer(state: TodoSessionState): boolean {
121
+ export function handleCompletionSteer(state: TodoSessionState): boolean {
125
122
  if (state.completionSteered) return false;
126
123
  const allCompleted = state.todos.length > 0 && state.todos.every((t) => t.status === "completed");
127
124
  if (!allCompleted) return false;
@@ -131,8 +128,7 @@ function handleCompletionSteer(state: TodoSessionState): boolean {
131
128
  return true;
132
129
  }
133
130
 
134
- /** 3. Stall 检测 — 设置延迟 steer */
135
- function handleStallDetection(state: TodoSessionState): boolean {
131
+ export function handleStallDetection(state: TodoSessionState): boolean {
136
132
  if (
137
133
  !state.stallNotified &&
138
134
  state.userMessageCount - state.lastTodoCallCount >= STALL_THRESHOLD
@@ -147,8 +143,7 @@ function handleStallDetection(state: TodoSessionState): boolean {
147
143
  return false;
148
144
  }
149
145
 
150
- /** 4. 提醒 — 设置延迟 steer */
151
- function handleReminder(state: TodoSessionState): boolean {
146
+ export function handleReminder(state: TodoSessionState): boolean {
152
147
  if (state.userMessageCount - state.lastTodoCallCount >= REMINDER_INTERVAL) {
153
148
  const reminder = buildMinimalReminder(state);
154
149
  if (reminder) {
package/src/index.ts CHANGED
@@ -1,24 +1,23 @@
1
1
  /**
2
- * Todo Extension v2 - 三态任务清单,支持状态栏和 entry GC
2
+ * Todo Extension — 轻量三态任务清单(pending / in_progress / completed)。
3
3
  *
4
- * 改动要点:
5
- * - done: boolean → status: "pending" | "in_progress" | "completed" | "failed"
6
- * - toggle → update(id + 可选 status/text,带参数守卫)
7
- * - 新增 delete action
8
- * - 状态栏通过 ctx.ui.setStatus 显示进度
9
- * - reconstructState 向后兼容旧 done 字段 + entry GC
10
- * - 支持 verifyText/verifyAttempts 字段(数据模型增强)
11
- * - add 支持 verifyTexts 参数(验证文本映射)
4
+ * 设计定位:刻意不做状态机约束(与 goal 扩展的 7 态对立),状态自由流转;
5
+ * 状态持久化复用 Pi 自动记录的 toolResult entry(非 appendEntry);
6
+ * 通过 agent_end → before_agent_start 的延迟 steer 驱动任务推进。
12
7
  *
13
- * 文件拆分(v3.1 — 修复 P1-1/P1-2/P1-3/P1-4 拆分后):
14
- * - state.ts: TodoSessionState 接口 + 工厂
15
- * - model.ts: 数据模型 + 纯函数(add/update/migrate/format)
16
- * - render.ts: 状态栏 / widget / tool result 渲染
17
- * - component.ts: TodoListComponent TUI 组件
18
- * - tool.ts: TodoParams schema + 5 个 action handler + execute dispatcher + registerTodoTool
19
- * - handlers.ts: 5 个事件处理器 + reconstructState + buildPendingContext
20
- * - commands.ts: /todos 命令
21
- * - index.ts(本文件): 工厂入口(创建 state + 注册所有 handler)
8
+ * 文件职责:
9
+ * - state.ts: TodoSessionState 会话状态接口 + 工厂(闭包内创建,session 隔离)
10
+ * - model.ts: 纯函数数据层(Todo 类型、migrateTodo 兼容迁移、addTodos/updateTodos、format/buildGui)
11
+ * - tool.ts: todo tool 注册 — 5 个 action(list/add/update/delete/clear)+ execute dispatcher
12
+ * - handlers.ts: 5 个事件处理器(session_start/session_tree/agent_start/before_agent_start/agent_end)
13
+ * + reconstructState(回放最后一条 todo toolResult)+ steer 四机制(autoClear/completion/stall/reminder)
14
+ * - render.ts: 状态栏(status line)/ widget(单双列自适应)/ tool result 三层渲染
15
+ * - component.ts: /todos 命令的 TodoListComponent TUI 视图(只读双列)
16
+ * - commands.ts: /todos 命令注册
17
+ * - index.ts(本文件): 工厂入口(创建 state + 注册 tool/command/event + refreshDisplay)
18
+ *
19
+ * 错误处理:handler 失败直接 throw(见 CLAUDE.md「Tool 设计」),不返回错误成功模式。
20
+ * model 层纯函数返回 Result 对象(合法),dispatcher 拿到 error 时 throw。
22
21
  */
23
22
 
24
23
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
package/src/model.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * (cancelled 不可恢复;isVerification 标记验证任务,FR-6 completion audit 用)
5
5
  */
6
6
 
7
+ import { guiComponent, type GuiRenderResult, guiResult, type TreeItem } from "@xyz-agent/extension-protocol";
8
+
7
9
  // ── 数据模型 ─────────────────────────────────────────
8
10
 
9
11
  export interface Todo {
@@ -18,15 +20,8 @@ export interface TodoDetails {
18
20
  action: "list" | "add" | "update" | "delete" | "clear";
19
21
  todos: Todo[];
20
22
  nextId: number;
21
- error?: string;
22
- _render?: {
23
- type: "task-list";
24
- summary?: string;
25
- data: {
26
- items: Array<{ id: number; text: string; status: string }>;
27
- meta: Record<string, string>;
28
- };
29
- };
23
+ /** GUI 渲染结果(仅 RPC 模式填充,前端 list-tree 渲染)。对齐 extension-protocol@0.2.0。 */
24
+ __gui__?: GuiRenderResult;
30
25
  }
31
26
 
32
27
  export const VALID_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const;
@@ -65,19 +60,42 @@ export function migrateTodo(raw: Todo): Todo {
65
60
  };
66
61
  }
67
62
 
68
- // ── 渲染辅助 ─────────────────────────────────────────
63
+ // ── GUI 渲染辅助 ─────────────────────────────────────
69
64
 
70
- export function buildRender(todoList: Todo[]): TodoDetails["_render"] {
71
- const completed = todoList.filter((t) => t.status === "completed").length;
72
- const total = todoList.length;
73
- return {
74
- type: "task-list" as const,
75
- summary: `${completed}/${total} completed`,
76
- data: {
77
- items: todoList.map((t) => ({ id: t.id, text: t.text, status: t.status })),
78
- meta: {},
79
- },
80
- };
65
+ /**
66
+ * 把 todos 映射为 list-tree GuiRenderResult(对齐 extension-protocol@0.2.0)。
67
+ * status → icon/status 映射:
68
+ * pending → dot / 无 status
69
+ * in_progress → circle / running
70
+ * completed → check / done
71
+ * cancelled → cross / failed
72
+ */
73
+ export function buildGui(todos: Todo[]): GuiRenderResult {
74
+ const items: TreeItem[] = todos.map((t) => {
75
+ const icon =
76
+ t.status === "completed"
77
+ ? "check"
78
+ : t.status === "in_progress"
79
+ ? "circle"
80
+ : t.status === "cancelled"
81
+ ? "cross"
82
+ : "dot"; // pending
83
+ const status =
84
+ t.status === "in_progress"
85
+ ? "running"
86
+ : t.status === "completed"
87
+ ? "done"
88
+ : t.status === "cancelled"
89
+ ? "failed"
90
+ : undefined; // pending 无 status
91
+ return {
92
+ icon,
93
+ label: `#${t.id}: ${t.text}`,
94
+ status,
95
+ depth: 0,
96
+ };
97
+ });
98
+ return guiResult(guiComponent("list-tree", { items }));
81
99
  }
82
100
 
83
101
  export function getDisplayStatus(t: Todo): string {
package/src/render.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { Theme } from "@mariozechner/pi-coding-agent";
6
- import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
6
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
7
 
8
8
  import {
9
9
  getDisplayStatus,
@@ -166,7 +166,7 @@ function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, the
166
166
 
167
167
  // ── Tool renderResult handler ────────────────────────
168
168
 
169
- import { Text } from "@mariozechner/pi-tui";
169
+ import { Text } from "@earendil-works/pi-tui";
170
170
 
171
171
  export function renderTodoResult(result: unknown, options: { expanded: boolean }, theme: Theme): Text {
172
172
  const r = result as { content: Array<{ type: string; text?: string }>; details?: unknown };
@@ -176,10 +176,6 @@ export function renderTodoResult(result: unknown, options: { expanded: boolean }
176
176
  return new Text(text?.type === "text" ? (text.text ?? "") : "", 0, 0);
177
177
  }
178
178
 
179
- if (details.error) {
180
- return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
181
- }
182
-
183
179
  const todoList = details.todos;
184
180
 
185
181
  switch (details.action) {