@zhushanwen/pi-todo 0.2.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 +72 -22
- package/src/__tests__/tool-rpc.test.ts +285 -0
- package/src/component.ts +1 -1
- package/src/handlers.ts +6 -11
- package/src/index.ts +20 -18
- package/src/model.ts +68 -34
- package/src/render.ts +14 -15
- package/src/tool.ts +96 -212
|
@@ -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");
|
|
@@ -100,8 +99,7 @@ export function reconstructState(state: TodoSessionState, ctx: ExtensionContext)
|
|
|
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";
|
|
@@ -35,6 +34,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
35
34
|
// ── 闭包内状态(session 隔离) ─────────────────────
|
|
36
35
|
const state = createTodoSessionState();
|
|
37
36
|
|
|
37
|
+
// 全解耦:不再暴露 pi.__todoGetList 跨扩展 API(goal 不再读 todo 状态)。
|
|
38
|
+
// todo 进度由 AI 自行管理,goal 不做强制检查。
|
|
39
|
+
|
|
38
40
|
// ── 刷新显示(依赖闭包 state) ─────────────────────
|
|
39
41
|
function refreshDisplay(ctx: ExtensionContext): void {
|
|
40
42
|
const statusText = renderStatusText(state.todos, ctx.ui.theme);
|
package/src/model.ts
CHANGED
|
@@ -1,46 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Todo 数据模型 — 纯函数,不依赖 Pi 运行时。
|
|
3
|
-
*
|
|
3
|
+
* 四态: pending → in_progress → completed;任一状态 → cancelled
|
|
4
|
+
* (cancelled 不可恢复;isVerification 标记验证任务,FR-6 completion audit 用)
|
|
4
5
|
*/
|
|
5
6
|
|
|
7
|
+
import { guiComponent, type GuiRenderResult, guiResult, type TreeItem } from "@xyz-agent/extension-protocol";
|
|
8
|
+
|
|
6
9
|
// ── 数据模型 ─────────────────────────────────────────
|
|
7
10
|
|
|
8
11
|
export interface Todo {
|
|
9
12
|
id: number;
|
|
10
13
|
text: string;
|
|
11
|
-
status: "pending" | "in_progress" | "completed";
|
|
14
|
+
status: "pending" | "in_progress" | "completed" | "cancelled";
|
|
15
|
+
/** 验证任务标记(FR-6 completion audit)。验证任务必须 completed,不可 cancelled。 */
|
|
16
|
+
isVerification?: boolean;
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
export interface TodoDetails {
|
|
15
20
|
action: "list" | "add" | "update" | "delete" | "clear";
|
|
16
21
|
todos: Todo[];
|
|
17
22
|
nextId: number;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
type: "task-list";
|
|
21
|
-
summary?: string;
|
|
22
|
-
data: {
|
|
23
|
-
items: Array<{ id: number; text: string; status: string }>;
|
|
24
|
-
meta: Record<string, string>;
|
|
25
|
-
};
|
|
26
|
-
};
|
|
23
|
+
/** GUI 渲染结果(仅 RPC 模式填充,前端 list-tree 渲染)。对齐 extension-protocol@0.2.0。 */
|
|
24
|
+
__gui__?: GuiRenderResult;
|
|
27
25
|
}
|
|
28
26
|
|
|
29
|
-
export const VALID_STATUSES = ["pending", "in_progress", "completed"] as const;
|
|
27
|
+
export const VALID_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const;
|
|
30
28
|
|
|
31
29
|
export type ValidStatus = (typeof VALID_STATUSES)[number];
|
|
32
30
|
|
|
33
31
|
// ── 迁移/兼容 ───────────────────────────────────────
|
|
34
32
|
|
|
35
|
-
const STALE_CONTEXT_PATTERNS = ["aborted", "context canceled", "stale context", "stalecontext", "extension context no longer active"];
|
|
36
|
-
|
|
37
|
-
/** 检查错误是否表示 stale / canceled context */
|
|
38
|
-
export function isStaleContextError(error: Error | unknown): boolean {
|
|
39
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
40
|
-
const lower = msg.toLowerCase();
|
|
41
|
-
return STALE_CONTEXT_PATTERNS.some((p) => lower.includes(p));
|
|
42
|
-
}
|
|
43
|
-
|
|
44
33
|
/** 旧格式迁移:verifying → in_progress,failed → pending,done:boolean → status */
|
|
45
34
|
export function migrateTodo(raw: Todo): Todo {
|
|
46
35
|
const record = raw as unknown as Record<string, unknown>;
|
|
@@ -66,22 +55,47 @@ export function migrateTodo(raw: Todo): Todo {
|
|
|
66
55
|
id: record.id as number,
|
|
67
56
|
text: record.text as string,
|
|
68
57
|
status,
|
|
58
|
+
// FR-6: 保留 isVerification 标记(可选字段,旧数据可能缺失)
|
|
59
|
+
isVerification: record.isVerification === true ? true : undefined,
|
|
69
60
|
};
|
|
70
61
|
}
|
|
71
62
|
|
|
72
|
-
// ── 渲染辅助
|
|
63
|
+
// ── GUI 渲染辅助 ─────────────────────────────────────
|
|
73
64
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 }));
|
|
85
99
|
}
|
|
86
100
|
|
|
87
101
|
export function getDisplayStatus(t: Todo): string {
|
|
@@ -101,6 +115,7 @@ export function addTodos(
|
|
|
101
115
|
currentTodos: Todo[],
|
|
102
116
|
currentNextId: number,
|
|
103
117
|
texts: string[],
|
|
118
|
+
isVerification?: boolean,
|
|
104
119
|
): AddResult {
|
|
105
120
|
if (!texts || texts.length === 0) {
|
|
106
121
|
return {
|
|
@@ -129,6 +144,8 @@ export function addTodos(
|
|
|
129
144
|
id: nextId++,
|
|
130
145
|
text: trimmed[i],
|
|
131
146
|
status: "pending" as const,
|
|
147
|
+
// FR-6: isVerification 标记验证任务(可选,仅 add 时可设)
|
|
148
|
+
isVerification: isVerification === true ? true : undefined,
|
|
132
149
|
});
|
|
133
150
|
}
|
|
134
151
|
const endId = nextId - 1;
|
|
@@ -183,6 +200,21 @@ export function updateTodos(
|
|
|
183
200
|
resultText: `Error: invalid status '${u.status}' for update item id ${u.id}`,
|
|
184
201
|
};
|
|
185
202
|
}
|
|
203
|
+
// FR-6 不变量守卫:(a) cancelled 不可恢复;(b) 验证任务不可 cancelled
|
|
204
|
+
if (todo.status === "cancelled" && u.status !== undefined) {
|
|
205
|
+
return {
|
|
206
|
+
updatedTodos: currentTodos,
|
|
207
|
+
error: `id ${u.id} is cancelled`,
|
|
208
|
+
resultText: `Error: Todo #${u.id} is cancelled and cannot be restored`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
if (todo.isVerification && u.status === "cancelled") {
|
|
212
|
+
return {
|
|
213
|
+
updatedTodos: currentTodos,
|
|
214
|
+
error: `id ${u.id} is verification todo`,
|
|
215
|
+
resultText: `Error: Todo #${u.id} is a verification todo and cannot be cancelled`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
186
218
|
}
|
|
187
219
|
|
|
188
220
|
const updated = currentTodos.map((t) => {
|
|
@@ -207,6 +239,8 @@ export function formatTodoLine(t: Todo): string {
|
|
|
207
239
|
? "x"
|
|
208
240
|
: t.status === "in_progress"
|
|
209
241
|
? "~"
|
|
210
|
-
:
|
|
242
|
+
: t.status === "cancelled"
|
|
243
|
+
? "-"
|
|
244
|
+
: " ";
|
|
211
245
|
return `[${mark}] #${t.id}: ${t.text}`;
|
|
212
246
|
}
|
package/src/render.ts
CHANGED
|
@@ -3,10 +3,9 @@
|
|
|
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
|
-
buildRender,
|
|
10
9
|
getDisplayStatus,
|
|
11
10
|
type Todo,
|
|
12
11
|
type TodoDetails,
|
|
@@ -55,22 +54,25 @@ export function renderStatusText(todoList: Todo[], th: Theme): string {
|
|
|
55
54
|
// ── Widget 双列渲染 ──────────────────────────────────
|
|
56
55
|
|
|
57
56
|
/** 渲染单条 todo 的 widget 行(不含缩进),供 component.ts 复用 */
|
|
58
|
-
|
|
57
|
+
function renderWidgetItem(t: Todo, th: Theme): string {
|
|
59
58
|
const mark =
|
|
60
59
|
t.status === "completed"
|
|
61
60
|
? th.fg("success", "\u2713")
|
|
62
61
|
: t.status === "in_progress"
|
|
63
62
|
? th.fg("warning", "\u25cf")
|
|
64
|
-
:
|
|
63
|
+
: t.status === "cancelled"
|
|
64
|
+
? th.fg("error", "\u2715")
|
|
65
|
+
: th.fg("dim", "\u25cb");
|
|
65
66
|
const id = th.fg("accent", `#${t.id}`);
|
|
66
|
-
const text =
|
|
67
|
+
const text =
|
|
68
|
+
t.status === "completed" || t.status === "cancelled" ? th.fg("dim", t.text) : th.fg("text", t.text);
|
|
67
69
|
return `${mark} ${id} ${text}`;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/** 单列布局渲染(widget 少量任务时使用) */
|
|
71
73
|
const PI_TEXT_PADDING = 2;
|
|
72
74
|
|
|
73
|
-
|
|
75
|
+
function renderSingleColumn(
|
|
74
76
|
todos: Todo[],
|
|
75
77
|
th: Theme,
|
|
76
78
|
termWidth: number,
|
|
@@ -136,7 +138,7 @@ export function renderWidgetLines(
|
|
|
136
138
|
|
|
137
139
|
// ── 列表渲染辅助函数 ─────────────────────────────────
|
|
138
140
|
|
|
139
|
-
|
|
141
|
+
function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, theme: Theme): string {
|
|
140
142
|
if (todoList.length === 0) {
|
|
141
143
|
return theme.fg("dim", "No todos");
|
|
142
144
|
}
|
|
@@ -149,9 +151,11 @@ export function buildTodoListText(todoList: Todo[], options: { expanded: boolean
|
|
|
149
151
|
? theme.fg("success", "\u2713")
|
|
150
152
|
: status === "in_progress"
|
|
151
153
|
? theme.fg("warning", "\u25cf")
|
|
152
|
-
:
|
|
154
|
+
: status === "cancelled"
|
|
155
|
+
? theme.fg("error", "\u2715")
|
|
156
|
+
: theme.fg("dim", "\u25cb");
|
|
153
157
|
const itemText =
|
|
154
|
-
status === "completed" ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
|
|
158
|
+
status === "completed" || status === "cancelled" ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
|
|
155
159
|
listText += `\n${mark} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
|
|
156
160
|
}
|
|
157
161
|
if (!options.expanded && todoList.length > MAX_COLLAPSED_ITEMS) {
|
|
@@ -162,7 +166,7 @@ export function buildTodoListText(todoList: Todo[], options: { expanded: boolean
|
|
|
162
166
|
|
|
163
167
|
// ── Tool renderResult handler ────────────────────────
|
|
164
168
|
|
|
165
|
-
import { Text } from "@
|
|
169
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
166
170
|
|
|
167
171
|
export function renderTodoResult(result: unknown, options: { expanded: boolean }, theme: Theme): Text {
|
|
168
172
|
const r = result as { content: Array<{ type: string; text?: string }>; details?: unknown };
|
|
@@ -172,10 +176,6 @@ export function renderTodoResult(result: unknown, options: { expanded: boolean }
|
|
|
172
176
|
return new Text(text?.type === "text" ? (text.text ?? "") : "", 0, 0);
|
|
173
177
|
}
|
|
174
178
|
|
|
175
|
-
if (details.error) {
|
|
176
|
-
return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
179
|
const todoList = details.todos;
|
|
180
180
|
|
|
181
181
|
switch (details.action) {
|
|
@@ -205,4 +205,3 @@ export function renderTodoResult(result: unknown, options: { expanded: boolean }
|
|
|
205
205
|
}
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
export { buildRender };
|