@zhushanwen/pi-todo 0.9.10 → 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 +7 -7
- package/src/__tests__/commands.test.ts +74 -0
- package/src/__tests__/entry.test.ts +29 -0
- package/src/commands.ts +15 -15
- 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,15 +29,15 @@
|
|
|
29
29
|
"vitest": "^4.1.8"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@zhushanwen/
|
|
33
|
-
"@zhushanwen/extension-
|
|
32
|
+
"@zhushanwen/extension-protocol": "0.18.0",
|
|
33
|
+
"@zhushanwen/pi-extension-logger": "0.6.2",
|
|
34
34
|
"@zhushanwen/pi-session-path": "0.1.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
38
|
-
"@earendil-works/pi-tui": "^0.
|
|
39
|
-
"@earendil-works/pi-ai": "^0.
|
|
40
|
-
"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"
|
|
41
41
|
},
|
|
42
42
|
"peerDependenciesMeta": {
|
|
43
43
|
"@earendil-works/pi-tui": {
|
|
@@ -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
|
+
});
|
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/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
|
-
}
|