@zhushanwen/pi-todo 0.9.9 → 0.10.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 +8 -7
- package/src/__tests__/active-path.test.ts +159 -0
- package/src/__tests__/commands.test.ts +74 -0
- package/src/__tests__/entry.test.ts +29 -0
- package/src/__tests__/steer.test.ts +1 -1
- package/src/__tests__/todo.test.ts +1 -0
- package/src/commands.ts +15 -15
- package/src/handlers.ts +8 -2
- package/src/index.ts +2 -3
- package/src/model.ts +1 -1
- package/src/render.ts +2 -2
- package/src/component.ts +0 -75
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-todo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
|
@@ -29,14 +29,15 @@
|
|
|
29
29
|
"vitest": "^4.1.8"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@zhushanwen/extension-protocol": "0.
|
|
33
|
-
"@zhushanwen/pi-extension-logger": "0.6.
|
|
32
|
+
"@zhushanwen/extension-protocol": "0.18.0",
|
|
33
|
+
"@zhushanwen/pi-extension-logger": "0.6.2",
|
|
34
|
+
"@zhushanwen/pi-session-path": "0.1.0"
|
|
34
35
|
},
|
|
35
36
|
"peerDependencies": {
|
|
36
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
37
|
-
"@earendil-works/pi-tui": "^0.
|
|
38
|
-
"@earendil-works/pi-ai": "^0.
|
|
39
|
-
"typebox": "
|
|
37
|
+
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
38
|
+
"@earendil-works/pi-tui": "^1.0.0",
|
|
39
|
+
"@earendil-works/pi-ai": "^1.0.0",
|
|
40
|
+
"typebox": "1.3.27"
|
|
40
41
|
},
|
|
41
42
|
"peerDependenciesMeta": {
|
|
42
43
|
"@earendil-works/pi-tui": {
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { reconstructState, registerTodoEventHandlers, type RefreshDisplayFn } from "../handlers";
|
|
5
|
+
import { createTodoSessionState } from "../state";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* U6b:reconstructState 回放接活跃路径裁剪——从 leafId 沿 parentId 回溯得活跃路径,
|
|
9
|
+
* 被撤子树的 todo 快照不得进入重建态(不得经 `<todo_context>` 注入模型上下文)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface BranchFixtureEntry {
|
|
13
|
+
id: string;
|
|
14
|
+
parentId: string | null;
|
|
15
|
+
todos?: { id: number; text: string; status: string }[];
|
|
16
|
+
nextId?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** todo toolResult 快照 entry(合法 TodoDetails,避免触发脏数据降级路径) */
|
|
20
|
+
function todoEntry(id: string, parentId: string | null, todos: { id: number; text: string; status: string }[], nextId?: number) {
|
|
21
|
+
return {
|
|
22
|
+
type: "message",
|
|
23
|
+
id,
|
|
24
|
+
parentId,
|
|
25
|
+
timestamp: "2026-09-24T00:00:00.000Z",
|
|
26
|
+
message: { role: "toolResult", toolName: "todo", details: { todos, nextId } },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 撤回后真实形态:label entry 落文件尾(parentId = 回退后叶子),leafId 指向它 */
|
|
31
|
+
function labelEntry(id: string, parentId: string | null) {
|
|
32
|
+
return { type: "label", id, parentId, timestamp: "2026-09-24T00:00:01.000Z", targetId: "u-x", label: "taiji:revoked" };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeCtx(entries: BranchFixtureEntry[], leafId: string | null): ExtensionContext {
|
|
36
|
+
return {
|
|
37
|
+
sessionManager: {
|
|
38
|
+
getEntries: () => entries,
|
|
39
|
+
getLeafId: () => leafId,
|
|
40
|
+
},
|
|
41
|
+
} as unknown as ExtensionContext;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("reconstructState 活跃路径裁剪(U6b)", () => {
|
|
45
|
+
it("有分支 fixture:被撤子树的 todo 快照不进重建态——取活跃路径内最后一条", () => {
|
|
46
|
+
// 树:e1(base) → e2(active-branch);e1 → e3(revoked-branch,物理后写=被撤子树);
|
|
47
|
+
// 撤回落 label 锚 L(parentId = 回退后叶子 e2),leafId = L → 活跃路径 {L, e2, e1}
|
|
48
|
+
const entries = [
|
|
49
|
+
todoEntry("e1", null, [{ id: 1, text: "base", status: "pending" }], 2),
|
|
50
|
+
todoEntry("e2", "e1", [{ id: 1, text: "active-branch", status: "in_progress" }], 2),
|
|
51
|
+
todoEntry("e3", "e1", [{ id: 1, text: "revoked-branch", status: "completed" }], 2),
|
|
52
|
+
labelEntry("L", "e2"),
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const state = createTodoSessionState();
|
|
56
|
+
reconstructState(state, makeCtx(entries, "L"));
|
|
57
|
+
|
|
58
|
+
expect(state.todos).toHaveLength(1);
|
|
59
|
+
expect(state.todos[0]?.text).toBe("active-branch");
|
|
60
|
+
// 反向断言:被撤子树快照不进重建态
|
|
61
|
+
expect(state.todos.map((t) => t.text)).not.toContain("revoked-branch");
|
|
62
|
+
expect(state.nextId).toBe(2);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("活跃路径内无 todo 快照(撤回点早于全部 todo 操作)→ 重建态为空,不回退到旧分支快照", () => {
|
|
66
|
+
// 树:e1(普通 message entry) → e2(todo 快照,被撤子树);撤回后 leafId 指回 e1
|
|
67
|
+
const entries = [
|
|
68
|
+
{ type: "message", id: "e1", parentId: null, timestamp: "t", message: { role: "user", content: "hi" } },
|
|
69
|
+
todoEntry("e2", "e1", [{ id: 1, text: "only-on-revoked-branch", status: "pending" }], 2),
|
|
70
|
+
labelEntry("L", "e1"),
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const state = createTodoSessionState();
|
|
74
|
+
reconstructState(state, makeCtx(entries, "L"));
|
|
75
|
+
|
|
76
|
+
expect(state.todos).toHaveLength(0);
|
|
77
|
+
expect(state.nextId).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("无分支回归:leafId = 文件尾 → 全量回放最后一条(现行为不变)", () => {
|
|
81
|
+
const entries = [
|
|
82
|
+
todoEntry("e1", null, [{ id: 1, text: "first", status: "pending" }], 2),
|
|
83
|
+
todoEntry("e2", "e1", [{ id: 1, text: "first", status: "completed" }, { id: 2, text: "second", status: "in_progress" }], 3),
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const state = createTodoSessionState();
|
|
87
|
+
reconstructState(state, makeCtx(entries, "e2"));
|
|
88
|
+
|
|
89
|
+
expect(state.todos).toHaveLength(2);
|
|
90
|
+
expect(state.todos[1]?.text).toBe("second");
|
|
91
|
+
expect(state.nextId).toBe(3);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("leafId 防御:null / 指向不存在 entry → 回退文件尾全量回放(不静默清空重建态)", () => {
|
|
95
|
+
const entries = [
|
|
96
|
+
todoEntry("e1", null, [{ id: 1, text: "first", status: "pending" }], 2),
|
|
97
|
+
todoEntry("e2", "e1", [{ id: 1, text: "tail-snapshot", status: "pending" }], 2),
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
for (const leafId of [null, "missing-id"]) {
|
|
101
|
+
const state = createTodoSessionState();
|
|
102
|
+
reconstructState(state, makeCtx(entries, leafId));
|
|
103
|
+
expect(state.todos).toHaveLength(1);
|
|
104
|
+
expect(state.todos[0]?.text).toBe("tail-snapshot");
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("环状 parentId 防御(损坏文件 / 非 pi 产出写入)", () => {
|
|
110
|
+
it("环状 parentId(a↔b,leafId 指向环内)→ 回溯终止不挂死,重建完成且不抛错", () => {
|
|
111
|
+
// 环:a.parentId=b、b.parentId=a;leafId=a → 回溯 a→b→(二次命中 a) 守卫终止
|
|
112
|
+
const entries = [
|
|
113
|
+
todoEntry("a", "b", [{ id: 1, text: "cycle-a", status: "pending" }], 2),
|
|
114
|
+
todoEntry("b", "a", [{ id: 1, text: "cycle-b", status: "in_progress" }], 2),
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
const state = createTodoSessionState();
|
|
118
|
+
expect(() => reconstructState(state, makeCtx(entries, "a"))).not.toThrow();
|
|
119
|
+
|
|
120
|
+
// 终止形态(实现语义):activeIds = {a, b},回放链上最后一条 todo 快照 b
|
|
121
|
+
expect(state.todos).toHaveLength(1);
|
|
122
|
+
expect(state.todos[0]?.text).toBe("cycle-b");
|
|
123
|
+
expect(state.nextId).toBe(2);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("session_tree handler 回归(重建态与裁剪后一致)", () => {
|
|
128
|
+
it("触发 session_tree → state 重建为活跃路径态 + refreshDisplay 调用", async () => {
|
|
129
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<unknown>>();
|
|
130
|
+
const pi = {
|
|
131
|
+
on: vi.fn((event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<unknown>) => {
|
|
132
|
+
handlers.set(event, handler);
|
|
133
|
+
}),
|
|
134
|
+
} as unknown as ExtensionAPI;
|
|
135
|
+
const refreshDisplay = vi.fn() as unknown as RefreshDisplayFn;
|
|
136
|
+
|
|
137
|
+
const state = createTodoSessionState();
|
|
138
|
+
// 预填 stale:撤回前的旧分支态(模拟运行中撤回后缓存未失效的窗口)
|
|
139
|
+
state.todos = [{ id: 1, text: "revoked-branch", status: "completed" }];
|
|
140
|
+
state.nextId = 2;
|
|
141
|
+
|
|
142
|
+
registerTodoEventHandlers(pi, state, refreshDisplay);
|
|
143
|
+
|
|
144
|
+
const entries = [
|
|
145
|
+
todoEntry("e1", null, [{ id: 1, text: "base", status: "pending" }], 2),
|
|
146
|
+
todoEntry("e2", "e1", [{ id: 1, text: "active-branch", status: "in_progress" }], 2),
|
|
147
|
+
todoEntry("e3", "e1", [{ id: 1, text: "revoked-branch", status: "completed" }], 2),
|
|
148
|
+
labelEntry("L", "e2"),
|
|
149
|
+
];
|
|
150
|
+
await handlers.get("session_tree")!({ type: "session_tree" }, makeCtx(entries, "L"));
|
|
151
|
+
|
|
152
|
+
// 与直接调 reconstructState(裁剪语义)一致:活跃路径内最后一条
|
|
153
|
+
expect(state.todos).toHaveLength(1);
|
|
154
|
+
expect(state.todos[0]?.text).toBe("active-branch");
|
|
155
|
+
expect(refreshDisplay).toHaveBeenCalledTimes(1);
|
|
156
|
+
// 纯重建体:不注入消息(session_start 块无此副作用,todo handler 本就不 steer——回归锚)
|
|
157
|
+
expect(handlers.has("session_tree")).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /todos 命令双通道反馈单测(D10①,Q1 裁决)。
|
|
3
|
+
*
|
|
4
|
+
* 断言面:notify 通道(清单摘要 / 空态文案 / no-UI 提示)+ widget 通道
|
|
5
|
+
* (refreshDisplay 强推);双通道在同一次命令触发中都可达。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { describe, expect, it, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
import { registerTodosCommand } from "../commands";
|
|
12
|
+
import type { RefreshDisplayFn } from "../handlers";
|
|
13
|
+
import { addTodos } from "../model";
|
|
14
|
+
import { createTodoSessionState } from "../state";
|
|
15
|
+
|
|
16
|
+
type TodosCommandDef = {
|
|
17
|
+
handler: (args: string | undefined, ctx: ExtensionCommandContext) => Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function mockPi(): ExtensionAPI {
|
|
21
|
+
return { registerCommand: vi.fn() } as unknown as ExtensionAPI;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 注册命令并取回 handler 定义(registerCommand spy 的首次调用参数)。 */
|
|
25
|
+
function registerTodos(def: { state?: ReturnType<typeof createTodoSessionState>; refreshDisplay?: RefreshDisplayFn }): TodosCommandDef {
|
|
26
|
+
const pi = mockPi();
|
|
27
|
+
const refreshDisplay = def.refreshDisplay ?? (vi.fn() as unknown as RefreshDisplayFn);
|
|
28
|
+
const state = def.state ?? createTodoSessionState();
|
|
29
|
+
registerTodosCommand(pi, state, refreshDisplay);
|
|
30
|
+
const calls = (pi.registerCommand as unknown as ReturnType<typeof vi.fn>).mock.calls as Array<[string, TodosCommandDef]>;
|
|
31
|
+
expect(calls[0]?.[0]).toBe("todos");
|
|
32
|
+
return calls[0][1];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function uiCtx(hasUI: boolean, notify: ReturnType<typeof vi.fn>): ExtensionCommandContext {
|
|
36
|
+
return { hasUI, ui: { notify }, sessionManager: { getSessionId: () => "s1" } } as unknown as ExtensionCommandContext;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("/todos command(D10① notify + widget 双通道)", () => {
|
|
40
|
+
it("有清单:notify 收到清单摘要(formatTodoList 形态)+ refreshDisplay 被推(widget 通道)", async () => {
|
|
41
|
+
const state = createTodoSessionState();
|
|
42
|
+
const added = addTodos(state.todos, state.nextId, ["first"]);
|
|
43
|
+
state.todos = added.newTodos;
|
|
44
|
+
state.nextId = added.newNextId;
|
|
45
|
+
const refreshDisplay = vi.fn() as unknown as RefreshDisplayFn;
|
|
46
|
+
const def = registerTodos({ state, refreshDisplay });
|
|
47
|
+
const notify = vi.fn();
|
|
48
|
+
await def.handler(undefined, uiCtx(true, notify));
|
|
49
|
+
|
|
50
|
+
expect(notify).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(String(notify.mock.calls[0][0])).toContain("[ ] #1: first");
|
|
52
|
+
expect(refreshDisplay).toHaveBeenCalledTimes(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("空清单:notify 收到显式空态文案(不弹空串)", async () => {
|
|
56
|
+
const refreshDisplay = vi.fn() as unknown as RefreshDisplayFn;
|
|
57
|
+
const def = registerTodos({ refreshDisplay });
|
|
58
|
+
const notify = vi.fn();
|
|
59
|
+
await def.handler(undefined, uiCtx(true, notify));
|
|
60
|
+
|
|
61
|
+
expect(notify).toHaveBeenCalledWith("No todos for the current session", "info");
|
|
62
|
+
expect(refreshDisplay).toHaveBeenCalledTimes(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("无 UI(hasUI=false):仅 notify error 提示,widget 不推", async () => {
|
|
66
|
+
const refreshDisplay = vi.fn() as unknown as RefreshDisplayFn;
|
|
67
|
+
const def = registerTodos({ refreshDisplay });
|
|
68
|
+
const notify = vi.fn();
|
|
69
|
+
await def.handler(undefined, uiCtx(false, notify));
|
|
70
|
+
|
|
71
|
+
expect(notify).toHaveBeenCalledWith("/todos requires interactive mode", "error");
|
|
72
|
+
expect(refreshDisplay).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工厂入口装配单测:默认导出一次性注册 event/tool/command 三通道。
|
|
3
|
+
*
|
|
4
|
+
* 断言面:5 个事件 handler(session_start/session_tree/agent_start/
|
|
5
|
+
* before_agent_start/agent_end)+ todo tool + todos 命令各注册一次;
|
|
6
|
+
* 命令名锚定 "todos"(/todos 入口漂移 = 用户命令失效)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { describe, expect, it, vi } from "vitest";
|
|
11
|
+
|
|
12
|
+
import todoExtension from "../index";
|
|
13
|
+
|
|
14
|
+
describe("todo extension 工厂入口", () => {
|
|
15
|
+
it("注册 5 个事件 handler + todo tool + todos 命令", () => {
|
|
16
|
+
const pi = {
|
|
17
|
+
on: vi.fn(),
|
|
18
|
+
registerTool: vi.fn(),
|
|
19
|
+
registerCommand: vi.fn(),
|
|
20
|
+
} as unknown as ExtensionAPI;
|
|
21
|
+
|
|
22
|
+
todoExtension(pi);
|
|
23
|
+
|
|
24
|
+
expect(pi.on).toHaveBeenCalledTimes(5);
|
|
25
|
+
expect(pi.registerTool).toHaveBeenCalledTimes(1);
|
|
26
|
+
expect(pi.registerCommand).toHaveBeenCalledTimes(1);
|
|
27
|
+
expect((pi.registerCommand as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]).toBe("todos");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -27,7 +27,7 @@ function todoEntry(todos: Todo[], nextId: number) {
|
|
|
27
27
|
|
|
28
28
|
function makeCtx(entries: unknown[]) {
|
|
29
29
|
return {
|
|
30
|
-
sessionManager: { getEntries: () => entries },
|
|
30
|
+
sessionManager: { getLeafId: () => null, getEntries: () => entries },
|
|
31
31
|
} as unknown as Parameters<typeof reconstructState>[1];
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -115,6 +115,7 @@ describe("reconstructState dirty entry degradation", () => {
|
|
|
115
115
|
function makeCtx(details: unknown): Parameters<typeof reconstructState>[1] {
|
|
116
116
|
return {
|
|
117
117
|
sessionManager: {
|
|
118
|
+
getLeafId: () => null,
|
|
118
119
|
getEntries: () => [
|
|
119
120
|
{ type: "message", message: { role: "toolResult", toolName: "todo", details } },
|
|
120
121
|
],
|
package/src/commands.ts
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* /todos 命令注册 —
|
|
2
|
+
* /todos 命令注册 — notify + widget 双通道反馈(D10①,Q1 裁决)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 反馈通道从 ctx.ui.custom 的 TUI 组件视图改为双通道:
|
|
5
|
+
* - notify:formatTodoList 清单摘要(toast 在 taiji RPC 模式可达);
|
|
6
|
+
* - widget:refreshDisplay 强推 todo 面板(与 tool/handlers 反馈同一通道)。
|
|
7
|
+
* ctx.ui.custom 在 RPC 模式恒 undefined(评估组 B 实测),原 TodoListComponent
|
|
8
|
+
* 组件视图整体退役(component.ts 已删除)。
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
|
-
import type { ExtensionAPI, ExtensionCommandContext
|
|
10
|
-
import type { TUI } from "@earendil-works/pi-tui";
|
|
11
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
11
12
|
|
|
13
|
+
import { formatTodoList } from "./model";
|
|
12
14
|
import type { TodoSessionState } from "./state";
|
|
13
|
-
import {
|
|
15
|
+
import type { RefreshDisplayFn } from "./handlers";
|
|
14
16
|
|
|
15
17
|
/** 注册 /todos 命令到 pi */
|
|
16
|
-
export function registerTodosCommand(pi: ExtensionAPI, state: TodoSessionState): void {
|
|
18
|
+
export function registerTodosCommand(pi: ExtensionAPI, state: TodoSessionState, refreshDisplay: RefreshDisplayFn): void {
|
|
17
19
|
pi.registerCommand("todos", {
|
|
18
20
|
description: "View all todos for the current session",
|
|
19
21
|
handler: async (_args: string | undefined, ctx: ExtensionCommandContext) => {
|
|
@@ -22,13 +24,11 @@ export function registerTodosCommand(pi: ExtensionAPI, state: TodoSessionState):
|
|
|
22
24
|
return;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return new TodoListComponent(state.todos, theme, () => done(undefined));
|
|
31
|
-
});
|
|
27
|
+
// widget 通道:强推清单面板(含 setStatus 状态行,空清单时清面板)
|
|
28
|
+
refreshDisplay(ctx);
|
|
29
|
+
// notify 通道:清单摘要 toast(空清单给显式空态文案,不弹空串)
|
|
30
|
+
const summary = formatTodoList(state.todos);
|
|
31
|
+
ctx.ui.notify(summary === "" ? "No todos for the current session" : summary, "info");
|
|
32
32
|
},
|
|
33
33
|
});
|
|
34
34
|
}
|
package/src/handlers.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
8
|
+
import { filterActivePath } from "@zhushanwen/pi-session-path";
|
|
8
9
|
|
|
9
10
|
import {
|
|
10
11
|
migrateTodo,
|
|
@@ -50,8 +51,13 @@ export function buildBeforeAgentStartMessage(state: TodoSessionState): { message
|
|
|
50
51
|
|
|
51
52
|
// ── 状态重建 ────────────────────────────────────────
|
|
52
53
|
|
|
54
|
+
// 活跃路径裁剪(leafId 沿 parentId 回溯,撤回后被撤子树的 todo 快照不进重建输入)
|
|
55
|
+
// 收敛于 @zhushanwen/pi-session-path 单一实现(四包同构副本收编,防御语义与回退
|
|
56
|
+
// 口径见该包 filterActivePath 注释;runtime 侧 entry-tree-builder 保持独立——
|
|
57
|
+
// extension 不能 import runtime 包)。
|
|
58
|
+
|
|
53
59
|
/**
|
|
54
|
-
*
|
|
60
|
+
* 回放活跃路径内最后一条 todo toolResult 重建 state(纯读,不修改 entries)。
|
|
55
61
|
*
|
|
56
62
|
* H1(C2 决策):pi 的 SessionManager.getEntries() 返回的是 filter-copy,原先的
|
|
57
63
|
* splice GC 段对副本操作无效,且修改传入 entries 是反模式。删除整段 splice,只保留
|
|
@@ -65,7 +71,7 @@ export function reconstructState(state: TodoSessionState, ctx: ExtensionContext)
|
|
|
65
71
|
state.completionSteered = false;
|
|
66
72
|
state.pendingSteerMessage = null;
|
|
67
73
|
|
|
68
|
-
const entries = ctx.sessionManager
|
|
74
|
+
const entries = filterActivePath(ctx.sessionManager);
|
|
69
75
|
|
|
70
76
|
for (let i = 0; i < entries.length; i++) {
|
|
71
77
|
const entry = entries[i];
|
package/src/index.ts
CHANGED
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
* - handlers.ts: 5 个事件处理器(session_start/session_tree/agent_start/before_agent_start/agent_end)
|
|
13
13
|
* + reconstructState(回放最后一条 todo toolResult)+ steer 双机制(autoClear/completion)
|
|
14
14
|
* - render.ts: 状态栏(status line)/ widget(单双列自适应)/ tool result 三层渲染
|
|
15
|
-
* -
|
|
16
|
-
* - commands.ts: /todos 命令注册
|
|
15
|
+
* - commands.ts: /todos 命令注册(notify + widget 双通道反馈,D10①)
|
|
17
16
|
* - index.ts(本文件): 工厂入口(创建 state + 注册 tool/command/event + makeRefreshDisplay)
|
|
18
17
|
*
|
|
19
18
|
* 错误处理:包内单一 throw 协议——handler 与 model 层纯函数(addTodos / updateTodos)
|
|
@@ -74,5 +73,5 @@ export default function (pi: ExtensionAPI) {
|
|
|
74
73
|
// ── 注册所有 handler / tool / command ──────────────
|
|
75
74
|
registerTodoEventHandlers(pi, state, refreshDisplay);
|
|
76
75
|
registerTodoTool(pi, state, refreshDisplay);
|
|
77
|
-
registerTodosCommand(pi, state);
|
|
76
|
+
registerTodosCommand(pi, state, refreshDisplay);
|
|
78
77
|
}
|
package/src/model.ts
CHANGED
|
@@ -111,7 +111,7 @@ export function migrateTodo(raw: unknown): Todo {
|
|
|
111
111
|
|
|
112
112
|
// ── GUI 渲染辅助 ─────────────────────────────────────
|
|
113
113
|
|
|
114
|
-
/** completed 计数单一来源:renderStatusText / renderWidgetLines
|
|
114
|
+
/** completed 计数单一来源:renderStatusText / renderWidgetLines 等消费点共用口径
|
|
115
115
|
* (buildGui 的 tab 计数与段内容改由 openTodos/doneTodos 同源复用,不再经本函数)。 */
|
|
116
116
|
export function todoProgress(todos: Todo[]): { completed: number; total: number } {
|
|
117
117
|
return {
|
package/src/render.ts
CHANGED
|
@@ -43,7 +43,7 @@ export function renderStatusText(todoList: Todo[], th: Theme): string {
|
|
|
43
43
|
|
|
44
44
|
// ── Widget 双列渲染 ──────────────────────────────────
|
|
45
45
|
|
|
46
|
-
/** 渲染单条 todo 的 widget
|
|
46
|
+
/** 渲染单条 todo 的 widget 行(不含缩进)。
|
|
47
47
|
* textColor = 非完成态文本颜色(widget 用 "text",tool result 列表用 "muted"——历史差异显式保留,未做视觉统一)。 */
|
|
48
48
|
function renderWidgetItem(t: Todo, th: Theme, textColor: ThemeColor = "text"): string {
|
|
49
49
|
const mark =
|
|
@@ -70,7 +70,7 @@ function renderSingleColumn(
|
|
|
70
70
|
return todos.map((t) => truncateToWidth(indent + renderWidgetItem(t, th), maxWidth));
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
/** 双列布局渲染,供 widget
|
|
73
|
+
/** 双列布局渲染,供 widget 复用 */
|
|
74
74
|
const COLUMN_COUNT = 2;
|
|
75
75
|
|
|
76
76
|
export function renderDualColumn(
|
package/src/component.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* /todos 命令的 TUI 组件 — 独立可关闭的 todo 列表视图(双列布局)。
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
7
|
-
|
|
8
|
-
import { todoProgress, type Todo } from "./model";
|
|
9
|
-
import { FALLBACK_TERM_WIDTH, renderDualColumn } from "./render";
|
|
10
|
-
|
|
11
|
-
const HEADER_PREFIX_DASHES = 3;
|
|
12
|
-
const HEADER_RESERVED_WIDTH = 10;
|
|
13
|
-
|
|
14
|
-
export class TodoListComponent {
|
|
15
|
-
private todos: Todo[];
|
|
16
|
-
private theme: Theme;
|
|
17
|
-
private onClose: () => void;
|
|
18
|
-
private cachedWidth?: number;
|
|
19
|
-
private cachedLines?: string[];
|
|
20
|
-
|
|
21
|
-
constructor(todos: Todo[], theme: Theme, onClose: () => void) {
|
|
22
|
-
this.todos = todos;
|
|
23
|
-
this.theme = theme;
|
|
24
|
-
this.onClose = onClose;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
handleInput(data: string): void {
|
|
28
|
-
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
29
|
-
this.onClose();
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
render(width: number): string[] {
|
|
34
|
-
if (this.cachedLines && this.cachedWidth === width) {
|
|
35
|
-
return this.cachedLines;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const lines: string[] = [];
|
|
39
|
-
const th = this.theme;
|
|
40
|
-
const termWidth = width || FALLBACK_TERM_WIDTH;
|
|
41
|
-
const indent = " ";
|
|
42
|
-
|
|
43
|
-
lines.push("");
|
|
44
|
-
const title = th.fg("accent", " Todos ");
|
|
45
|
-
const headerLine =
|
|
46
|
-
th.fg("borderMuted", "\u2500".repeat(HEADER_PREFIX_DASHES)) + title + th.fg("borderMuted", "\u2500".repeat(Math.max(0, termWidth - HEADER_RESERVED_WIDTH)));
|
|
47
|
-
lines.push(truncateToWidth(headerLine, termWidth));
|
|
48
|
-
lines.push("");
|
|
49
|
-
|
|
50
|
-
if (this.todos.length === 0) {
|
|
51
|
-
lines.push(truncateToWidth(`${indent}${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, termWidth));
|
|
52
|
-
} else {
|
|
53
|
-
const { completed, total } = todoProgress(this.todos);
|
|
54
|
-
lines.push(truncateToWidth(`${indent}${th.fg("muted", `${completed}/${total} completed`)}`, termWidth));
|
|
55
|
-
lines.push("");
|
|
56
|
-
|
|
57
|
-
for (const line of renderDualColumn(this.todos, th, termWidth, indent)) {
|
|
58
|
-
lines.push(line);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
lines.push("");
|
|
63
|
-
lines.push(truncateToWidth(`${indent}${th.fg("dim", "Press Escape to close")}`, termWidth));
|
|
64
|
-
lines.push("");
|
|
65
|
-
|
|
66
|
-
this.cachedWidth = width;
|
|
67
|
-
this.cachedLines = lines;
|
|
68
|
-
return lines;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
invalidate(): void {
|
|
72
|
-
this.cachedWidth = undefined;
|
|
73
|
-
this.cachedLines = undefined;
|
|
74
|
-
}
|
|
75
|
-
}
|