@zhushanwen/pi-todo 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-todo",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
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",
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import { buildGui, type Todo } from "../model";
4
4
 
5
- describe("buildGui", () => {
6
- it("maps 3 statuses to list-tree with correct icons", () => {
5
+ describe("buildGui(v1.1 meta head 架构)", () => {
6
+ it("内容根 = numbered list-tree:行首序号由 ListTree 渲染,label 纯文本(无 #N 前缀)", () => {
7
7
  const todos: Todo[] = [
8
8
  { id: 1, text: "pending task", status: "pending" },
9
9
  { id: 2, text: "active task", status: "in_progress" },
@@ -12,20 +12,54 @@ describe("buildGui", () => {
12
12
  const gui = buildGui(todos);
13
13
  expect(gui.v).toBe(1);
14
14
  expect(gui.component.type).toBe("list-tree");
15
+ expect(gui.component.props.numbered).toBe(true);
15
16
  const items = gui.component.props.items;
16
17
  expect(items).toHaveLength(3);
17
- // pending → dot, no status(guiResult 的 stripUndefined 删除 undefined 键)
18
- expect(items[0]).toMatchObject({ icon: "dot", label: "#1: pending task", depth: 0 });
19
- expect(items[0]).not.toHaveProperty("status");
20
- // in_progress circle, running
21
- expect(items[1]).toMatchObject({ icon: "circle", label: "#2: active task", status: "running", depth: 0 });
22
- // completed check, done
23
- expect(items[2]).toMatchObject({ icon: "check", label: "#3: done task", status: "done", depth: 0 });
18
+ // pending → status(guiResult 的 stripUndefined 删除 undefined 键),无 icon(状态由圆点单一表达)
19
+ expect(items[0]).toEqual({ label: "pending task", depth: 0 });
20
+ // in_progress → running
21
+ expect(items[1]).toEqual({ label: "active task", status: "running", depth: 0 });
22
+ // completed done
23
+ expect(items[2]).toEqual({ label: "done task", status: "done", depth: 0 });
24
24
  });
25
25
 
26
- it("empty todos empty list-tree", () => {
27
- const gui = buildGui([]);
28
- expect(gui.component.props.items).toEqual([]);
26
+ it("meta:title=Todo,progress=current/total 计数(head 渲染,body 不再有 progress-bar)", () => {
27
+ const todos: Todo[] = [
28
+ { id: 1, text: "a", status: "completed" },
29
+ { id: 2, text: "b", status: "in_progress" },
30
+ { id: 3, text: "c", status: "pending" },
31
+ ];
32
+ const gui = buildGui(todos);
33
+ expect(gui.meta).toEqual({
34
+ title: "Todo",
35
+ status: "running",
36
+ progress: { current: 1, total: 3 },
37
+ });
38
+ });
39
+
40
+ it("全部完成 → meta.status=done", () => {
41
+ const todos: Todo[] = [
42
+ { id: 1, text: "a", status: "completed" },
43
+ { id: 2, text: "b", status: "completed" },
44
+ ];
45
+ expect(buildGui(todos).meta).toEqual({
46
+ title: "Todo",
47
+ status: "done",
48
+ progress: { current: 2, total: 2 },
49
+ });
29
50
  });
30
51
 
52
+ it("有 pending 无 in_progress → status=idle;empty todos → 无 progress", () => {
53
+ const pendingOnly: Todo[] = [{ id: 1, text: "a", status: "pending" }];
54
+ expect(buildGui(pendingOnly).meta).toEqual({
55
+ title: "Todo",
56
+ status: "idle",
57
+ progress: { current: 0, total: 1 },
58
+ });
59
+ expect(buildGui([]).meta).toEqual({ title: "Todo", status: "idle" });
60
+ // 空 list:numbered 仍开(items 空,无行渲染)
61
+ const emptyGui = buildGui([]);
62
+ expect(emptyGui.component.type).toBe("list-tree");
63
+ expect(emptyGui.component.props.items).toEqual([]);
64
+ });
31
65
  });
@@ -1,31 +1,33 @@
1
1
  /**
2
- * executeTodoAction handler 级测试 —— 覆盖 `if (ctx.mode === "rpc")` 分支
3
- * 和非 RPC 模式不附加 __gui__ 的路径。对齐 ask-user R-1~R-7 handler 级范式。
2
+ * executeTodoAction handler 级测试 —— M17 后的两条路径:
3
+ * 1. tool result __gui__(全模式统一——状态展示不再进 details)
4
+ * 2. refreshDisplay GUI widget 推送(rpc 推 marker 编码 / tui 推纯文本行)
4
5
  *
5
6
  * 策略:executeTodoAction 未导出,通过 registerTodoTool + mock pi 捕获
6
- * 已注册 tool,再以不同 ctx.mode 调 execute。每个用例新建 state(隔离),
7
- * 无模块级状态需重置。
7
+ * 已注册 tool,再以不同 ctx.mode 调 execute。setup 第三参传真实
8
+ * makeRefreshDisplay(state)(与 index.ts 工厂共用同一实现,不测复制品)。
9
+ * 每个用例新建 state(隔离),无模块级状态需重置。
8
10
  */
9
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
12
  import type { Theme } from "@earendil-works/pi-coding-agent";
11
- import { describe, expect, it } from "vitest";
13
+ import { GUI_WIDGET_MARKER } from "@xyz-agent/extension-protocol";
14
+ import { describe, expect, it, vi, type Mock } from "vitest";
12
15
 
16
+ import { makeRefreshDisplay } from "../index";
13
17
  import { createTodoSessionState, type TodoSessionState } from "../state";
14
18
  import { registerTodoTool } from "../tool";
15
19
 
16
20
  // ── Types for the registered tool ───────────────────────
17
21
  type TestMode = "tui" | "rpc" | "json" | "print";
18
22
 
23
+ type SetWidgetFn = (name: string, content: string[] | undefined) => void;
24
+
19
25
  interface ExecuteResult {
20
26
  content: Array<{ type: "text"; text: string }>;
21
27
  details: {
22
28
  action: string;
23
29
  todos: Array<{ id: number; text: string; status: string }>;
24
30
  nextId: number;
25
- __gui__?: {
26
- v: number;
27
- component: { type: string; props: { items: unknown[] } };
28
- };
29
31
  };
30
32
  }
31
33
 
@@ -47,7 +49,7 @@ interface MockPi {
47
49
  registerTool(tool: RegisteredTool): void;
48
50
  }
49
51
 
50
- /** 捕获注册的 tool,返回 + 暴露 state 供断言。 */
52
+ /** 捕获注册的 tool,返回 + 暴露 state 供断言。refreshDisplay 传真实实现(makeRefreshDisplay)。 */
51
53
  function setup(): { tool: RegisteredTool; state: TodoSessionState } {
52
54
  const state = createTodoSessionState();
53
55
  const pi: MockPi = {
@@ -55,7 +57,7 @@ function setup(): { tool: RegisteredTool; state: TodoSessionState } {
55
57
  this.tool = tool;
56
58
  },
57
59
  };
58
- registerTodoTool(pi as unknown as ExtensionAPI, state, () => {});
60
+ registerTodoTool(pi as unknown as ExtensionAPI, state, makeRefreshDisplay(state));
59
61
  if (!pi.tool) throw new Error("registerTodoTool did not register a tool");
60
62
  return { tool: pi.tool, state };
61
63
  }
@@ -76,95 +78,65 @@ const stubTheme = {
76
78
  getBashModeBorderColor: () => (text: string) => text,
77
79
  } as unknown as Theme;
78
80
 
79
- /** RPC 模式 ctx:hasUI=falserefreshDisplay 调用 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
- });
81
+ /** 构造指定 mode ctx,setWidget vi.fn 供断言(refreshDisplay 推送出口)。 */
82
+ function makeCtx(mode: TestMode, hasUI: boolean): {
83
+ ctx: { mode: TestMode; hasUI: boolean; ui: { theme: Theme; setStatus: Mock; setWidget: Mock<SetWidgetFn> } };
84
+ setWidget: Mock<SetWidgetFn>;
85
+ } {
86
+ const setWidget = vi.fn<SetWidgetFn>();
87
+ return {
88
+ ctx: {
89
+ mode,
90
+ hasUI,
91
+ ui: { theme: stubTheme, setStatus: vi.fn(), setWidget },
92
+ },
93
+ setWidget,
94
+ };
95
+ }
96
+
97
+ /** RPC 模式 ctx:hasUI=false。 */
98
+ const makeRpcCtx = () => makeCtx("rpc", false);
89
99
 
90
100
  /** 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
- });
101
+ const makeTuiCtx = () => makeCtx("tui", true);
102
+
103
+ // ── tool result:无 __gui__(M17 后全模式统一)─────────
100
104
 
101
- // ── RPC 模式:附加 __gui__ ────────────────────────────
105
+ describe("executeTodoAction tool result __gui__(全模式)", () => {
106
+ const MODES: TestMode[] = ["rpc", "tui", "json", "print"];
102
107
 
103
- describe("executeTodoAction RPC mode attaches __gui__", () => {
104
- it("R-1: rpc + add → details.__gui__ exists, type is list-tree", async () => {
108
+ it.each(MODES)("%s + add details __gui__ 字段,仍含 action/todos/nextId", async (mode) => {
105
109
  const { tool } = setup();
106
110
  const result = await tool.execute(
107
111
  "id",
108
- { action: "add", texts: ["task A", "task B"] },
112
+ { action: "add", texts: ["task A"] },
109
113
  undefined,
110
114
  undefined,
111
- makeRpcCtx(),
115
+ makeCtx(mode, mode === "tui").ctx,
112
116
  );
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" });
117
+ expect("__gui__" in result.details).toBe(false);
118
+ // details 仍带原生文本路径数据(todos / nextId)
119
+ expect(result.details.action).toBe("add");
120
+ expect(result.details.todos).toHaveLength(1);
121
+ expect(result.content[0].text).toContain("Added");
124
122
  });
125
123
 
126
- it("R-2: rpc + list → __gui__ reflects current todo state", async () => {
124
+ it.each(MODES)("%s + list → details __gui__ 字段,文本内容可读", async (mode) => {
127
125
  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;
126
+ state.todos = [{ id: 1, text: "x", status: "pending" }];
127
+ state.nextId = 2;
135
128
  const result = await tool.execute(
136
129
  "id",
137
130
  { action: "list" },
138
131
  undefined,
139
132
  undefined,
140
- makeRpcCtx(),
133
+ makeCtx(mode, mode === "tui").ctx,
141
134
  );
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
- });
135
+ expect("__gui__" in result.details).toBe(false);
136
+ expect(result.content[0].text).toContain("#1");
165
137
  });
166
138
 
167
- it("R-3: rpc + update → __gui__ reflects post-update status", async () => {
139
+ it("rpc + update → details __gui__ 字段,状态变更仍生效", async () => {
168
140
  const { tool, state } = setup();
169
141
  state.todos = [{ id: 1, text: "item", status: "pending" }];
170
142
  state.nextId = 2;
@@ -173,82 +145,76 @@ describe("executeTodoAction — RPC mode attaches __gui__", () => {
173
145
  { action: "update", updates: [{ id: 1, status: "in_progress" }] },
174
146
  undefined,
175
147
  undefined,
176
- makeRpcCtx(),
148
+ makeRpcCtx().ctx,
177
149
  );
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" });
150
+ expect("__gui__" in result.details).toBe(false);
151
+ expect(result.details.todos[0]!.status).toBe("in_progress");
184
152
  });
185
153
  });
186
154
 
187
- // ── TUI 模式:不附加 __gui__ ──────────────────────────
155
+ // ── refreshDisplay:GUI widget 推送(M17,真实实现)────
188
156
 
189
- describe("executeTodoActionnon-RPC modes omit __gui__", () => {
190
- it("T-1: tui + add → details.__gui__ is undefined", async () => {
157
+ describe("refreshDisplayGUI widget 推送(setup 传真实实现)", () => {
158
+ it("G-1: rpc + add → setWidget 收到 ('todo', [GUI_WIDGET_MARKER + JSON]),解析后为 v1.1 信封(list-tree + meta)", async () => {
191
159
  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(
160
+ const { ctx, setWidget } = makeRpcCtx();
161
+ await tool.execute(
210
162
  "id",
211
- { action: "list" },
163
+ { action: "add", texts: ["task A", "task B"] },
212
164
  undefined,
213
165
  undefined,
214
- makeTuiCtx(),
166
+ ctx,
215
167
  );
216
- expect(result.details.__gui__).toBeUndefined();
217
- // 文本内容仍可读
218
- expect(result.content[0].text).toContain("#1");
168
+ expect(setWidget).toHaveBeenCalledTimes(1);
169
+ const [key, value] = setWidget.mock.calls[0]!;
170
+ expect(key).toBe("todo");
171
+ expect(value).toHaveLength(1);
172
+ const encoded = value![0]!;
173
+ // marker 前缀用协议常量断言(不手写编码)
174
+ expect(encoded.startsWith(GUI_WIDGET_MARKER)).toBe(true);
175
+ const parsed = JSON.parse(encoded.slice(GUI_WIDGET_MARKER.length)) as {
176
+ v: number;
177
+ component: { type: string; props: { numbered: boolean; items: Array<{ label: string }> } };
178
+ meta: { title: string; progress: { current: number; total: number } };
179
+ };
180
+ // v1.1 wire:GuiRenderResult 信封(component + meta 宿主元数据)
181
+ expect(parsed.v).toBe(1);
182
+ expect(parsed.component.type).toBe("list-tree");
183
+ expect(parsed.component.props.numbered).toBe(true);
184
+ expect(parsed.component.props.items).toHaveLength(2);
185
+ expect(parsed.component.props.items[0]).toMatchObject({ label: "task A" });
186
+ expect(parsed.component.props.items[1]).toMatchObject({ label: "task B" });
187
+ expect(parsed.meta).toMatchObject({ title: "Todo", progress: { current: 0, total: 2 } });
219
188
  });
220
189
 
221
- it("T-3: print mode + adddetails.__gui__ is undefined", async () => {
190
+ it("G-2: rpc + delete 清空列表 setWidget 收到 ('todo', undefined)(清除语义)", async () => {
222
191
  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();
192
+ const { ctx, setWidget } = makeRpcCtx();
193
+ await tool.execute("id", { action: "add", texts: ["only"] }, undefined, undefined, ctx);
194
+ await tool.execute("id", { action: "delete", ids: [1] }, undefined, undefined, ctx);
195
+ expect(setWidget).toHaveBeenLastCalledWith("todo", undefined);
231
196
  });
232
197
 
233
- it("T-4: json mode + add → details.__gui__ is undefined", async () => {
198
+ it("G-3: tui + add → setWidget 收到纯文本行数组(无 marker 前缀)", async () => {
234
199
  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();
200
+ const { ctx, setWidget } = makeTuiCtx();
201
+ await tool.execute("id", { action: "add", texts: ["task A"] }, undefined, undefined, ctx);
202
+ expect(setWidget).toHaveBeenCalledTimes(1);
203
+ const [, value] = setWidget.mock.calls[0]!;
204
+ expect(value!.length).toBeGreaterThan(0);
205
+ for (const line of value!) {
206
+ // isGuiCapable 外层判定生效:TUI 行不含 GUI marker 编码
207
+ expect(line.startsWith(GUI_WIDGET_MARKER)).toBe(false);
208
+ }
243
209
  });
244
210
  });
245
211
 
246
- // ── 共享 state:rpc 附加但 details.todos 是快照副本 ────
212
+ // ── 共享 state:details.todos 是快照副本 ───────────────
247
213
 
248
214
  describe("executeTodoAction — state isolation & snapshot", () => {
249
215
  it("S-1: each setup() yields independent state (no module-level leak)", async () => {
250
216
  const { tool: tool1 } = setup();
251
- await tool1.execute("id", { action: "add", texts: ["first"] }, undefined, undefined, makeRpcCtx());
217
+ await tool1.execute("id", { action: "add", texts: ["first"] }, undefined, undefined, makeRpcCtx().ctx);
252
218
  // 第二个 setup 起步,不应看到第一个的 todos
253
219
  const { tool: tool2, state: state2 } = setup();
254
220
  expect(state2.todos).toHaveLength(0);
@@ -257,29 +223,26 @@ describe("executeTodoAction — state isolation & snapshot", () => {
257
223
  { action: "list" },
258
224
  undefined,
259
225
  undefined,
260
- makeRpcCtx(),
226
+ makeRpcCtx().ctx,
261
227
  );
262
228
  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
229
  });
267
230
 
268
231
  it("S-2: details.todos is a shallow array copy (splice-safe, element-shared)", async () => {
269
232
  // executeTodoAction 用 [...state.todos] 做浅拷贝:数组独立、元素共享。
270
233
  // add/delete 改数组长度时旧 details.todos 不受影响;但原地改元素会共享。
271
234
  const { tool } = setup();
272
- await tool.execute("id", { action: "add", texts: ["a", "b"] }, undefined, undefined, makeRpcCtx());
235
+ await tool.execute("id", { action: "add", texts: ["a", "b"] }, undefined, undefined, makeRpcCtx().ctx);
273
236
  const before = (await tool.execute(
274
237
  "id",
275
238
  { action: "list" },
276
239
  undefined,
277
240
  undefined,
278
- makeRpcCtx(),
241
+ makeRpcCtx().ctx,
279
242
  )).details.todos;
280
243
  expect(before).toHaveLength(2);
281
244
  // delete 改 state.todos 数组,已发出的 before 快照仍为 2 项
282
- await tool.execute("id", { action: "delete", ids: [1, 2] }, undefined, undefined, makeRpcCtx());
245
+ await tool.execute("id", { action: "delete", ids: [1, 2] }, undefined, undefined, makeRpcCtx().ctx);
283
246
  expect(before).toHaveLength(2);
284
247
  });
285
248
  });
package/src/index.ts CHANGED
@@ -14,20 +14,54 @@
14
14
  * - render.ts: 状态栏(status line)/ widget(单双列自适应)/ tool result 三层渲染
15
15
  * - component.ts: /todos 命令的 TodoListComponent TUI 视图(只读双列)
16
16
  * - commands.ts: /todos 命令注册
17
- * - index.ts(本文件): 工厂入口(创建 state + 注册 tool/command/event + refreshDisplay
17
+ * - index.ts(本文件): 工厂入口(创建 state + 注册 tool/command/event + makeRefreshDisplay
18
18
  *
19
19
  * 错误处理:handler 失败直接 throw(见 CLAUDE.md「Tool 设计」),不返回错误成功模式。
20
20
  * model 层纯函数返回 Result 对象(合法),dispatcher 拿到 error 时 throw。
21
21
  */
22
22
 
23
23
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
24
+ import { guiSetWidget, isGuiCapable, type GuiContext } from "@xyz-agent/extension-protocol";
24
25
 
25
26
  import { registerTodosCommand } from "./commands";
26
27
  import { registerTodoEventHandlers } from "./handlers";
28
+ import { buildGui } from "./model";
27
29
  import { renderStatusText, renderWidgetLines } from "./render";
28
- import { createTodoSessionState } from "./state";
30
+ import { createTodoSessionState, type TodoSessionState } from "./state";
29
31
  import { registerTodoTool } from "./tool";
30
32
 
33
+ // ── 刷新显示(导出供测试,生产路径与测试共用同一实现)──────
34
+
35
+ /**
36
+ * 构造依赖 TodoSessionState 的 refreshDisplay(M17 widget 面板推送)。
37
+ *
38
+ * 类型断言根因:pi 的 ExtensionContext.ui.custom 是泛型方法,参数逆变使其
39
+ * 与 GuiContext 不兼容,直接传参需断言收窄;双步 unknown 中转沿用 goal
40
+ * adapters/ports.ts setGuiWidget 同款先例。
41
+ *
42
+ * isGuiCapable 外层判定不可省略:guiSetWidget 内部无 isGui 守卫
43
+ * (extension-protocol helpers.ts 仅查 ctx.ui?.setWidget 存在性),
44
+ * TUI 模式误调会把 marker 编码行推给原生 widget 造成乱码。
45
+ */
46
+ export function makeRefreshDisplay(state: TodoSessionState): (ctx: ExtensionContext) => void {
47
+ return function refreshDisplay(ctx: ExtensionContext): void {
48
+ const statusText = renderStatusText(state.todos, ctx.ui.theme);
49
+ ctx.ui.setStatus("todo", statusText || undefined);
50
+ const isGui = isGuiCapable(ctx as unknown as GuiContext);
51
+ if (state.todos.length === 0) {
52
+ if (isGui) {
53
+ guiSetWidget(ctx as unknown as GuiContext, "todo", undefined);
54
+ } else {
55
+ ctx.ui.setWidget("todo", undefined);
56
+ }
57
+ } else if (isGui) {
58
+ guiSetWidget(ctx as unknown as GuiContext, "todo", buildGui(state.todos));
59
+ } else {
60
+ ctx.ui.setWidget("todo", renderWidgetLines(state.todos, ctx.ui.theme));
61
+ }
62
+ };
63
+ }
64
+
31
65
  // ── 扩展入口 ─────────────────────────────────────────
32
66
 
33
67
  export default function (pi: ExtensionAPI) {
@@ -37,16 +71,7 @@ export default function (pi: ExtensionAPI) {
37
71
  // 全解耦:不再暴露 pi.__todoGetList 跨扩展 API(goal 不再读 todo 状态)。
38
72
  // todo 进度由 AI 自行管理,goal 不做强制检查。
39
73
 
40
- // ── 刷新显示(依赖闭包 state) ─────────────────────
41
- function refreshDisplay(ctx: ExtensionContext): void {
42
- const statusText = renderStatusText(state.todos, ctx.ui.theme);
43
- ctx.ui.setStatus("todo", statusText || undefined);
44
- if (state.todos.length === 0) {
45
- ctx.ui.setWidget("todo", undefined);
46
- } else {
47
- ctx.ui.setWidget("todo", renderWidgetLines(state.todos, ctx.ui.theme));
48
- }
49
- }
74
+ const refreshDisplay = makeRefreshDisplay(state);
50
75
 
51
76
  // ── 注册所有 handler / tool / command ──────────────
52
77
  registerTodoEventHandlers(pi, state, refreshDisplay);
package/src/model.ts CHANGED
@@ -3,7 +3,13 @@
3
3
  * 三态: pending → in_progress → completed
4
4
  */
5
5
 
6
- import { guiComponent, type GuiRenderResult, guiResult, type TreeItem } from "@xyz-agent/extension-protocol";
6
+ import {
7
+ type GuiRenderResult,
8
+ guiComponent,
9
+ guiResult,
10
+ type TreeItem,
11
+ type WidgetMeta,
12
+ } from "@xyz-agent/extension-protocol";
7
13
 
8
14
  // ── 数据模型 ─────────────────────────────────────────
9
15
 
@@ -17,8 +23,6 @@ export interface TodoDetails {
17
23
  action: "list" | "add" | "update" | "delete";
18
24
  todos: Todo[];
19
25
  nextId: number;
20
- /** GUI 渲染结果(仅 RPC 模式填充,前端 list-tree 渲染)。对齐 extension-protocol@0.2.0。 */
21
- __gui__?: GuiRenderResult;
22
26
  }
23
27
 
24
28
  export const VALID_STATUSES = ["pending", "in_progress", "completed"] as const;
@@ -67,34 +71,46 @@ export function migrateTodo(raw: unknown): Todo {
67
71
  // ── GUI 渲染辅助 ─────────────────────────────────────
68
72
 
69
73
  /**
70
- * 把 todos 映射为 list-tree GuiRenderResult(对齐 extension-protocol@0.2.0)。
71
- * status → icon/status 映射:
72
- * pending → dot / status
73
- * in_progress → circle / running
74
- * completed → check / done
74
+ * 把 todos 组装为 GuiRenderResult(v1.1 meta head 架构,对齐 extension-protocol@0.3.0)。
75
+ *
76
+ * - meta(标题/状态/进度)由宿主壳层渲染成唯一 head:进度计数 "N/M" + mini bar
77
+ * 替代 body 内 progress-bar(精简 body),全完成 status=done(head 绿点 + bar 变绿)。
78
+ * - 内容根 = numbered list-tree:行首弱化序号(编辑器行号范式,ListTree 渲染),
79
+ * id 不再烧进 label——update/delete 锚点由模型经 list action 获取,用户引用
80
+ * 「第 N 项」即可;状态由行尾圆点单一表达(无 icon,v6 单一信息源裁决)。
81
+ *
82
+ * status → 圆点映射:
83
+ * pending → 无圆点(常态归零)
84
+ * in_progress → running(accent)
85
+ * completed → done(success + label 弱化)
75
86
  */
76
87
  export function buildGui(todos: Todo[]): GuiRenderResult {
77
- const items: TreeItem[] = todos.map((t) => {
78
- const icon =
79
- t.status === "completed"
80
- ? "check"
81
- : t.status === "in_progress"
82
- ? "circle"
83
- : "dot"; // pending
84
- const status =
88
+ const total = todos.length;
89
+ const completed = todos.filter((t) => t.status === "completed").length;
90
+ const inProgress = todos.filter((t) => t.status === "in_progress").length;
91
+
92
+ const status: WidgetMeta["status"] =
93
+ total > 0 && completed === total ? "done" : inProgress > 0 ? "running" : "idle";
94
+
95
+ const items: TreeItem[] = todos.map((t) => ({
96
+ label: t.text,
97
+ status:
85
98
  t.status === "in_progress"
86
99
  ? "running"
87
100
  : t.status === "completed"
88
101
  ? "done"
89
- : undefined; // pending 无 status
90
- return {
91
- icon,
92
- label: `#${t.id}: ${t.text}`,
102
+ : undefined, // pending 无 status
103
+ depth: 0,
104
+ }));
105
+
106
+ return guiResult(
107
+ guiComponent("list-tree", { numbered: true, items }),
108
+ {
109
+ title: "Todo",
93
110
  status,
94
- depth: 0,
95
- };
96
- });
97
- return guiResult(guiComponent("list-tree", { items }));
111
+ progress: total > 0 ? { current: completed, total } : undefined,
112
+ },
113
+ );
98
114
  }
99
115
 
100
116
  export function getDisplayStatus(t: Todo): string {
package/src/tool.ts CHANGED
@@ -16,7 +16,6 @@ import { type Static, Type } from "typebox";
16
16
 
17
17
  import {
18
18
  addTodos,
19
- buildGui,
20
19
  formatTodoList,
21
20
  type Todo,
22
21
  type TodoDetails,
@@ -222,11 +221,8 @@ function executeTodoAction(
222
221
  todos: [...state.todos],
223
222
  nextId: state.nextId,
224
223
  };
225
- // RPC 模式(xyz-agent GUI)附加 __gui__,前端按 list-tree 渲染。
226
- // TUI/print/json 模式走原生文本渲染(contentText 已在 content 中)。
227
- if (ctx.mode === "rpc") {
228
- details.__gui__ = buildGui(state.todos);
229
- }
224
+ // 状态展示不再进 tool result(GUI 渲染字段已移除):GUI refreshDisplay 的
225
+ // guiSetWidget 推送(M17 widget 面板),TUI 走原生文本渲染(contentText 已在 content 中)。
230
226
  return {
231
227
  content: [{ type: "text" as const, text: contentText }],
232
228
  details,