@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.
@@ -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");
@@ -100,8 +99,7 @@ export function reconstructState(state: TodoSessionState, ctx: ExtensionContext)
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";
@@ -35,6 +34,9 @@ export default function (pi: ExtensionAPI) {
35
34
  // ── 闭包内状态(session 隔离) ─────────────────────
36
35
  const state = createTodoSessionState();
37
36
 
37
+ // 全解耦:不再暴露 pi.__todoGetList 跨扩展 API(goal 不再读 todo 状态)。
38
+ // todo 进度由 AI 自行管理,goal 不做强制检查。
39
+
38
40
  // ── 刷新显示(依赖闭包 state) ─────────────────────
39
41
  function refreshDisplay(ctx: ExtensionContext): void {
40
42
  const statusText = renderStatusText(state.todos, ctx.ui.theme);
package/src/model.ts CHANGED
@@ -1,46 +1,35 @@
1
1
  /**
2
2
  * Todo 数据模型 — 纯函数,不依赖 Pi 运行时。
3
- * 三态: pending → in_progress → completed
3
+ * 四态: pending → in_progress → completed;任一状态 → cancelled
4
+ * (cancelled 不可恢复;isVerification 标记验证任务,FR-6 completion audit 用)
4
5
  */
5
6
 
7
+ import { guiComponent, type GuiRenderResult, guiResult, type TreeItem } from "@xyz-agent/extension-protocol";
8
+
6
9
  // ── 数据模型 ─────────────────────────────────────────
7
10
 
8
11
  export interface Todo {
9
12
  id: number;
10
13
  text: string;
11
- status: "pending" | "in_progress" | "completed";
14
+ status: "pending" | "in_progress" | "completed" | "cancelled";
15
+ /** 验证任务标记(FR-6 completion audit)。验证任务必须 completed,不可 cancelled。 */
16
+ isVerification?: boolean;
12
17
  }
13
18
 
14
19
  export interface TodoDetails {
15
20
  action: "list" | "add" | "update" | "delete" | "clear";
16
21
  todos: Todo[];
17
22
  nextId: number;
18
- error?: string;
19
- _render?: {
20
- type: "task-list";
21
- summary?: string;
22
- data: {
23
- items: Array<{ id: number; text: string; status: string }>;
24
- meta: Record<string, string>;
25
- };
26
- };
23
+ /** GUI 渲染结果(仅 RPC 模式填充,前端 list-tree 渲染)。对齐 extension-protocol@0.2.0。 */
24
+ __gui__?: GuiRenderResult;
27
25
  }
28
26
 
29
- export const VALID_STATUSES = ["pending", "in_progress", "completed"] as const;
27
+ export const VALID_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const;
30
28
 
31
29
  export type ValidStatus = (typeof VALID_STATUSES)[number];
32
30
 
33
31
  // ── 迁移/兼容 ───────────────────────────────────────
34
32
 
35
- const STALE_CONTEXT_PATTERNS = ["aborted", "context canceled", "stale context", "stalecontext", "extension context no longer active"];
36
-
37
- /** 检查错误是否表示 stale / canceled context */
38
- export function isStaleContextError(error: Error | unknown): boolean {
39
- const msg = error instanceof Error ? error.message : String(error);
40
- const lower = msg.toLowerCase();
41
- return STALE_CONTEXT_PATTERNS.some((p) => lower.includes(p));
42
- }
43
-
44
33
  /** 旧格式迁移:verifying → in_progress,failed → pending,done:boolean → status */
45
34
  export function migrateTodo(raw: Todo): Todo {
46
35
  const record = raw as unknown as Record<string, unknown>;
@@ -66,22 +55,47 @@ export function migrateTodo(raw: Todo): Todo {
66
55
  id: record.id as number,
67
56
  text: record.text as string,
68
57
  status,
58
+ // FR-6: 保留 isVerification 标记(可选字段,旧数据可能缺失)
59
+ isVerification: record.isVerification === true ? true : undefined,
69
60
  };
70
61
  }
71
62
 
72
- // ── 渲染辅助 ─────────────────────────────────────────
63
+ // ── GUI 渲染辅助 ─────────────────────────────────────
73
64
 
74
- export function buildRender(todoList: Todo[]): TodoDetails["_render"] {
75
- const completed = todoList.filter((t) => t.status === "completed").length;
76
- const total = todoList.length;
77
- return {
78
- type: "task-list" as const,
79
- summary: `${completed}/${total} completed`,
80
- data: {
81
- items: todoList.map((t) => ({ id: t.id, text: t.text, status: t.status })),
82
- meta: {},
83
- },
84
- };
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 }));
85
99
  }
86
100
 
87
101
  export function getDisplayStatus(t: Todo): string {
@@ -101,6 +115,7 @@ export function addTodos(
101
115
  currentTodos: Todo[],
102
116
  currentNextId: number,
103
117
  texts: string[],
118
+ isVerification?: boolean,
104
119
  ): AddResult {
105
120
  if (!texts || texts.length === 0) {
106
121
  return {
@@ -129,6 +144,8 @@ export function addTodos(
129
144
  id: nextId++,
130
145
  text: trimmed[i],
131
146
  status: "pending" as const,
147
+ // FR-6: isVerification 标记验证任务(可选,仅 add 时可设)
148
+ isVerification: isVerification === true ? true : undefined,
132
149
  });
133
150
  }
134
151
  const endId = nextId - 1;
@@ -183,6 +200,21 @@ export function updateTodos(
183
200
  resultText: `Error: invalid status '${u.status}' for update item id ${u.id}`,
184
201
  };
185
202
  }
203
+ // FR-6 不变量守卫:(a) cancelled 不可恢复;(b) 验证任务不可 cancelled
204
+ if (todo.status === "cancelled" && u.status !== undefined) {
205
+ return {
206
+ updatedTodos: currentTodos,
207
+ error: `id ${u.id} is cancelled`,
208
+ resultText: `Error: Todo #${u.id} is cancelled and cannot be restored`,
209
+ };
210
+ }
211
+ if (todo.isVerification && u.status === "cancelled") {
212
+ return {
213
+ updatedTodos: currentTodos,
214
+ error: `id ${u.id} is verification todo`,
215
+ resultText: `Error: Todo #${u.id} is a verification todo and cannot be cancelled`,
216
+ };
217
+ }
186
218
  }
187
219
 
188
220
  const updated = currentTodos.map((t) => {
@@ -207,6 +239,8 @@ export function formatTodoLine(t: Todo): string {
207
239
  ? "x"
208
240
  : t.status === "in_progress"
209
241
  ? "~"
210
- : " ";
242
+ : t.status === "cancelled"
243
+ ? "-"
244
+ : " ";
211
245
  return `[${mark}] #${t.id}: ${t.text}`;
212
246
  }
package/src/render.ts CHANGED
@@ -3,10 +3,9 @@
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
- buildRender,
10
9
  getDisplayStatus,
11
10
  type Todo,
12
11
  type TodoDetails,
@@ -55,22 +54,25 @@ export function renderStatusText(todoList: Todo[], th: Theme): string {
55
54
  // ── Widget 双列渲染 ──────────────────────────────────
56
55
 
57
56
  /** 渲染单条 todo 的 widget 行(不含缩进),供 component.ts 复用 */
58
- export function renderWidgetItem(t: Todo, th: Theme): string {
57
+ function renderWidgetItem(t: Todo, th: Theme): string {
59
58
  const mark =
60
59
  t.status === "completed"
61
60
  ? th.fg("success", "\u2713")
62
61
  : t.status === "in_progress"
63
62
  ? th.fg("warning", "\u25cf")
64
- : th.fg("dim", "\u25cb");
63
+ : t.status === "cancelled"
64
+ ? th.fg("error", "\u2715")
65
+ : th.fg("dim", "\u25cb");
65
66
  const id = th.fg("accent", `#${t.id}`);
66
- const text = t.status === "completed" ? th.fg("dim", t.text) : th.fg("text", t.text);
67
+ const text =
68
+ t.status === "completed" || t.status === "cancelled" ? th.fg("dim", t.text) : th.fg("text", t.text);
67
69
  return `${mark} ${id} ${text}`;
68
70
  }
69
71
 
70
72
  /** 单列布局渲染(widget 少量任务时使用) */
71
73
  const PI_TEXT_PADDING = 2;
72
74
 
73
- export function renderSingleColumn(
75
+ function renderSingleColumn(
74
76
  todos: Todo[],
75
77
  th: Theme,
76
78
  termWidth: number,
@@ -136,7 +138,7 @@ export function renderWidgetLines(
136
138
 
137
139
  // ── 列表渲染辅助函数 ─────────────────────────────────
138
140
 
139
- export function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, theme: Theme): string {
141
+ function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, theme: Theme): string {
140
142
  if (todoList.length === 0) {
141
143
  return theme.fg("dim", "No todos");
142
144
  }
@@ -149,9 +151,11 @@ export function buildTodoListText(todoList: Todo[], options: { expanded: boolean
149
151
  ? theme.fg("success", "\u2713")
150
152
  : status === "in_progress"
151
153
  ? theme.fg("warning", "\u25cf")
152
- : theme.fg("dim", "\u25cb");
154
+ : status === "cancelled"
155
+ ? theme.fg("error", "\u2715")
156
+ : theme.fg("dim", "\u25cb");
153
157
  const itemText =
154
- status === "completed" ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
158
+ status === "completed" || status === "cancelled" ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
155
159
  listText += `\n${mark} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
156
160
  }
157
161
  if (!options.expanded && todoList.length > MAX_COLLAPSED_ITEMS) {
@@ -162,7 +166,7 @@ export function buildTodoListText(todoList: Todo[], options: { expanded: boolean
162
166
 
163
167
  // ── Tool renderResult handler ────────────────────────
164
168
 
165
- import { Text } from "@mariozechner/pi-tui";
169
+ import { Text } from "@earendil-works/pi-tui";
166
170
 
167
171
  export function renderTodoResult(result: unknown, options: { expanded: boolean }, theme: Theme): Text {
168
172
  const r = result as { content: Array<{ type: string; text?: string }>; details?: unknown };
@@ -172,10 +176,6 @@ export function renderTodoResult(result: unknown, options: { expanded: boolean }
172
176
  return new Text(text?.type === "text" ? (text.text ?? "") : "", 0, 0);
173
177
  }
174
178
 
175
- if (details.error) {
176
- return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
177
- }
178
-
179
179
  const todoList = details.todos;
180
180
 
181
181
  switch (details.action) {
@@ -205,4 +205,3 @@ export function renderTodoResult(result: unknown, options: { expanded: boolean }
205
205
  }
206
206
  }
207
207
 
208
- export { buildRender };