@zhushanwen/pi-todo 0.9.0 → 0.9.2

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 CHANGED
@@ -37,7 +37,7 @@ pi install npm:@zhushanwen/pi-todo
37
37
 
38
38
  ### 错误处理约定
39
39
 
40
- handler 失败**直接 `throw new Error()`**,不返回错误成功模式(见 CLAUDE.md「Tool 设计」)。常见错误:
40
+ handler 失败**直接 `throw new Error()`**,不返回错误成功模式(见 docs/extensions/extension-conventions.md「Tool 设计」)。常见错误:
41
41
 
42
42
  | 触发 | 错误信息 |
43
43
  |------|---------|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-todo",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "AI-driven todo list for Pi — stateful task management with session persistence and /todos command.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -29,7 +29,7 @@
29
29
  "vitest": "^4.1.8"
30
30
  },
31
31
  "dependencies": {
32
- "@xyz-agent/extension-protocol": "0.9.0",
32
+ "@xyz-agent/extension-protocol": "0.11.0",
33
33
  "@zhushanwen/pi-extension-logger": "0.6.0"
34
34
  },
35
35
  "peerDependencies": {
@@ -1,8 +1,9 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
2
3
  import { describe, expect, it } from "vitest";
3
4
 
4
5
  import type { Todo, TodoDetails } from "../model";
5
- import { renderTodoResult } from "../render";
6
+ import { renderDualColumn, renderTodoResult } from "../render";
6
7
 
7
8
  // ── mock theme(fg 直通,便于断言纯文本)────────────
8
9
 
@@ -100,3 +101,53 @@ describe("renderTodoResult", () => {
100
101
  expect(renderedText(result)).toContain("Done");
101
102
  });
102
103
  });
104
+
105
+ // ── renderDualColumn 列宽行为锁定 ──────────────────────
106
+ // 锁定 D14 替换前的列宽补齐/截断可见输出(ext-simplify 裁决零行为变化):
107
+ // 截断形态是「前缀 + 6 个点」(内层省略号 + 外层省略号的旧组合)——这是锁定旧行为,不是认可该形态。
108
+
109
+ describe("renderDualColumn column fitting", () => {
110
+ const longTodos: Todo[] = [
111
+ { id: 1, text: "x".repeat(30), status: "pending" },
112
+ { id: 2, text: "y".repeat(30), status: "pending" },
113
+ ];
114
+
115
+ it("超宽列文本截断为 前缀+6点,两列均恰好列宽", () => {
116
+ const termWidth = 27; // colWidth = floor((27 - 2 - 3) / 2) = 11
117
+ const lines = renderDualColumn(longTodos, mockTheme, termWidth, " ");
118
+ expect(lines).toHaveLength(1);
119
+ const row = stripTerminalSequences(lines[0]);
120
+ expect(visibleWidth(row)).toBe(11 + 3 + 11);
121
+ const divIdx = row.indexOf("│");
122
+ // divider(" │ ")的前后空格不属于列内容,trim 后再断言
123
+ const left = row.slice(0, divIdx).trimEnd();
124
+ const right = row.slice(divIdx + 1).trimStart();
125
+ // 两列均以 6 个点收尾(前缀+6点旧形态)
126
+ expect(left.endsWith("......")).toBe(true);
127
+ expect(right.endsWith("......")).toBe(true);
128
+ // 点前的前缀可见宽恰为 colWidth - 6
129
+ expect(visibleWidth(left.slice(0, left.length - 6))).toBe(11 - 6);
130
+ });
131
+
132
+ it("列内文本未超宽时不产生省略号,补齐到列宽", () => {
133
+ const shortTodos: Todo[] = [
134
+ { id: 1, text: "ab", status: "pending" },
135
+ { id: 2, text: "cd", status: "pending" },
136
+ ];
137
+ const lines = renderDualColumn(shortTodos, mockTheme, 27, " ");
138
+ const row = stripTerminalSequences(lines[0]);
139
+ expect(visibleWidth(row)).toBe(11 + 3 + 11);
140
+ expect(row).not.toContain(".");
141
+ expect(row).toContain("#1 ab");
142
+ expect(row).toContain("#2 cd");
143
+ });
144
+
145
+ it("colWidth <= 3 时超宽列退化为省略号截片", () => {
146
+ const termWidth = 11; // colWidth = floor((11 - 2 - 3) / 2) = 3
147
+ const lines = renderDualColumn(longTodos, mockTheme, termWidth, " ");
148
+ const row = stripTerminalSequences(lines[0]);
149
+ expect(visibleWidth(row)).toBe(3 + 3 + 3);
150
+ const divIdx = row.indexOf("│");
151
+ expect(row.slice(0, divIdx).trimEnd()).toBe("...");
152
+ });
153
+ });
@@ -68,13 +68,13 @@ describe("handleCompletionSteer", () => {
68
68
  describe("handleAutoClear", () => {
69
69
  it("does not handle when not all completed, and resets anchor", () => {
70
70
  const s = makeState([{ id: 1, text: "a", status: "pending" }], { allCompletedAtCount: 3 });
71
- expect(handleAutoClear(s)).toEqual({ handled: false, cleared: false });
71
+ expect(handleAutoClear(s)).toBe(false);
72
72
  expect(s.allCompletedAtCount).toBeNull();
73
73
  });
74
74
 
75
75
  it("anchors on first all-completed round without clearing", () => {
76
76
  const s = makeState([{ id: 1, text: "a", status: "completed" }], { userMessageCount: 5 });
77
- expect(handleAutoClear(s)).toEqual({ handled: true, cleared: false });
77
+ expect(handleAutoClear(s)).toBe(false);
78
78
  expect(s.allCompletedAtCount).toBe(5);
79
79
  expect(s.todos).toHaveLength(1);
80
80
  });
@@ -83,14 +83,14 @@ describe("handleAutoClear", () => {
83
83
  const s = makeState([{ id: 1, text: "a", status: "completed" }], {
84
84
  userMessageCount: 5, allCompletedAtCount: 4,
85
85
  });
86
- expect(handleAutoClear(s)).toEqual({ handled: true, cleared: false });
86
+ expect(handleAutoClear(s)).toBe(false);
87
87
  });
88
88
 
89
89
  it("clears and resets flags after delay elapses", () => {
90
90
  const s = makeState([{ id: 1, text: "a", status: "completed" }], {
91
91
  userMessageCount: 6, allCompletedAtCount: 4, completionSteered: true,
92
92
  });
93
- expect(handleAutoClear(s)).toEqual({ handled: true, cleared: true });
93
+ expect(handleAutoClear(s)).toBe(true);
94
94
  expect(s.todos).toEqual([]);
95
95
  expect(s.nextId).toBe(1);
96
96
  expect(s.allCompletedAtCount).toBeNull();
@@ -229,10 +229,10 @@ describe("reconstructState", () => {
229
229
  describe("agent_end short-circuit order", () => {
230
230
  it("completion steer fires before auto-clear (completion does not short-circuit)", () => {
231
231
  const s = makeState([{ id: 1, text: "a", status: "completed" }], { userMessageCount: 5 });
232
- // 模拟 agent_end: handleCompletionSteer(不短路) → handleAutoClear(短路)
232
+ // 模拟 agent_end: handleCompletionSteer(不短路) → handleAutoClear
233
233
  expect(handleCompletionSteer(s)).toBe(true);
234
234
  expect(s.pendingSteerMessage).toContain("交付质量");
235
- expect(handleAutoClear(s)).toEqual({ handled: true, cleared: false });
235
+ expect(handleAutoClear(s)).toBe(false);
236
236
  expect(s.allCompletedAtCount).toBe(5);
237
237
  });
238
238
 
@@ -243,7 +243,7 @@ describe("agent_end short-circuit order", () => {
243
243
  completionSteered: true, pendingSteerMessage: "<queued>",
244
244
  });
245
245
  expect(handleCompletionSteer(s)).toBe(false); // 已 steered,不重复
246
- expect(handleAutoClear(s).cleared).toBe(true);
246
+ expect(handleAutoClear(s)).toBe(true);
247
247
  expect(s.todos).toEqual([]);
248
248
  // pendingSteerMessage 仍保留,由下一 turn before_agent_start 消费(此时 todos 已空)
249
249
  expect(s.pendingSteerMessage).toBe("<queued>");
@@ -198,7 +198,6 @@ describe("todo update batch", () => {
198
198
  { id: 3, status: "completed", text: "C done" },
199
199
  ]);
200
200
 
201
- expect(result.error).toBeUndefined();
202
201
  expect(result.updatedTodos).toHaveLength(3);
203
202
  expect(result.updatedTodos[0].status).toBe("completed");
204
203
  expect(result.updatedTodos[0].text).toBe("A");
@@ -211,7 +210,6 @@ describe("todo update batch", () => {
211
210
  it("TC6: trims text on apply(批量路径)", () => {
212
211
  const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
213
212
  const result = updateTodos(todos, [{ id: 1, text: " B updated " }]);
214
- expect(result.error).toBeUndefined();
215
213
  expect(result.updatedTodos[0].text).toBe("B updated");
216
214
  });
217
215
 
@@ -222,30 +220,33 @@ describe("todo update batch", () => {
222
220
 
223
221
  it("should reject duplicate ids in updates[]", () => {
224
222
  const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
225
- const result = updateTodos(todos, [
226
- { id: 1, status: "completed" },
227
- { id: 1, status: "pending" },
228
- ]);
229
- expect(result.error).toBe("duplicate ids in updates");
230
- expect(result.updatedTodos).toEqual(todos);
223
+ expect(() =>
224
+ updateTodos(todos, [
225
+ { id: 1, status: "completed" },
226
+ { id: 1, status: "pending" },
227
+ ]),
228
+ ).toThrow("duplicate ids in updates");
231
229
  });
232
230
 
233
231
  it("should reject non-existent ids", () => {
234
232
  const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
235
- const result = updateTodos(todos, [{ id: 999, status: "pending" }]);
236
- expect(result.error).toBe("id 999 not found");
233
+ expect(() => updateTodos(todos, [{ id: 999, status: "pending" }])).toThrow(
234
+ "Todo #999 not found",
235
+ );
237
236
  });
238
237
 
239
238
  it("should reject updates[] item missing both status and text", () => {
240
239
  const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
241
- const result = updateTodos(todos, [{ id: 1 }]);
242
- expect(result.error).toContain("neither status nor text");
240
+ expect(() => updateTodos(todos, [{ id: 1 }])).toThrow(
241
+ "update item for id 1 has neither status nor text",
242
+ );
243
243
  });
244
244
 
245
245
  it("should reject invalid status values", () => {
246
246
  const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
247
- const result = updateTodos(todos, [{ id: 1, status: "banana" }]);
248
- expect(result.error).toContain("invalid status");
247
+ expect(() => updateTodos(todos, [{ id: 1, status: "banana" }])).toThrow(
248
+ "invalid status 'banana' for update item id 1",
249
+ );
249
250
  });
250
251
  });
251
252
 
@@ -312,14 +313,12 @@ describe("completed without interception", () => {
312
313
  it("should allow in_progress → completed directly", () => {
313
314
  const todos: Todo[] = [{ id: 1, text: "simple", status: "in_progress" }];
314
315
  const result = updateTodos(todos, [{ id: 1, status: "completed" }]);
315
- expect(result.error).toBeUndefined();
316
316
  expect(result.updatedTodos[0].status).toBe("completed");
317
317
  });
318
318
 
319
319
  it("should allow pending → completed directly", () => {
320
320
  const todos: Todo[] = [{ id: 1, text: "skip", status: "pending" }];
321
321
  const result = updateTodos(todos, [{ id: 1, status: "completed" }]);
322
- expect(result.error).toBeUndefined();
323
322
  expect(result.updatedTodos[0].status).toBe("completed");
324
323
  });
325
324
 
@@ -332,7 +331,6 @@ describe("completed without interception", () => {
332
331
  { id: 1, status: "completed" },
333
332
  { id: 2, status: "completed" },
334
333
  ]);
335
- expect(result.error).toBeUndefined();
336
334
  expect(result.updatedTodos[0].status).toBe("completed");
337
335
  expect(result.updatedTodos[1].status).toBe("completed");
338
336
  });
@@ -203,7 +203,7 @@ describe("refreshDisplay — GUI widget 推送(setup 传真实实现)", () =
203
203
  const [, value] = setWidget.mock.calls[0]!;
204
204
  expect(value!.length).toBeGreaterThan(0);
205
205
  for (const line of value!) {
206
- // isGuiCapable 外层判定生效:TUI 行不含 GUI marker 编码
206
+ // setWidgetDual 模式分派生效(守卫单点在 protocol helper):TUI 行不含 GUI marker 编码
207
207
  expect(line.startsWith(GUI_WIDGET_MARKER)).toBe(false);
208
208
  }
209
209
  });
package/src/component.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  import type { Theme } from "@earendil-works/pi-coding-agent";
6
6
  import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
7
7
 
8
- import type { Todo } from "./model";
8
+ import { todoProgress, type Todo } from "./model";
9
9
  import { FALLBACK_TERM_WIDTH, renderDualColumn } from "./render";
10
10
 
11
11
  const HEADER_PREFIX_DASHES = 3;
@@ -50,8 +50,7 @@ export class TodoListComponent {
50
50
  if (this.todos.length === 0) {
51
51
  lines.push(truncateToWidth(`${indent}${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, termWidth));
52
52
  } else {
53
- const completed = this.todos.filter((t) => t.status === "completed").length;
54
- const total = this.todos.length;
53
+ const { completed, total } = todoProgress(this.todos);
55
54
  lines.push(truncateToWidth(`${indent}${th.fg("muted", `${completed}/${total} completed`)}`, termWidth));
56
55
  lines.push("");
57
56
 
package/src/handlers.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  migrateTodo,
11
11
  type TodoDetails,
12
12
  } from "./model";
13
+ import { renderStatusText } from "./render";
13
14
  import type { TodoSessionState } from "./state";
14
15
 
15
16
  const logger = getLogger("todo");
@@ -94,11 +95,12 @@ export function reconstructState(state: TodoSessionState, ctx: ExtensionContext)
94
95
 
95
96
  // ── agent_end 子函数 ────────────────────────────────
96
97
 
97
- export function handleAutoClear(state: TodoSessionState): { handled: boolean; cleared: boolean } {
98
+ /** 返回是否已清空(false 含三种形态:未全完成 / 本轮刚锚定 / 延迟未到——调用方均无需刷新) */
99
+ export function handleAutoClear(state: TodoSessionState): boolean {
98
100
  const allCompleted = state.todos.every((t) => t.status === "completed");
99
101
  if (!allCompleted) {
100
102
  state.allCompletedAtCount = null;
101
- return { handled: false, cleared: false };
103
+ return false;
102
104
  }
103
105
  if (state.allCompletedAtCount === null) {
104
106
  state.allCompletedAtCount = state.userMessageCount;
@@ -108,9 +110,9 @@ export function handleAutoClear(state: TodoSessionState): { handled: boolean; cl
108
110
  state.nextId = 1;
109
111
  state.allCompletedAtCount = null;
110
112
  state.completionSteered = false;
111
- return { handled: true, cleared: true };
113
+ return true;
112
114
  }
113
- return { handled: true, cleared: false };
115
+ return false;
114
116
  }
115
117
 
116
118
  export function handleCompletionSteer(state: TodoSessionState): boolean {
@@ -147,7 +149,8 @@ export function registerTodoEventHandlers(
147
149
  try {
148
150
  const pendingTodos = state.todos.filter(isPending);
149
151
  if (pendingTodos.length > 0) {
150
- ctx.ui.setStatus("todo", `📋 ${pendingTodos.length} pending`);
152
+ // 文案与 refreshDisplay 共用 renderStatusText(N/M 口径),避免同一 status 槽两种格式交替
153
+ ctx.ui.setStatus("todo", renderStatusText(state.todos, ctx.ui.theme));
151
154
  }
152
155
  // 优先级 1: agent_end 设置的延迟 steer
153
156
  if (state.pendingSteerMessage) {
@@ -170,12 +173,8 @@ export function registerTodoEventHandlers(
170
173
  // 全部 completed → 总检查 steer(仅一次)
171
174
  handleCompletionSteer(state);
172
175
 
173
- // auto-clear(全部完成后延迟清理)
174
- const ac = handleAutoClear(state);
175
- if (ac.handled) {
176
- if (ac.cleared) refreshDisplay(ctx);
177
- return;
178
- }
176
+ // auto-clear(全部完成后延迟清理;已清空才需要刷新显示)
177
+ if (handleAutoClear(state)) refreshDisplay(ctx);
179
178
  } catch (e) {
180
179
  // best-effort:agent_end 事件处理器出错不阻断会话主流程,仅记录调试日志
181
180
  logger.debug("agent_end error", { error: String(e) });
package/src/index.ts CHANGED
@@ -16,15 +16,16 @@
16
16
  * - commands.ts: /todos 命令注册
17
17
  * - index.ts(本文件): 工厂入口(创建 state + 注册 tool/command/event + makeRefreshDisplay)
18
18
  *
19
- * 错误处理:handler 失败直接 throw(见 CLAUDE.md「Tool 设计」),不返回错误成功模式。
20
- * model 层纯函数返回 Result 对象(合法),dispatcher 拿到 error 时 throw。
19
+ * 错误处理:包内单一 throw 协议——handler model 层纯函数(addTodos / updateTodos)
20
+ * 校验失败均直接 throw(见 docs/extensions/extension-conventions.md「Tool 设计」),
21
+ * 不返回错误成功模式。
21
22
  */
22
23
 
23
24
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
24
- import { guiSetWidget, isGuiCapable, type GuiContext } from "@xyz-agent/extension-protocol";
25
+ import { setWidgetDual, type GuiContext } from "@xyz-agent/extension-protocol";
25
26
 
26
27
  import { registerTodosCommand } from "./commands";
27
- import { registerTodoEventHandlers } from "./handlers";
28
+ import { registerTodoEventHandlers, type RefreshDisplayFn } from "./handlers";
28
29
  import { buildGui } from "./model";
29
30
  import { renderStatusText, renderWidgetLines } from "./render";
30
31
  import { createTodoSessionState, type TodoSessionState } from "./state";
@@ -38,28 +39,23 @@ import { registerTodoTool } from "./tool";
38
39
  * 类型断言根因:pi 的 ExtensionContext.ui.custom 是泛型方法(返回 Promise<T>),
39
40
  * 与 GuiContext.ui.custom 的具体返回类型静态不兼容,传参需断言收窄;
40
41
  * mode/hasUI/ui.setWidget 形状一致(ExtensionMode 与 GuiContext.mode union 完全
41
- * 相同),单层直接断言可过 tsc(guiSetWidget/isGuiCapable 不读 custom),
42
- * goal adapters/ports.ts setGuiWidget 同款。
42
+ * 相同),单层直接断言可过 tsc(setWidgetDual 只读 mode 与 ui.setWidget,
43
+ * 不读 custom)。
43
44
  *
44
- * isGuiCapable 外层判定不可省略:guiSetWidget 内部无 isGui 守卫
45
- * extension-protocol helpers.ts 仅查 ctx.ui?.setWidget 存在性),
46
- * TUI 模式误调会把 marker 编码行推给原生 widget 造成乱码。
45
+ * 推送/清屏 × GUI/TUI 模式分派由 protocol setWidgetDual 单点内化
46
+ * (守卫单点化说明见 extension-protocol helpers.ts,本文件不再自持 isGui 判定)。
47
47
  */
48
- export function makeRefreshDisplay(state: TodoSessionState): (ctx: ExtensionContext) => void {
48
+ export function makeRefreshDisplay(state: TodoSessionState): RefreshDisplayFn {
49
49
  return function refreshDisplay(ctx: ExtensionContext): void {
50
50
  const statusText = renderStatusText(state.todos, ctx.ui.theme);
51
51
  ctx.ui.setStatus("todo", statusText || undefined);
52
- const isGui = isGuiCapable(ctx as GuiContext);
53
52
  if (state.todos.length === 0) {
54
- if (isGui) {
55
- guiSetWidget(ctx as GuiContext, "todo", undefined);
56
- } else {
57
- ctx.ui.setWidget("todo", undefined);
58
- }
59
- } else if (isGui) {
60
- guiSetWidget(ctx as GuiContext, "todo", buildGui(state.todos));
53
+ setWidgetDual(ctx as GuiContext, "todo", undefined);
61
54
  } else {
62
- ctx.ui.setWidget("todo", renderWidgetLines(state.todos, ctx.ui.theme));
55
+ setWidgetDual(ctx as GuiContext, "todo", {
56
+ gui: buildGui(state.todos),
57
+ text: renderWidgetLines(state.todos, ctx.ui.theme),
58
+ });
63
59
  }
64
60
  };
65
61
  }
package/src/model.ts CHANGED
@@ -27,7 +27,7 @@ export interface TodoDetails {
27
27
 
28
28
  export const VALID_STATUSES = ["pending", "in_progress", "completed"] as const;
29
29
 
30
- export type ValidStatus = (typeof VALID_STATUSES)[number];
30
+ type ValidStatus = (typeof VALID_STATUSES)[number];
31
31
 
32
32
  // ── 迁移/兼容 ───────────────────────────────────────
33
33
 
@@ -70,6 +70,14 @@ export function migrateTodo(raw: unknown): Todo {
70
70
 
71
71
  // ── GUI 渲染辅助 ─────────────────────────────────────
72
72
 
73
+ /** completed 计数单一来源:buildGui / renderStatusText / renderWidgetLines / component 四个消费点共用口径 */
74
+ export function todoProgress(todos: Todo[]): { completed: number; total: number } {
75
+ return {
76
+ completed: todos.filter((t) => t.status === "completed").length,
77
+ total: todos.length,
78
+ };
79
+ }
80
+
73
81
  /**
74
82
  * 把 todos 组装为 GuiRenderResult(v1.1 meta head 架构,对齐 extension-protocol@0.3.0)。
75
83
  *
@@ -85,8 +93,7 @@ export function migrateTodo(raw: unknown): Todo {
85
93
  * completed → done(success + label 弱化)
86
94
  */
87
95
  export function buildGui(todos: Todo[]): GuiRenderResult {
88
- const total = todos.length;
89
- const completed = todos.filter((t) => t.status === "completed").length;
96
+ const { completed, total } = todoProgress(todos);
90
97
  const inProgress = todos.filter((t) => t.status === "in_progress").length;
91
98
 
92
99
  const status: WidgetMeta["status"] =
@@ -118,7 +125,7 @@ export function buildGui(todos: Todo[]): GuiRenderResult {
118
125
  /** 建议的单 session todo 数上限(软约束:超限提醒,不硬拒绝) */
119
126
  export const RECOMMENDED_MAX_TODOS = 10;
120
127
 
121
- export interface AddResult {
128
+ interface AddResult {
122
129
  newTodos: Todo[];
123
130
  newNextId: number;
124
131
  resultText: string;
@@ -186,12 +193,17 @@ export function addTodos(
186
193
 
187
194
  // ── Update 逻辑 ──────────────────────────────────────
188
195
 
189
- export interface UpdateResult {
196
+ /** updateTodos 成功返回形状;校验失败直接 throw(包内单一错误协议)。 */
197
+ interface UpdateResult {
190
198
  updatedTodos: Todo[];
191
- error?: string;
192
- resultText?: string;
199
+ resultText: string;
193
200
  }
194
201
 
202
+ /**
203
+ * 批量更新 todo。校验失败(重复 id / id 不存在 / 无 status 无 text / 非法 status)
204
+ * 直接 throw——与 addTodos / handler 同一 throw 协议,文案不带 "Error: " 前缀
205
+ * (错误形态由 pi 工具错误通道表达);throw 发生在任何突变之前,state 保持不变。
206
+ */
195
207
  export function updateTodos(
196
208
  currentTodos: Todo[],
197
209
  updates: Array<{ id: number; status?: string; text?: string }>,
@@ -205,34 +217,18 @@ export function updateTodos(
205
217
 
206
218
  const ids = updates.map((u) => u.id);
207
219
  if (new Set(ids).size !== ids.length) {
208
- return {
209
- updatedTodos: currentTodos,
210
- error: "duplicate ids in updates",
211
- resultText: "Error: duplicate ids in updates",
212
- };
220
+ throw new Error("duplicate ids in updates");
213
221
  }
214
222
  for (const u of updates) {
215
223
  const todo = currentTodos.find((t) => t.id === u.id);
216
224
  if (!todo) {
217
- return {
218
- updatedTodos: currentTodos,
219
- error: `id ${u.id} not found`,
220
- resultText: `Error: Todo #${u.id} not found`,
221
- };
225
+ throw new Error(`Todo #${u.id} not found`);
222
226
  }
223
227
  if (!u.status && !u.text) {
224
- return {
225
- updatedTodos: currentTodos,
226
- error: `update item for id ${u.id} has neither status nor text`,
227
- resultText: `Error: update item for id ${u.id} has neither status nor text`,
228
- };
228
+ throw new Error(`update item for id ${u.id} has neither status nor text`);
229
229
  }
230
230
  if (u.status && !VALID_STATUSES.includes(u.status as (typeof VALID_STATUSES)[number])) {
231
- return {
232
- updatedTodos: currentTodos,
233
- error: `invalid status: ${u.status}`,
234
- resultText: `Error: invalid status '${u.status}' for update item id ${u.id}`,
235
- };
231
+ throw new Error(`invalid status '${u.status}' for update item id ${u.id}`);
236
232
  }
237
233
  }
238
234
 
package/src/render.ts CHANGED
@@ -2,10 +2,11 @@
2
2
  * Todo 渲染函数 — 状态栏、widget(双列)、tool result 渲染。
3
3
  */
4
4
 
5
- import type { Theme } from "@earendil-works/pi-coding-agent";
5
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
6
6
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
7
 
8
8
  import {
9
+ todoProgress,
9
10
  type Todo,
10
11
  type TodoDetails,
11
12
  } from "./model";
@@ -24,25 +25,15 @@ const SINGLE_COLUMN_BUDGET = WIDGET_MAX_LINES - 1;
24
25
 
25
26
  /** 垂直分割线视觉宽度(" │ ") */
26
27
  const DIVIDER_VISUAL_WIDTH = 3;
27
- const ELLIPSIS_MIN_WIDTH = 3;
28
-
29
- /** 截断或补齐到精确视觉宽度,截断时追加 "..." */
30
- function fixedWidth(text: string, width: number): string {
31
- const len = visibleWidth(text);
32
- if (len <= width) {
33
- return text + " ".repeat(width - len);
34
- }
35
- if (width <= ELLIPSIS_MIN_WIDTH) return "...".slice(0, width);
36
- return truncateToWidth(text, width - ELLIPSIS_MIN_WIDTH) + "...";
37
- }
28
+ /** 省略号视觉宽度("...") */
29
+ const ELLIPSIS_WIDTH = 3;
38
30
 
39
31
  // ── 状态栏 ────────────────────────────────────────────
40
32
 
41
33
  export function renderStatusText(todoList: Todo[], th: Theme): string {
42
34
  if (todoList.length === 0) return "";
43
35
 
44
- const completed = todoList.filter((t) => t.status === "completed").length;
45
- const total = todoList.length;
36
+ const { completed, total } = todoProgress(todoList);
46
37
 
47
38
  if (completed === total) {
48
39
  return th.fg("success", `\u2713 ${completed}/${total}`);
@@ -52,8 +43,9 @@ export function renderStatusText(todoList: Todo[], th: Theme): string {
52
43
 
53
44
  // ── Widget 双列渲染 ──────────────────────────────────
54
45
 
55
- /** 渲染单条 todo 的 widget 行(不含缩进),供 component.ts 复用 */
56
- function renderWidgetItem(t: Todo, th: Theme): string {
46
+ /** 渲染单条 todo 的 widget 行(不含缩进),供 component.ts 复用。
47
+ * textColor = 非完成态文本颜色(widget "text",tool result 列表用 "muted"——历史差异显式保留,未做视觉统一)。 */
48
+ function renderWidgetItem(t: Todo, th: Theme, textColor: ThemeColor = "text"): string {
57
49
  const mark =
58
50
  t.status === "completed"
59
51
  ? th.fg("success", "\u2713")
@@ -61,7 +53,7 @@ function renderWidgetItem(t: Todo, th: Theme): string {
61
53
  ? th.fg("warning", "\u25cf")
62
54
  : th.fg("dim", "\u25cb"); // pending
63
55
  const id = th.fg("accent", `#${t.id}`);
64
- const text = t.status === "completed" ? th.fg("dim", t.text) : th.fg("text", t.text);
56
+ const text = t.status === "completed" ? th.fg("dim", t.text) : th.fg(textColor, t.text);
65
57
  return `${mark} ${id} ${text}`;
66
58
  }
67
59
 
@@ -91,11 +83,22 @@ export function renderDualColumn(
91
83
  const lines: string[] = [];
92
84
  const half = Math.ceil(todos.length / COLUMN_COUNT);
93
85
  const divider = " " + th.fg("borderMuted", "\u2502") + " ";
86
+ // 补齐/截断到列宽:可见输出与 D14 替换前的列宽逻辑逐字符一致(裁决零行为变化)——
87
+ // 截断列保持「前缀+6点」旧形态,不可简化为单调用 truncateToWidth(text, colWidth, "...", true)
88
+ // (会改为前缀+3点);行为由 __tests__/render.test.ts 锁定。colWidth <= ELLIPSIS_WIDTH 走 pi-tui
89
+ // clipped-ellipsis 路径,可见一致(ANSI reset 包裹差异不影响渲染)。负列宽(病态窄终端)pi-tui 对
90
+ // maxWidth<=0 恒空串,无法复现旧 slice 负索引语义,保留原特判。
91
+ const fit = (text: string): string =>
92
+ colWidth < 0
93
+ ? "...".slice(0, colWidth)
94
+ : visibleWidth(text) <= colWidth || colWidth <= ELLIPSIS_WIDTH
95
+ ? truncateToWidth(text, colWidth, "...", true)
96
+ : truncateToWidth(text, colWidth - ELLIPSIS_WIDTH) + "...";
94
97
  for (let row = 0; row < half; row++) {
95
- const left = fixedWidth(indent + renderWidgetItem(todos[row], th), colWidth);
98
+ const left = fit(indent + renderWidgetItem(todos[row], th));
96
99
  const rightIdx = row + half;
97
100
  const right = rightIdx < todos.length
98
- ? fixedWidth(renderWidgetItem(todos[rightIdx], th), colWidth)
101
+ ? fit(renderWidgetItem(todos[rightIdx], th))
99
102
  : " ".repeat(colWidth);
100
103
  lines.push(left + divider + right);
101
104
  }
@@ -112,8 +115,7 @@ export function renderWidgetLines(
112
115
 
113
116
  const width = termWidth ?? (process.stdout.columns || FALLBACK_TERM_WIDTH);
114
117
  const lines: string[] = [];
115
- const completed = todoList.filter((t) => t.status === "completed").length;
116
- const total = todoList.length;
118
+ const { completed, total } = todoProgress(todoList);
117
119
 
118
120
  lines.push(th.fg("accent", "\u2611") + th.fg("muted", ` ${completed}/${total}`));
119
121
 
@@ -141,15 +143,7 @@ function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, the
141
143
  let listText = theme.fg("muted", `${todoList.length} todos:`);
142
144
  const display = options.expanded ? todoList : todoList.slice(0, MAX_COLLAPSED_ITEMS);
143
145
  for (const t of display) {
144
- const status = t.status;
145
- const mark =
146
- status === "completed"
147
- ? theme.fg("success", "\u2713")
148
- : status === "in_progress"
149
- ? theme.fg("warning", "\u25cf")
150
- : theme.fg("dim", "\u25cb"); // pending
151
- const itemText = status === "completed" ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
152
- listText += `\n${mark} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
146
+ listText += `\n${renderWidgetItem(t, theme, "muted")}`;
153
147
  }
154
148
  if (!options.expanded && todoList.length > MAX_COLLAPSED_ITEMS) {
155
149
  listText += `\n${theme.fg("dim", `... ${todoList.length - MAX_COLLAPSED_ITEMS} more`)}`;
@@ -160,12 +154,7 @@ function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, the
160
154
  // ── Tool renderResult handler ────────────────────────
161
155
 
162
156
  import { Text } from "@earendil-works/pi-tui";
163
-
164
- /** content[0] 提取 text(缺失/非 text 类型 → 空串) */
165
- function firstContentText(r: { content: Array<{ type: string; text?: string }> }): string {
166
- const text = r.content[0];
167
- return text?.type === "text" ? (text.text ?? "") : "";
168
- }
157
+ import { firstContentText } from "@xyz-agent/extension-protocol";
169
158
 
170
159
  export function renderTodoResult(result: unknown, options: { expanded: boolean }, theme: Theme): Text {
171
160
  const r = result as { content: Array<{ type: string; text?: string }>; details?: unknown };
package/src/tool.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  VALID_STATUSES,
24
24
  } from "./model";
25
25
  import { renderTodoResult } from "./render";
26
+ import type { RefreshDisplayFn } from "./handlers";
26
27
  import type { TodoSessionState } from "./state";
27
28
 
28
29
  // ── TodoParams schema(扁平 Type.Object,OpenAI 兼容)──────────
@@ -64,10 +65,10 @@ export const TodoParams = Type.Object(
64
65
  export type TodoParamsT = Static<typeof TodoParams>;
65
66
 
66
67
  // ── 4 个 action handler ──────────────────────────────
67
- // 错误处理约定(见 CLAUDE.md「Tool 设计」):handler 失败直接 throw,
68
- // 不返回「错误成功模式」。model 层纯函数(updateTodos)返回 Result 对象(合法),
69
- // dispatcher 在拿到 error 时 throw,把友好文案交给 Pi 框架展示。
70
- // addTodos 的校验失败直接 throw(C1:不再静默 filter)。
68
+ // 错误处理约定(见 docs/extensions/extension-conventions.md「Tool 设计」):
69
+ // 包内单一 throw 协议——handler 与 model 层纯函数(addTodos / updateTodos)
70
+ // 校验失败均直接 throw,把文案交给 Pi 框架以工具错误展示,不返回「错误成功模式」,
71
+ // handler 也不做 error→throw 翻译。
71
72
 
72
73
  /** list action — 返回完整格式化列表 */
73
74
  function handleList(state: TodoSessionState): string {
@@ -107,9 +108,8 @@ export function handleAdd(state: TodoSessionState, params: TodoParamsT): string
107
108
  /** update action: batch — 失败抛错 */
108
109
  function handleBatchUpdate(state: TodoSessionState, params: TodoParamsT): string {
109
110
  const r = updateTodos(state.todos, params.updates ?? []);
110
- if (r.error) throw new Error(r.resultText);
111
111
  state.todos = r.updatedTodos;
112
- return r.resultText!;
112
+ return r.resultText;
113
113
  }
114
114
 
115
115
  /** update action: single — 失败抛错 */
@@ -186,7 +186,7 @@ function executeTodoAction(
186
186
  params: TodoParamsT,
187
187
  state: TodoSessionState,
188
188
  ctx: ExtensionContext,
189
- refreshDisplay: (ctx: ExtensionContext) => void,
189
+ refreshDisplay: RefreshDisplayFn,
190
190
  ): {
191
191
  content: Array<{ type: "text"; text: string }>;
192
192
  details: TodoDetails;
@@ -213,11 +213,10 @@ function executeTodoAction(
213
213
 
214
214
  refreshDisplay(ctx);
215
215
 
216
- // content 组装(T3):突变附带完整列表;list 已含列表
216
+ // content 组装(T3):突变附带完整列表(复用 handleList 的空列表兜底);list 已含列表
217
217
  let contentText: string;
218
218
  if (isMutation) {
219
- const listText = state.todos.length > 0 ? formatTodoList(state.todos) : "No todos";
220
- contentText = `${resultText}\n${listText}`;
219
+ contentText = `${resultText}\n${handleList(state)}`;
221
220
  } else {
222
221
  contentText = resultText;
223
222
  }
@@ -240,7 +239,7 @@ function executeTodoAction(
240
239
  export function registerTodoTool(
241
240
  pi: ExtensionAPI,
242
241
  state: TodoSessionState,
243
- refreshDisplay: (ctx: ExtensionContext) => void,
242
+ refreshDisplay: RefreshDisplayFn,
244
243
  ): void {
245
244
  pi.registerTool({
246
245
  name: "todo",