@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 +1 -1
- package/src/__tests__/gui.test.ts +46 -12
- package/src/__tests__/tool-rpc.test.ts +102 -139
- package/src/index.ts +37 -12
- package/src/model.ts +40 -24
- package/src/tool.ts +2 -6
package/package.json
CHANGED
|
@@ -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("
|
|
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 →
|
|
18
|
-
expect(items[0]).
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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("
|
|
27
|
-
const
|
|
28
|
-
|
|
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 级测试 ——
|
|
3
|
-
*
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
80
|
-
|
|
81
|
-
mode:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
105
|
+
describe("executeTodoAction — tool result 无 __gui__(全模式)", () => {
|
|
106
|
+
const MODES: TestMode[] = ["rpc", "tui", "json", "print"];
|
|
102
107
|
|
|
103
|
-
|
|
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"
|
|
112
|
+
{ action: "add", texts: ["task A"] },
|
|
109
113
|
undefined,
|
|
110
114
|
undefined,
|
|
111
|
-
|
|
115
|
+
makeCtx(mode, mode === "tui").ctx,
|
|
112
116
|
);
|
|
113
|
-
expect(result.details
|
|
114
|
-
|
|
115
|
-
expect(result.details.
|
|
116
|
-
|
|
117
|
-
|
|
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("
|
|
124
|
+
it.each(MODES)("%s + list → details 无 __gui__ 字段,文本内容可读", async (mode) => {
|
|
127
125
|
const { tool, state } = setup();
|
|
128
|
-
|
|
129
|
-
state.
|
|
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
|
-
|
|
133
|
+
makeCtx(mode, mode === "tui").ctx,
|
|
141
134
|
);
|
|
142
|
-
expect(result.details
|
|
143
|
-
expect(result.
|
|
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("
|
|
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
|
|
179
|
-
|
|
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
|
-
// ──
|
|
155
|
+
// ── refreshDisplay:GUI widget 推送(M17,真实实现)────
|
|
188
156
|
|
|
189
|
-
describe("
|
|
190
|
-
it("
|
|
157
|
+
describe("refreshDisplay — GUI 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
|
|
193
|
-
|
|
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: "
|
|
163
|
+
{ action: "add", texts: ["task A", "task B"] },
|
|
212
164
|
undefined,
|
|
213
165
|
undefined,
|
|
214
|
-
|
|
166
|
+
ctx,
|
|
215
167
|
);
|
|
216
|
-
expect(
|
|
217
|
-
|
|
218
|
-
expect(
|
|
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("
|
|
190
|
+
it("G-2: rpc + delete 清空列表 → setWidget 收到 ('todo', undefined)(清除语义)", async () => {
|
|
222
191
|
const { tool } = setup();
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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("
|
|
198
|
+
it("G-3: tui + add → setWidget 收到纯文本行数组(无 marker 前缀)", async () => {
|
|
234
199
|
const { tool } = setup();
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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:
|
|
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 +
|
|
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
|
-
|
|
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 {
|
|
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
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
226
|
-
// TUI
|
|
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,
|