@zhushanwen/pi-todo 0.6.0 → 0.7.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.
@@ -1,10 +1,10 @@
1
1
  // 提示词质量回归:todo tool 的 description 与 runtime 纠错文案必须是
2
- // "弱模型友好"的——条件必填字段要给完整 JSON 正例,双形陷阱(text/texts、
3
- // id/ids)要给消歧反例,失败后 throw 要带 Correct 正例让模型自我纠正。
2
+ // "弱模型友好"的——条件必填字段在 schema 层强约束(见 schema.test.ts),双形陷阱
3
+ // (text/texts、id/ids)的 throw 要带 Correct 纠错正例让模型自我纠正。
4
4
  //
5
- // 本测试用源码文本断言锁定这些约束,防止后续重构把正例/反例/纠错文案删掉或
6
- // 弱化。读源码而非 import,避免 mock 链(tool.ts 依赖 typebox/ExtensionAPI/
7
- // Theme 等值导入)。参考 subagent-workflow 的 prompt 回归范式。
5
+ // 本测试用源码文本断言锁定这些约束,防止后续重构把纠错文案删掉或弱化。读源码而非
6
+ // import,避免 mock 链(tool.ts 依赖 typebox/ExtensionAPI/Theme 等值导入)。参考
7
+ // subagent-workflow 的 prompt 回归范式。
8
8
 
9
9
  import { readFileSync } from "node:fs";
10
10
  import { dirname, join } from "node:path";
@@ -21,8 +21,6 @@ const TOOL_SRC = readFileSync(join(__dirname, "../tool.ts"), "utf-8");
21
21
  */
22
22
  function extractDescriptionRegion(src: string): string {
23
23
  // 锚定到 registerTodoTool 内的 tool description,而非 schema 字段的 description:
24
- // (Type.String({ description: "..." }) 等 schema 字段会先被 indexOf 命中,导致
25
- // 捕获区域含 schema 描述 + tool 描述——虽当前子串唯一能过但 fragile to restructuring)。
26
24
  const regIdx = src.indexOf("registerTodoTool");
27
25
  if (regIdx === -1) throw new Error("registerTodoTool not found in tool.ts");
28
26
  const start = src.indexOf("description:", regIdx);
@@ -34,49 +32,36 @@ function extractDescriptionRegion(src: string): string {
34
32
 
35
33
  const DESCRIPTION_REGION = extractDescriptionRegion(TOOL_SRC);
36
34
 
37
- // ── description 必须给完整 JSON 正例 ─────────────────
35
+ // ── description 中文版(T7/TC9)──────────────────────
38
36
 
39
- describe("todo description — 给完整 JSON 正例", () => {
40
- it("add 正例:含 {\"action\":\"add\",\"texts\"", () => {
41
- // 弱模型 add 时易误用单数 text;正例必须显式 texts 数组。
42
- expect(DESCRIPTION_REGION).toContain('{"action":"add","texts"');
37
+ describe("todo description — 中文版(动作 + 规则)", () => {
38
+ it("开篇是中文定位语", () => {
39
+ expect(DESCRIPTION_REGION).toContain("管理当前会话的 todo 列表");
43
40
  });
44
41
 
45
- it("update single 正例:含 {\"action\":\"update\",\"id\"", () => {
46
- expect(DESCRIPTION_REGION).toContain('{"action":"update","id"');
42
+ it("含「动作:」清单(list/add/update/delete 四动作)", () => {
43
+ expect(DESCRIPTION_REGION).toContain("动作:");
44
+ expect(DESCRIPTION_REGION).toContain("list: 查看全部 todo");
45
+ expect(DESCRIPTION_REGION).toContain("add: 批量添加 todo");
46
+ expect(DESCRIPTION_REGION).toContain("update: 按 id 更新 todo");
47
+ expect(DESCRIPTION_REGION).toContain("delete: 按 id 删除 todo");
47
48
  });
48
49
 
49
- it("update batch 正例:含 {\"action\":\"update\",\"updates\"", () => {
50
- // updates[] 批量路径是 [批量优先] 规范的核心,正例不可缺。
51
- expect(DESCRIPTION_REGION).toContain('{"action":"update","updates"');
50
+ it("不含已删除的 clear 动作", () => {
51
+ expect(DESCRIPTION_REGION).not.toMatch(/clear/i);
52
52
  });
53
53
 
54
- it("delete 正例:含 {\"action\":\"delete\",\"ids\"", () => {
55
- // 弱模型 delete 时易误用单数 id;正例必须显式 ids 数组。
56
- expect(DESCRIPTION_REGION).toContain('{"action":"delete","ids"');
57
- });
58
- });
59
-
60
- // ── description 必须给双形陷阱反例(消歧) ──────────
61
-
62
- describe("todo description — 双形陷阱反例(消歧单复数)", () => {
63
- it("Don't 段存在", () => {
64
- expect(DESCRIPTION_REGION).toMatch(/Don't/);
65
- });
66
-
67
- it("反例标注:text 属于 update(add 用 texts)", () => {
68
- // 反例必须含消歧词,告诉模型 text 属于 update 而非 add。
69
- expect(DESCRIPTION_REGION).toContain("text is for update");
70
- expect(DESCRIPTION_REGION).toContain("add uses texts");
71
- });
72
-
73
- it("反例标注:id 属于 update(delete 用 ids)", () => {
74
- expect(DESCRIPTION_REGION).toContain("id is for update");
75
- expect(DESCRIPTION_REGION).toContain("delete uses ids");
54
+ it("含「规则:」三条核心行为准则", () => {
55
+ expect(DESCRIPTION_REGION).toContain("规则:");
56
+ expect(DESCRIPTION_REGION).toContain("同一时间只有一个 todo 处于 in_progress");
57
+ expect(DESCRIPTION_REGION).toContain("完成一个 todo 立即标记 completed");
58
+ expect(DESCRIPTION_REGION).toContain("未真正完成不得标记 completed");
76
59
  });
77
60
 
78
- it("反例标注:update 缺 id", () => {
79
- expect(DESCRIPTION_REGION).toContain("missing id");
61
+ it("已删除旧英文 Examples 段与 Don't 段", () => {
62
+ expect(DESCRIPTION_REGION).not.toContain("Available actions");
63
+ expect(DESCRIPTION_REGION).not.toContain("Don't");
64
+ expect(DESCRIPTION_REGION).not.toContain("Examples");
80
65
  });
81
66
  });
82
67
 
@@ -100,18 +85,13 @@ describe("todo runtime — throw 含 Correct 纠错正例", () => {
100
85
  });
101
86
  });
102
87
 
103
- // ── promptSnippet / promptGuidelines 验证引导(verification guidance)──
88
+ // ── promptSnippet / promptGuidelines(verification guidance)──
104
89
  // DESCRIPTION_REGION 截取止于 promptSnippet:,不含这两个字段,故对完整 TOOL_SRC 断言。
105
90
 
106
- describe("todo tool prompt — verification guidance", () => {
107
- it("promptSnippet 引导验证步骤建 todo", () => {
108
- expect(TOOL_SRC).toContain(
109
- "Consider adding a separate todo for verification checks like running tests or typecheck.",
110
- );
111
- });
112
-
113
- it("promptSnippet 声明多步骤工作场景", () => {
114
- expect(TOOL_SRC).toContain("Use todo when breaking multi-step work into trackable items.");
91
+ describe("todo tool prompt — snippet & guidelines", () => {
92
+ it("promptSnippet 为中文精简版(验证步骤引导)", () => {
93
+ expect(TOOL_SRC).toContain("用 todo 跟踪多步骤工作");
94
+ expect(TOOL_SRC).toContain("验证步骤");
115
95
  });
116
96
 
117
97
  it("promptGuidelines 含 [验证任务] 条目(完成前确保验证通过)", () => {
@@ -119,7 +99,7 @@ describe("todo tool prompt — verification guidance", () => {
119
99
  });
120
100
 
121
101
  it("promptGuidelines 含 [自动闭合] / [批量优先] 核心条目", () => {
122
- expect(TOOL_SRC).toContain("[自动闭合] 全部完成后工具自动清理,无需手动 clear");
102
+ expect(TOOL_SRC).toContain("[自动闭合] 全部完成后自动清理");
123
103
  expect(TOOL_SRC).toContain("[批量优先] 完成多项任务时使用 updates[] 批量更新");
124
104
  });
125
105
  });
@@ -267,7 +267,7 @@ describe("executeTodoAction — state isolation & snapshot", () => {
267
267
 
268
268
  it("S-2: details.todos is a shallow array copy (splice-safe, element-shared)", async () => {
269
269
  // executeTodoAction 用 [...state.todos] 做浅拷贝:数组独立、元素共享。
270
- // add/clear 改数组长度时旧 details.todos 不受影响;但原地改元素会共享。
270
+ // add/delete 改数组长度时旧 details.todos 不受影响;但原地改元素会共享。
271
271
  const { tool } = setup();
272
272
  await tool.execute("id", { action: "add", texts: ["a", "b"] }, undefined, undefined, makeRpcCtx());
273
273
  const before = (await tool.execute(
@@ -278,8 +278,8 @@ describe("executeTodoAction — state isolation & snapshot", () => {
278
278
  makeRpcCtx(),
279
279
  )).details.todos;
280
280
  expect(before).toHaveLength(2);
281
- // clear 改 state.todos 数组,已发出的 before 快照仍为 2 项
282
- await tool.execute("id", { action: "clear" }, undefined, undefined, makeRpcCtx());
281
+ // delete 改 state.todos 数组,已发出的 before 快照仍为 2 项
282
+ await tool.execute("id", { action: "delete", ids: [1, 2] }, undefined, undefined, makeRpcCtx());
283
283
  expect(before).toHaveLength(2);
284
284
  });
285
285
  });
package/src/handlers.ts CHANGED
@@ -15,29 +15,16 @@ import type { TodoSessionState } from "./state";
15
15
 
16
16
  /** 全部完成后保留的轮数,之后再自动 clear */
17
17
  const AUTO_CLEAR_DELAY_ROUNDS = 2;
18
- /** Stall 检测阈值(无 todo 活动轮数 → stall 提醒) */
19
- const STALL_THRESHOLD = 5;
20
- /** 提醒间隔(上次 todo 调用后轮数 → 提醒) */
21
- const REMINDER_INTERVAL = 2;
22
18
 
23
19
  // ── 辅助函数 ────────────────────────────────────────
24
20
 
25
21
  export type RefreshDisplayFn = (ctx: ExtensionContext) => void;
26
22
 
27
- /** 未完成任务判定:pending / in_progress(cancelled 不可恢复,从提醒排除) */
23
+ /** 未完成任务判定:pending / in_progress */
28
24
  function isPending(t: TodoDetails["todos"][number]): boolean {
29
25
  return t.status === "pending" || t.status === "in_progress";
30
26
  }
31
27
 
32
- /** 构建极简提醒:只含下一个推荐任务 + 行动指令 */
33
- export function buildMinimalReminder(state: TodoSessionState): string {
34
- const pendingTodos = state.todos.filter(isPending);
35
- if (pendingTodos.length === 0) return "";
36
-
37
- const next = pendingTodos[0];
38
- return `<todo_context>\n[TODO] 你有 ${pendingTodos.length} 个未完成任务已搁置。下一个必须处理:#${next.id} ${next.text}。完成后用 todo update 标记 completed,不要继续搁置。\n</todo_context>`;
39
- }
40
-
41
28
  export function buildBeforeAgentStartMessage(state: TodoSessionState): { message: { customType: string; content: string; display: boolean } } | undefined {
42
29
  if (state.todos.length === 0) return undefined;
43
30
 
@@ -59,18 +46,22 @@ export function buildBeforeAgentStartMessage(state: TodoSessionState): { message
59
46
 
60
47
  // ── 状态重建 ────────────────────────────────────────
61
48
 
49
+ /**
50
+ * 回放最后一条 todo toolResult 重建 state(纯读,不修改 entries)。
51
+ *
52
+ * H1(C2 决策):pi 的 SessionManager.getEntries() 返回的是 filter-copy,原先的
53
+ * splice GC 段对副本操作无效,且修改传入 entries 是反模式。删除整段 splice,只保留
54
+ * 回放逻辑——找到最后一条有效 todo 快照,迁移脏数据后载入 state。
55
+ */
62
56
  export function reconstructState(state: TodoSessionState, ctx: ExtensionContext): void {
63
57
  state.todos = [];
64
58
  state.nextId = 1;
65
59
  state.userMessageCount = 0;
66
- state.lastTodoCallCount = 0;
67
- state.stallNotified = false;
68
60
  state.allCompletedAtCount = null;
69
61
  state.completionSteered = false;
70
62
  state.pendingSteerMessage = null;
71
63
 
72
64
  const entries = ctx.sessionManager.getEntries();
73
- let latestIdx = -1;
74
65
 
75
66
  for (let i = 0; i < entries.length; i++) {
76
67
  const entry = entries[i];
@@ -93,25 +84,9 @@ export function reconstructState(state: TodoSessionState, ctx: ExtensionContext)
93
84
  if (migrated.length > 0) {
94
85
  state.todos = migrated;
95
86
  state.nextId = details.nextId ?? Math.max(...migrated.map((t) => t.id)) + 1;
96
- latestIdx = i;
97
87
  }
98
88
  }
99
89
  }
100
-
101
- if (latestIdx >= 0) {
102
- const staleIndices: number[] = [];
103
- for (let i = 0; i < latestIdx; i++) {
104
- const entry = entries[i];
105
- if (entry.type !== "message") continue;
106
- const msg = entry.message;
107
- if (msg.role === "toolResult" && msg.toolName === "todo") {
108
- staleIndices.push(i);
109
- }
110
- }
111
- for (let j = staleIndices.length - 1; j >= 0; j--) {
112
- entries.splice(staleIndices[j], 1);
113
- }
114
- }
115
90
  }
116
91
 
117
92
  // ── agent_end 子函数 ────────────────────────────────
@@ -145,32 +120,6 @@ export function handleCompletionSteer(state: TodoSessionState): boolean {
145
120
  return true;
146
121
  }
147
122
 
148
- export function handleStallDetection(state: TodoSessionState): boolean {
149
- if (
150
- !state.stallNotified &&
151
- state.userMessageCount - state.lastTodoCallCount >= STALL_THRESHOLD
152
- ) {
153
- state.stallNotified = true;
154
- const reminder = buildMinimalReminder(state);
155
- if (reminder) {
156
- state.pendingSteerMessage = reminder;
157
- }
158
- return true;
159
- }
160
- return false;
161
- }
162
-
163
- export function handleReminder(state: TodoSessionState): boolean {
164
- if (state.userMessageCount - state.lastTodoCallCount >= REMINDER_INTERVAL) {
165
- const reminder = buildMinimalReminder(state);
166
- if (reminder) {
167
- state.pendingSteerMessage = reminder;
168
- }
169
- return true;
170
- }
171
- return false;
172
- }
173
-
174
123
  // ── Event handler 注册入口 ──────────────────────────
175
124
 
176
125
  export function registerTodoEventHandlers(
@@ -218,12 +167,12 @@ export function registerTodoEventHandlers(
218
167
  // 全部 completed → 总检查 steer(仅一次)
219
168
  handleCompletionSteer(state);
220
169
 
221
- // auto-clear
170
+ // auto-clear(全部完成后延迟清理)
222
171
  const ac = handleAutoClear(state);
223
- if (ac.handled) { if (ac.cleared) refreshDisplay(ctx); return; }
224
-
225
- if (handleStallDetection(state)) return;
226
- handleReminder(state);
172
+ if (ac.handled) {
173
+ if (ac.cleared) refreshDisplay(ctx);
174
+ return;
175
+ }
227
176
  } catch (e) {
228
177
  // best-effort:agent_end 事件处理器出错不阻断会话主流程,仅记录调试日志
229
178
  console.debug("[todo] agent_end error:", e);
package/src/index.ts CHANGED
@@ -8,9 +8,9 @@
8
8
  * 文件职责:
9
9
  * - state.ts: TodoSessionState 会话状态接口 + 工厂(闭包内创建,session 隔离)
10
10
  * - model.ts: 纯函数数据层(Todo 类型、migrateTodo 兼容迁移、addTodos/updateTodos、format/buildGui)
11
- * - tool.ts: todo tool 注册 — 5 个 action(list/add/update/delete/clear)+ execute dispatcher
11
+ * - tool.ts: todo tool 注册 — 4 个 action(list/add/update/delete)+ execute dispatcher
12
12
  * - handlers.ts: 5 个事件处理器(session_start/session_tree/agent_start/before_agent_start/agent_end)
13
- * + reconstructState(回放最后一条 todo toolResult)+ steer 四机制(autoClear/completion/stall/reminder)
13
+ * + reconstructState(回放最后一条 todo toolResult)+ steer 双机制(autoClear/completion)
14
14
  * - render.ts: 状态栏(status line)/ widget(单双列自适应)/ tool result 三层渲染
15
15
  * - component.ts: /todos 命令的 TodoListComponent TUI 视图(只读双列)
16
16
  * - commands.ts: /todos 命令注册
package/src/model.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Todo 数据模型 — 纯函数,不依赖 Pi 运行时。
3
- * 四态: pending → in_progress → completed;任一状态 → cancelled
4
- * (cancelled 不可恢复)
3
+ * 三态: pending → in_progress → completed
5
4
  */
6
5
 
7
6
  import { guiComponent, type GuiRenderResult, guiResult, type TreeItem } from "@xyz-agent/extension-protocol";
@@ -11,24 +10,24 @@ import { guiComponent, type GuiRenderResult, guiResult, type TreeItem } from "@x
11
10
  export interface Todo {
12
11
  id: number;
13
12
  text: string;
14
- status: "pending" | "in_progress" | "completed" | "cancelled";
13
+ status: "pending" | "in_progress" | "completed";
15
14
  }
16
15
 
17
16
  export interface TodoDetails {
18
- action: "list" | "add" | "update" | "delete" | "clear";
17
+ action: "list" | "add" | "update" | "delete";
19
18
  todos: Todo[];
20
19
  nextId: number;
21
20
  /** GUI 渲染结果(仅 RPC 模式填充,前端 list-tree 渲染)。对齐 extension-protocol@0.2.0。 */
22
21
  __gui__?: GuiRenderResult;
23
22
  }
24
23
 
25
- export const VALID_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const;
24
+ export const VALID_STATUSES = ["pending", "in_progress", "completed"] as const;
26
25
 
27
26
  export type ValidStatus = (typeof VALID_STATUSES)[number];
28
27
 
29
28
  // ── 迁移/兼容 ───────────────────────────────────────
30
29
 
31
- /** 旧格式迁移:verifying → in_progress,failed → pending,done:boolean → status */
30
+ /** 旧格式迁移:verifying → in_progress,failed → pending,cancelled → completed(历史三态化降级),done:boolean → status */
32
31
  export function migrateTodo(raw: unknown): Todo {
33
32
  // raw 是任意旧格式数据(兼容 done:boolean 等历史结构),以 Record 方式安全访问字段
34
33
  // 守卫:null/原始类型(typeof null === 'object',必须显式排除 null)→ 明确报错而非混淆的 TypeError
@@ -51,10 +50,12 @@ export function migrateTodo(raw: unknown): Todo {
51
50
  status = done === true ? "completed" : "pending";
52
51
  }
53
52
 
54
- // 旧版五态映射(先转 string 避免类型收窄后无法比较)
53
+ // 历史状态映射(先转 string 避免类型收窄后无法比较)
55
54
  const rawStatus = record.status as string | undefined;
56
55
  if (rawStatus === "verifying") status = "in_progress";
57
56
  if (rawStatus === "failed") status = "pending";
57
+ // 三态化降级:历史 cancelled 项映射为 completed(不丢数据,且解除 every(completed) 死锁)
58
+ if (rawStatus === "cancelled") status = "completed";
58
59
 
59
60
  return {
60
61
  id: record.id as number,
@@ -71,7 +72,6 @@ export function migrateTodo(raw: unknown): Todo {
71
72
  * pending → dot / 无 status
72
73
  * in_progress → circle / running
73
74
  * completed → check / done
74
- * cancelled → cross / failed
75
75
  */
76
76
  export function buildGui(todos: Todo[]): GuiRenderResult {
77
77
  const items: TreeItem[] = todos.map((t) => {
@@ -80,17 +80,13 @@ export function buildGui(todos: Todo[]): GuiRenderResult {
80
80
  ? "check"
81
81
  : t.status === "in_progress"
82
82
  ? "circle"
83
- : t.status === "cancelled"
84
- ? "cross"
85
- : "dot"; // pending
83
+ : "dot"; // pending
86
84
  const status =
87
85
  t.status === "in_progress"
88
86
  ? "running"
89
87
  : t.status === "completed"
90
88
  ? "done"
91
- : t.status === "cancelled"
92
- ? "failed"
93
- : undefined; // pending 无 status
89
+ : undefined; // pending 无 status
94
90
  return {
95
91
  icon,
96
92
  label: `#${t.id}: ${t.text}`,
@@ -110,32 +106,27 @@ export function getDisplayStatus(t: Todo): string {
110
106
  export interface AddResult {
111
107
  newTodos: Todo[];
112
108
  newNextId: number;
113
- error?: string;
114
- resultText?: string;
109
+ resultText: string;
115
110
  }
116
111
 
112
+ /**
113
+ * 批量新增 todo。
114
+ * texts 整体 trim;任一项 trim 后为空串则 throw(不再静默 filter 丢弃——
115
+ * 模型应学到传有效项,C1 决策)。
116
+ */
117
117
  export function addTodos(
118
118
  currentTodos: Todo[],
119
119
  currentNextId: number,
120
120
  texts: string[],
121
121
  ): AddResult {
122
122
  if (!texts || texts.length === 0) {
123
- return {
124
- newTodos: currentTodos,
125
- newNextId: currentNextId,
126
- error: "texts required",
127
- resultText: "Error: add requires texts parameter (non-empty array)",
128
- };
123
+ throw new Error("add requires texts parameter (non-empty array)");
129
124
  }
130
125
 
131
- const trimmed = texts.map((t) => t.trim()).filter((t) => t.length > 0);
132
- if (trimmed.length === 0) {
133
- return {
134
- newTodos: currentTodos,
135
- newNextId: currentNextId,
136
- error: "all texts empty",
137
- resultText: "Error: texts must contain at least one non-empty string",
138
- };
126
+ const trimmed = texts.map((t) => t.trim());
127
+ // 任一项 trim 后空串 → throw(不静默 filter)
128
+ if (trimmed.some((t) => t.length === 0)) {
129
+ throw new Error("texts must not contain empty or whitespace-only items");
139
130
  }
140
131
 
141
132
  const startId = currentNextId;
@@ -169,6 +160,13 @@ export function updateTodos(
169
160
  currentTodos: Todo[],
170
161
  updates: Array<{ id: number; status?: string; text?: string }>,
171
162
  ): UpdateResult {
163
+ // text 校验统一(CT5):text 存在则 trim,空串 throw(不静默跳过)
164
+ for (const u of updates) {
165
+ if (u.text !== undefined && u.text.trim().length === 0) {
166
+ throw new Error(`update item id ${u.id}: text cannot be empty or whitespace-only`);
167
+ }
168
+ }
169
+
172
170
  const ids = updates.map((u) => u.id);
173
171
  if (new Set(ids).size !== ids.length) {
174
172
  return {
@@ -200,14 +198,6 @@ export function updateTodos(
200
198
  resultText: `Error: invalid status '${u.status}' for update item id ${u.id}`,
201
199
  };
202
200
  }
203
- // 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
201
  }
212
202
 
213
203
  const updated = currentTodos.map((t) => {
@@ -215,7 +205,7 @@ export function updateTodos(
215
205
  if (!u) return t;
216
206
  const patch: Partial<Todo> = {};
217
207
  if (u.status) patch.status = u.status as Todo["status"];
218
- if (u.text) patch.text = u.text;
208
+ if (u.text !== undefined) patch.text = u.text.trim();
219
209
  return { ...t, ...patch };
220
210
  });
221
211
  return {
@@ -232,8 +222,11 @@ export function formatTodoLine(t: Todo): string {
232
222
  ? "x"
233
223
  : t.status === "in_progress"
234
224
  ? "~"
235
- : t.status === "cancelled"
236
- ? "-"
237
- : " ";
225
+ : " "; // pending
238
226
  return `[${mark}] #${t.id}: ${t.text}`;
239
227
  }
228
+
229
+ /** 把整张列表格式化为多行文本,每行复用 formatTodoLine(T3)。 */
230
+ export function formatTodoList(todos: Todo[]): string {
231
+ return todos.map((t) => formatTodoLine(t)).join("\n");
232
+ }
package/src/render.ts CHANGED
@@ -60,12 +60,9 @@ function renderWidgetItem(t: Todo, th: Theme): string {
60
60
  ? th.fg("success", "\u2713")
61
61
  : t.status === "in_progress"
62
62
  ? th.fg("warning", "\u25cf")
63
- : t.status === "cancelled"
64
- ? th.fg("error", "\u2715")
65
- : th.fg("dim", "\u25cb");
63
+ : th.fg("dim", "\u25cb"); // pending
66
64
  const id = th.fg("accent", `#${t.id}`);
67
- const text =
68
- t.status === "completed" || t.status === "cancelled" ? th.fg("dim", t.text) : th.fg("text", t.text);
65
+ const text = t.status === "completed" ? th.fg("dim", t.text) : th.fg("text", t.text);
69
66
  return `${mark} ${id} ${text}`;
70
67
  }
71
68
 
@@ -151,11 +148,8 @@ function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, the
151
148
  ? theme.fg("success", "\u2713")
152
149
  : status === "in_progress"
153
150
  ? theme.fg("warning", "\u25cf")
154
- : status === "cancelled"
155
- ? theme.fg("error", "\u2715")
156
- : theme.fg("dim", "\u25cb");
157
- const itemText =
158
- status === "completed" || status === "cancelled" ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
151
+ : theme.fg("dim", "\u25cb"); // pending
152
+ const itemText = status === "completed" ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
159
153
  listText += `\n${mark} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
160
154
  }
161
155
  if (!options.expanded && todoList.length > MAX_COLLAPSED_ITEMS) {
@@ -185,8 +179,7 @@ export function renderTodoResult(result: unknown, options: { expanded: boolean }
185
179
 
186
180
  case "add":
187
181
  case "update":
188
- case "delete":
189
- case "clear": {
182
+ case "delete": {
190
183
  const text = r.content[0];
191
184
  const msg = text?.type === "text" ? (text.text ?? "") : "";
192
185
  const listText = buildTodoListText(todoList, options, theme);
@@ -204,4 +197,3 @@ export function renderTodoResult(result: unknown, options: { expanded: boolean }
204
197
  }
205
198
  }
206
199
  }
207
-
package/src/state.ts CHANGED
@@ -12,10 +12,8 @@ import type { Todo } from "./model";
12
12
  export interface TodoSessionState {
13
13
  todos: Todo[];
14
14
  nextId: number;
15
- // 用户消息轮数与提醒追踪
15
+ // 用户消息轮数追踪(auto-clear 锚定用)
16
16
  userMessageCount: number;
17
- lastTodoCallCount: number;
18
- stallNotified: boolean;
19
17
  allCompletedAtCount: number | null;
20
18
  /** 全部 completed 时已注入 steer,防止重复 */
21
19
  completionSteered: boolean;
@@ -28,8 +26,6 @@ export function createTodoSessionState(): TodoSessionState {
28
26
  todos: [],
29
27
  nextId: 1,
30
28
  userMessageCount: 0,
31
- lastTodoCallCount: 0,
32
- stallNotified: false,
33
29
  allCompletedAtCount: null,
34
30
  completionSteered: false,
35
31
  pendingSteerMessage: null,