@zhushanwen/pi-todo 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -24
- package/package.json +4 -1
- package/src/__tests__/gui.test.ts +39 -0
- package/src/__tests__/steer.test.ts +254 -0
- package/src/__tests__/todo.test.ts +4 -26
- package/src/__tests__/tool-rpc.test.ts +285 -0
- package/src/component.ts +1 -1
- package/src/handlers.ts +7 -12
- package/src/index.ts +17 -18
- package/src/model.ts +39 -21
- package/src/render.ts +2 -6
- package/src/tool.ts +67 -197
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* executeTodoAction handler 级测试 —— 覆盖 `if (ctx.mode === "rpc")` 分支
|
|
3
|
+
* 和非 RPC 模式不附加 __gui__ 的路径。对齐 ask-user R-1~R-7 handler 级范式。
|
|
4
|
+
*
|
|
5
|
+
* 策略:executeTodoAction 未导出,通过 registerTodoTool + mock pi 捕获
|
|
6
|
+
* 已注册 tool,再以不同 ctx.mode 调 execute。每个用例新建 state(隔离),
|
|
7
|
+
* 无模块级状态需重置。
|
|
8
|
+
*/
|
|
9
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
10
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
|
|
13
|
+
import { createTodoSessionState, type TodoSessionState } from "../state";
|
|
14
|
+
import { registerTodoTool } from "../tool";
|
|
15
|
+
|
|
16
|
+
// ── Types for the registered tool ───────────────────────
|
|
17
|
+
type TestMode = "tui" | "rpc" | "json" | "print";
|
|
18
|
+
|
|
19
|
+
interface ExecuteResult {
|
|
20
|
+
content: Array<{ type: "text"; text: string }>;
|
|
21
|
+
details: {
|
|
22
|
+
action: string;
|
|
23
|
+
todos: Array<{ id: number; text: string; status: string; isVerification?: boolean }>;
|
|
24
|
+
nextId: number;
|
|
25
|
+
__gui__?: {
|
|
26
|
+
v: number;
|
|
27
|
+
component: { type: string; props: { items: unknown[] } };
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface RegisteredTool {
|
|
33
|
+
name: string;
|
|
34
|
+
execute: (
|
|
35
|
+
toolCallId: string,
|
|
36
|
+
params: Record<string, unknown>,
|
|
37
|
+
signal: AbortSignal | undefined,
|
|
38
|
+
onUpdate: unknown,
|
|
39
|
+
ctx: { mode: TestMode },
|
|
40
|
+
) => Promise<ExecuteResult>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// pi-coding-agent 的 ExtensionContext 类型声明里没有 mode 字段(运行时实际有),
|
|
44
|
+
// 与 extension-protocol 的 GuiContext 结构化兼容。这里做最小 mock 并断言。
|
|
45
|
+
interface MockPi {
|
|
46
|
+
tool?: RegisteredTool;
|
|
47
|
+
registerTool(tool: RegisteredTool): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 捕获注册的 tool,返回 + 暴露 state 供断言。 */
|
|
51
|
+
function setup(): { tool: RegisteredTool; state: TodoSessionState } {
|
|
52
|
+
const state = createTodoSessionState();
|
|
53
|
+
const pi: MockPi = {
|
|
54
|
+
registerTool(tool) {
|
|
55
|
+
this.tool = tool;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
registerTodoTool(pi as unknown as ExtensionAPI, state, () => {});
|
|
59
|
+
if (!pi.tool) throw new Error("registerTodoTool did not register a tool");
|
|
60
|
+
return { tool: pi.tool, state };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Theme passthrough(与 todo.test.ts mockTheme 一致) ──
|
|
64
|
+
const stubTheme = {
|
|
65
|
+
fg: (_color: string, text: string) => text,
|
|
66
|
+
bg: (_color: string, text: string) => text,
|
|
67
|
+
bold: (text: string) => text,
|
|
68
|
+
italic: (text: string) => text,
|
|
69
|
+
underline: (text: string) => text,
|
|
70
|
+
inverse: (text: string) => text,
|
|
71
|
+
strikethrough: (text: string) => text,
|
|
72
|
+
getFgAnsi: (_color: string) => "",
|
|
73
|
+
getBgAnsi: (_color: string) => "",
|
|
74
|
+
getColorMode: () => "truecolor" as const,
|
|
75
|
+
getThinkingBorderColor: () => (text: string) => text,
|
|
76
|
+
getBashModeBorderColor: () => (text: string) => text,
|
|
77
|
+
} as unknown as Theme;
|
|
78
|
+
|
|
79
|
+
/** RPC 模式 ctx:hasUI=false,refreshDisplay 调用 ui.theme/setStatus/setWidget。 */
|
|
80
|
+
const makeRpcCtx = () => ({
|
|
81
|
+
mode: "rpc" as const,
|
|
82
|
+
hasUI: false,
|
|
83
|
+
ui: {
|
|
84
|
+
theme: stubTheme,
|
|
85
|
+
setStatus: () => {},
|
|
86
|
+
setWidget: () => {},
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/** TUI 模式 ctx:hasUI=true。 */
|
|
91
|
+
const makeTuiCtx = () => ({
|
|
92
|
+
mode: "tui" as const,
|
|
93
|
+
hasUI: true,
|
|
94
|
+
ui: {
|
|
95
|
+
theme: stubTheme,
|
|
96
|
+
setStatus: () => {},
|
|
97
|
+
setWidget: () => {},
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ── RPC 模式:附加 __gui__ ────────────────────────────
|
|
102
|
+
|
|
103
|
+
describe("executeTodoAction — RPC mode attaches __gui__", () => {
|
|
104
|
+
it("R-1: rpc + add → details.__gui__ exists, type is list-tree", async () => {
|
|
105
|
+
const { tool } = setup();
|
|
106
|
+
const result = await tool.execute(
|
|
107
|
+
"id",
|
|
108
|
+
{ action: "add", texts: ["task A", "task B"] },
|
|
109
|
+
undefined,
|
|
110
|
+
undefined,
|
|
111
|
+
makeRpcCtx(),
|
|
112
|
+
);
|
|
113
|
+
expect(result.details.__gui__).toBeDefined();
|
|
114
|
+
expect(result.details.__gui__!.v).toBe(1);
|
|
115
|
+
expect(result.details.__gui__!.component.type).toBe("list-tree");
|
|
116
|
+
// 两条新增 todo 反映在 items 中
|
|
117
|
+
const items = result.details.__gui__!.component.props.items as Array<{
|
|
118
|
+
label: string;
|
|
119
|
+
icon: string;
|
|
120
|
+
}>;
|
|
121
|
+
expect(items).toHaveLength(2);
|
|
122
|
+
expect(items[0]).toMatchObject({ label: "#1: task A", icon: "dot" });
|
|
123
|
+
expect(items[1]).toMatchObject({ label: "#2: task B", icon: "dot" });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("R-2: rpc + list → __gui__ reflects current todo state", async () => {
|
|
127
|
+
const { tool, state } = setup();
|
|
128
|
+
// 预置状态(绕开 add,直接构造 todos)
|
|
129
|
+
state.todos = [
|
|
130
|
+
{ id: 1, text: "pending task", status: "pending" },
|
|
131
|
+
{ id: 2, text: "active task", status: "in_progress" },
|
|
132
|
+
{ id: 3, text: "done task", status: "completed" },
|
|
133
|
+
];
|
|
134
|
+
state.nextId = 4;
|
|
135
|
+
const result = await tool.execute(
|
|
136
|
+
"id",
|
|
137
|
+
{ action: "list" },
|
|
138
|
+
undefined,
|
|
139
|
+
undefined,
|
|
140
|
+
makeRpcCtx(),
|
|
141
|
+
);
|
|
142
|
+
expect(result.details.__gui__).toBeDefined();
|
|
143
|
+
expect(result.details.__gui__!.component.type).toBe("list-tree");
|
|
144
|
+
const items = result.details.__gui__!.component.props.items as Array<{
|
|
145
|
+
label: string;
|
|
146
|
+
icon: string;
|
|
147
|
+
status?: string;
|
|
148
|
+
}>;
|
|
149
|
+
expect(items).toHaveLength(3);
|
|
150
|
+
// pending → dot 无 status
|
|
151
|
+
expect(items[0]).toMatchObject({ label: "#1: pending task", icon: "dot" });
|
|
152
|
+
expect(items[0]).not.toHaveProperty("status");
|
|
153
|
+
// in_progress → circle / running
|
|
154
|
+
expect(items[1]).toMatchObject({
|
|
155
|
+
label: "#2: active task",
|
|
156
|
+
icon: "circle",
|
|
157
|
+
status: "running",
|
|
158
|
+
});
|
|
159
|
+
// completed → check / done
|
|
160
|
+
expect(items[2]).toMatchObject({
|
|
161
|
+
label: "#3: done task",
|
|
162
|
+
icon: "check",
|
|
163
|
+
status: "done",
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("R-3: rpc + update → __gui__ reflects post-update status", async () => {
|
|
168
|
+
const { tool, state } = setup();
|
|
169
|
+
state.todos = [{ id: 1, text: "item", status: "pending" }];
|
|
170
|
+
state.nextId = 2;
|
|
171
|
+
const result = await tool.execute(
|
|
172
|
+
"id",
|
|
173
|
+
{ action: "update", updates: [{ id: 1, status: "in_progress" }] },
|
|
174
|
+
undefined,
|
|
175
|
+
undefined,
|
|
176
|
+
makeRpcCtx(),
|
|
177
|
+
);
|
|
178
|
+
expect(result.details.__gui__).toBeDefined();
|
|
179
|
+
const items = result.details.__gui__!.component.props.items as Array<{
|
|
180
|
+
icon: string;
|
|
181
|
+
status?: string;
|
|
182
|
+
}>;
|
|
183
|
+
expect(items[0]).toMatchObject({ icon: "circle", status: "running" });
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ── TUI 模式:不附加 __gui__ ──────────────────────────
|
|
188
|
+
|
|
189
|
+
describe("executeTodoAction — non-RPC modes omit __gui__", () => {
|
|
190
|
+
it("T-1: tui + add → details.__gui__ is undefined", async () => {
|
|
191
|
+
const { tool } = setup();
|
|
192
|
+
const result = await tool.execute(
|
|
193
|
+
"id",
|
|
194
|
+
{ action: "add", texts: ["task A"] },
|
|
195
|
+
undefined,
|
|
196
|
+
undefined,
|
|
197
|
+
makeTuiCtx(),
|
|
198
|
+
);
|
|
199
|
+
expect(result.details.__gui__).toBeUndefined();
|
|
200
|
+
// details 仍带原生文本路径数据(todos / nextId)
|
|
201
|
+
expect(result.details.todos).toHaveLength(1);
|
|
202
|
+
expect(result.content[0].text).toContain("Added");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("T-2: tui + list → details.__gui__ is undefined", async () => {
|
|
206
|
+
const { tool, state } = setup();
|
|
207
|
+
state.todos = [{ id: 1, text: "x", status: "pending" }];
|
|
208
|
+
state.nextId = 2;
|
|
209
|
+
const result = await tool.execute(
|
|
210
|
+
"id",
|
|
211
|
+
{ action: "list" },
|
|
212
|
+
undefined,
|
|
213
|
+
undefined,
|
|
214
|
+
makeTuiCtx(),
|
|
215
|
+
);
|
|
216
|
+
expect(result.details.__gui__).toBeUndefined();
|
|
217
|
+
// 文本内容仍可读
|
|
218
|
+
expect(result.content[0].text).toContain("#1");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("T-3: print mode + add → details.__gui__ is undefined", async () => {
|
|
222
|
+
const { tool } = setup();
|
|
223
|
+
const result = await tool.execute(
|
|
224
|
+
"id",
|
|
225
|
+
{ action: "add", texts: ["task A"] },
|
|
226
|
+
undefined,
|
|
227
|
+
undefined,
|
|
228
|
+
{ mode: "print", hasUI: false, ui: { theme: stubTheme, setStatus: () => {}, setWidget: () => {} } },
|
|
229
|
+
);
|
|
230
|
+
expect(result.details.__gui__).toBeUndefined();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("T-4: json mode + add → details.__gui__ is undefined", async () => {
|
|
234
|
+
const { tool } = setup();
|
|
235
|
+
const result = await tool.execute(
|
|
236
|
+
"id",
|
|
237
|
+
{ action: "add", texts: ["task A"] },
|
|
238
|
+
undefined,
|
|
239
|
+
undefined,
|
|
240
|
+
{ mode: "json", hasUI: false, ui: { theme: stubTheme, setStatus: () => {}, setWidget: () => {} } },
|
|
241
|
+
);
|
|
242
|
+
expect(result.details.__gui__).toBeUndefined();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ── 共享 state:rpc 附加但 details.todos 是快照副本 ────
|
|
247
|
+
|
|
248
|
+
describe("executeTodoAction — state isolation & snapshot", () => {
|
|
249
|
+
it("S-1: each setup() yields independent state (no module-level leak)", async () => {
|
|
250
|
+
const { tool: tool1 } = setup();
|
|
251
|
+
await tool1.execute("id", { action: "add", texts: ["first"] }, undefined, undefined, makeRpcCtx());
|
|
252
|
+
// 第二个 setup 起步,不应看到第一个的 todos
|
|
253
|
+
const { tool: tool2, state: state2 } = setup();
|
|
254
|
+
expect(state2.todos).toHaveLength(0);
|
|
255
|
+
const result = await tool2.execute(
|
|
256
|
+
"id",
|
|
257
|
+
{ action: "list" },
|
|
258
|
+
undefined,
|
|
259
|
+
undefined,
|
|
260
|
+
makeRpcCtx(),
|
|
261
|
+
);
|
|
262
|
+
expect(result.content[0].text).toBe("No todos");
|
|
263
|
+
// 空 list 仍走 buildGui([])(rpc 分支无条件 attach)
|
|
264
|
+
expect(result.details.__gui__).toBeDefined();
|
|
265
|
+
expect(result.details.__gui__!.component.props.items).toEqual([]);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("S-2: details.todos is a shallow array copy (splice-safe, element-shared)", async () => {
|
|
269
|
+
// executeTodoAction 用 [...state.todos] 做浅拷贝:数组独立、元素共享。
|
|
270
|
+
// add/clear 改数组长度时旧 details.todos 不受影响;但原地改元素会共享。
|
|
271
|
+
const { tool } = setup();
|
|
272
|
+
await tool.execute("id", { action: "add", texts: ["a", "b"] }, undefined, undefined, makeRpcCtx());
|
|
273
|
+
const before = (await tool.execute(
|
|
274
|
+
"id",
|
|
275
|
+
{ action: "list" },
|
|
276
|
+
undefined,
|
|
277
|
+
undefined,
|
|
278
|
+
makeRpcCtx(),
|
|
279
|
+
)).details.todos;
|
|
280
|
+
expect(before).toHaveLength(2);
|
|
281
|
+
// clear 改 state.todos 数组,已发出的 before 快照仍为 2 项
|
|
282
|
+
await tool.execute("id", { action: "clear" }, undefined, undefined, makeRpcCtx());
|
|
283
|
+
expect(before).toHaveLength(2);
|
|
284
|
+
});
|
|
285
|
+
});
|
package/src/component.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
6
|
-
import { matchesKey, truncateToWidth } from "@
|
|
6
|
+
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
7
7
|
|
|
8
8
|
import type { Todo } from "./model";
|
|
9
9
|
import { FALLBACK_TERM_WIDTH, renderDualColumn } from "./render";
|
package/src/handlers.ts
CHANGED
|
@@ -25,7 +25,7 @@ const REMINDER_INTERVAL = 2;
|
|
|
25
25
|
export type RefreshDisplayFn = (ctx: ExtensionContext) => void;
|
|
26
26
|
|
|
27
27
|
/** 构建极简提醒:只含下一个推荐任务 */
|
|
28
|
-
function buildMinimalReminder(state: TodoSessionState): string {
|
|
28
|
+
export function buildMinimalReminder(state: TodoSessionState): string {
|
|
29
29
|
const pendingTodos = state.todos.filter((t) => t.status !== "completed");
|
|
30
30
|
if (pendingTodos.length === 0) return "";
|
|
31
31
|
|
|
@@ -33,8 +33,7 @@ function buildMinimalReminder(state: TodoSessionState): string {
|
|
|
33
33
|
return `<todo_context>\n[TODO] 你有 ${pendingTodos.length} 个未完成任务。下一个应处理:#${next.id} ${next.text}\n</todo_context>`;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
function buildBeforeAgentStartMessage(state: TodoSessionState): { message: { customType: string; content: string; display: boolean } } | undefined {
|
|
36
|
+
export function buildBeforeAgentStartMessage(state: TodoSessionState): { message: { customType: string; content: string; display: boolean } } | undefined {
|
|
38
37
|
if (state.todos.length === 0) return undefined;
|
|
39
38
|
|
|
40
39
|
const pendingTodos = state.todos.filter((t) => t.status !== "completed");
|
|
@@ -55,7 +54,7 @@ function buildBeforeAgentStartMessage(state: TodoSessionState): { message: { cus
|
|
|
55
54
|
|
|
56
55
|
// ── 状态重建 ────────────────────────────────────────
|
|
57
56
|
|
|
58
|
-
function reconstructState(state: TodoSessionState, ctx: ExtensionContext): void {
|
|
57
|
+
export function reconstructState(state: TodoSessionState, ctx: ExtensionContext): void {
|
|
59
58
|
state.todos = [];
|
|
60
59
|
state.nextId = 1;
|
|
61
60
|
state.userMessageCount = 0;
|
|
@@ -100,8 +99,7 @@ function reconstructState(state: TodoSessionState, ctx: ExtensionContext): void
|
|
|
100
99
|
|
|
101
100
|
// ── agent_end 子函数 ────────────────────────────────
|
|
102
101
|
|
|
103
|
-
|
|
104
|
-
function handleAutoClear(state: TodoSessionState): { handled: boolean; cleared: boolean } {
|
|
102
|
+
export function handleAutoClear(state: TodoSessionState): { handled: boolean; cleared: boolean } {
|
|
105
103
|
const allCompleted = state.todos.every((t) => t.status === "completed");
|
|
106
104
|
if (!allCompleted) {
|
|
107
105
|
state.allCompletedAtCount = null;
|
|
@@ -120,8 +118,7 @@ function handleAutoClear(state: TodoSessionState): { handled: boolean; cleared:
|
|
|
120
118
|
return { handled: true, cleared: false };
|
|
121
119
|
}
|
|
122
120
|
|
|
123
|
-
|
|
124
|
-
function handleCompletionSteer(state: TodoSessionState): boolean {
|
|
121
|
+
export function handleCompletionSteer(state: TodoSessionState): boolean {
|
|
125
122
|
if (state.completionSteered) return false;
|
|
126
123
|
const allCompleted = state.todos.length > 0 && state.todos.every((t) => t.status === "completed");
|
|
127
124
|
if (!allCompleted) return false;
|
|
@@ -131,8 +128,7 @@ function handleCompletionSteer(state: TodoSessionState): boolean {
|
|
|
131
128
|
return true;
|
|
132
129
|
}
|
|
133
130
|
|
|
134
|
-
|
|
135
|
-
function handleStallDetection(state: TodoSessionState): boolean {
|
|
131
|
+
export function handleStallDetection(state: TodoSessionState): boolean {
|
|
136
132
|
if (
|
|
137
133
|
!state.stallNotified &&
|
|
138
134
|
state.userMessageCount - state.lastTodoCallCount >= STALL_THRESHOLD
|
|
@@ -147,8 +143,7 @@ function handleStallDetection(state: TodoSessionState): boolean {
|
|
|
147
143
|
return false;
|
|
148
144
|
}
|
|
149
145
|
|
|
150
|
-
|
|
151
|
-
function handleReminder(state: TodoSessionState): boolean {
|
|
146
|
+
export function handleReminder(state: TodoSessionState): boolean {
|
|
152
147
|
if (state.userMessageCount - state.lastTodoCallCount >= REMINDER_INTERVAL) {
|
|
153
148
|
const reminder = buildMinimalReminder(state);
|
|
154
149
|
if (reminder) {
|
package/src/index.ts
CHANGED
|
@@ -1,24 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Todo Extension
|
|
2
|
+
* Todo Extension — 轻量三态任务清单(pending / in_progress / completed)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* - 新增 delete action
|
|
8
|
-
* - 状态栏通过 ctx.ui.setStatus 显示进度
|
|
9
|
-
* - reconstructState 向后兼容旧 done 字段 + entry GC
|
|
10
|
-
* - 支持 verifyText/verifyAttempts 字段(数据模型增强)
|
|
11
|
-
* - add 支持 verifyTexts 参数(验证文本映射)
|
|
4
|
+
* 设计定位:刻意不做状态机约束(与 goal 扩展的 7 态对立),状态自由流转;
|
|
5
|
+
* 状态持久化复用 Pi 自动记录的 toolResult entry(非 appendEntry);
|
|
6
|
+
* 通过 agent_end → before_agent_start 的延迟 steer 驱动任务推进。
|
|
12
7
|
*
|
|
13
|
-
*
|
|
14
|
-
* - state.ts:
|
|
15
|
-
* - model.ts:
|
|
16
|
-
* -
|
|
17
|
-
* -
|
|
18
|
-
*
|
|
19
|
-
* -
|
|
20
|
-
* -
|
|
21
|
-
* -
|
|
8
|
+
* 文件职责:
|
|
9
|
+
* - state.ts: TodoSessionState 会话状态接口 + 工厂(闭包内创建,session 隔离)
|
|
10
|
+
* - model.ts: 纯函数数据层(Todo 类型、migrateTodo 兼容迁移、addTodos/updateTodos、format/buildGui)
|
|
11
|
+
* - tool.ts: todo tool 注册 — 5 个 action(list/add/update/delete/clear)+ execute dispatcher
|
|
12
|
+
* - handlers.ts: 5 个事件处理器(session_start/session_tree/agent_start/before_agent_start/agent_end)
|
|
13
|
+
* + reconstructState(回放最后一条 todo toolResult)+ steer 四机制(autoClear/completion/stall/reminder)
|
|
14
|
+
* - render.ts: 状态栏(status line)/ widget(单双列自适应)/ tool result 三层渲染
|
|
15
|
+
* - component.ts: /todos 命令的 TodoListComponent TUI 视图(只读双列)
|
|
16
|
+
* - commands.ts: /todos 命令注册
|
|
17
|
+
* - index.ts(本文件): 工厂入口(创建 state + 注册 tool/command/event + refreshDisplay)
|
|
18
|
+
*
|
|
19
|
+
* 错误处理:handler 失败直接 throw(见 CLAUDE.md「Tool 设计」),不返回错误成功模式。
|
|
20
|
+
* model 层纯函数返回 Result 对象(合法),dispatcher 拿到 error 时 throw。
|
|
22
21
|
*/
|
|
23
22
|
|
|
24
23
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
package/src/model.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* (cancelled 不可恢复;isVerification 标记验证任务,FR-6 completion audit 用)
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { guiComponent, type GuiRenderResult, guiResult, type TreeItem } from "@xyz-agent/extension-protocol";
|
|
8
|
+
|
|
7
9
|
// ── 数据模型 ─────────────────────────────────────────
|
|
8
10
|
|
|
9
11
|
export interface Todo {
|
|
@@ -18,15 +20,8 @@ export interface TodoDetails {
|
|
|
18
20
|
action: "list" | "add" | "update" | "delete" | "clear";
|
|
19
21
|
todos: Todo[];
|
|
20
22
|
nextId: number;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
type: "task-list";
|
|
24
|
-
summary?: string;
|
|
25
|
-
data: {
|
|
26
|
-
items: Array<{ id: number; text: string; status: string }>;
|
|
27
|
-
meta: Record<string, string>;
|
|
28
|
-
};
|
|
29
|
-
};
|
|
23
|
+
/** GUI 渲染结果(仅 RPC 模式填充,前端 list-tree 渲染)。对齐 extension-protocol@0.2.0。 */
|
|
24
|
+
__gui__?: GuiRenderResult;
|
|
30
25
|
}
|
|
31
26
|
|
|
32
27
|
export const VALID_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const;
|
|
@@ -65,19 +60,42 @@ export function migrateTodo(raw: Todo): Todo {
|
|
|
65
60
|
};
|
|
66
61
|
}
|
|
67
62
|
|
|
68
|
-
// ── 渲染辅助
|
|
63
|
+
// ── GUI 渲染辅助 ─────────────────────────────────────
|
|
69
64
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
65
|
+
/**
|
|
66
|
+
* 把 todos 映射为 list-tree GuiRenderResult(对齐 extension-protocol@0.2.0)。
|
|
67
|
+
* status → icon/status 映射:
|
|
68
|
+
* pending → dot / 无 status
|
|
69
|
+
* in_progress → circle / running
|
|
70
|
+
* completed → check / done
|
|
71
|
+
* cancelled → cross / failed
|
|
72
|
+
*/
|
|
73
|
+
export function buildGui(todos: Todo[]): GuiRenderResult {
|
|
74
|
+
const items: TreeItem[] = todos.map((t) => {
|
|
75
|
+
const icon =
|
|
76
|
+
t.status === "completed"
|
|
77
|
+
? "check"
|
|
78
|
+
: t.status === "in_progress"
|
|
79
|
+
? "circle"
|
|
80
|
+
: t.status === "cancelled"
|
|
81
|
+
? "cross"
|
|
82
|
+
: "dot"; // pending
|
|
83
|
+
const status =
|
|
84
|
+
t.status === "in_progress"
|
|
85
|
+
? "running"
|
|
86
|
+
: t.status === "completed"
|
|
87
|
+
? "done"
|
|
88
|
+
: t.status === "cancelled"
|
|
89
|
+
? "failed"
|
|
90
|
+
: undefined; // pending 无 status
|
|
91
|
+
return {
|
|
92
|
+
icon,
|
|
93
|
+
label: `#${t.id}: ${t.text}`,
|
|
94
|
+
status,
|
|
95
|
+
depth: 0,
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
return guiResult(guiComponent("list-tree", { items }));
|
|
81
99
|
}
|
|
82
100
|
|
|
83
101
|
export function getDisplayStatus(t: Todo): string {
|
package/src/render.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
6
|
-
import { truncateToWidth, visibleWidth } from "@
|
|
6
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
getDisplayStatus,
|
|
@@ -166,7 +166,7 @@ function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, the
|
|
|
166
166
|
|
|
167
167
|
// ── Tool renderResult handler ────────────────────────
|
|
168
168
|
|
|
169
|
-
import { Text } from "@
|
|
169
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
170
170
|
|
|
171
171
|
export function renderTodoResult(result: unknown, options: { expanded: boolean }, theme: Theme): Text {
|
|
172
172
|
const r = result as { content: Array<{ type: string; text?: string }>; details?: unknown };
|
|
@@ -176,10 +176,6 @@ export function renderTodoResult(result: unknown, options: { expanded: boolean }
|
|
|
176
176
|
return new Text(text?.type === "text" ? (text.text ?? "") : "", 0, 0);
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
if (details.error) {
|
|
180
|
-
return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
179
|
const todoList = details.todos;
|
|
184
180
|
|
|
185
181
|
switch (details.action) {
|