@zhushanwen/pi-todo 0.9.3 → 0.9.5
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 +3 -3
- package/src/__tests__/gui.test.ts +125 -19
- package/src/__tests__/todo.test.ts +97 -1
- package/src/__tests__/tool-rpc.test.ts +105 -17
- package/src/handlers.ts +12 -3
- package/src/index.ts +1 -1
- package/src/model.ts +121 -44
- package/src/render.ts +1 -1
- package/src/tool.ts +30 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-todo",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.5",
|
|
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,8 +29,8 @@
|
|
|
29
29
|
"vitest": "^4.1.8"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@
|
|
33
|
-
"@zhushanwen/pi-extension-logger": "0.6.
|
|
32
|
+
"@zhushanwen/extension-protocol": "0.12.0",
|
|
33
|
+
"@zhushanwen/pi-extension-logger": "0.6.1"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
@@ -1,9 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buildGui 测试 — meta head + tab-bar 双段架构。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* - 内容根 = tab-bar 双段:tabs 与 sections 等长(2 段,容器化前提),待办段 =
|
|
6
|
+
* 未完成项 numbered list-tree(行首序号语义不变),已完成段 = 已完成项 list-tree
|
|
7
|
+
* - 两段互斥且覆盖全量(tab 标签计数与段内容同源);空段 = 空 items(既有空态语义)
|
|
8
|
+
* - meta:title=Todo、progress=current/total 计数、status 语义、托盘 icon(显式
|
|
9
|
+
* 'list-checks')+ badge(未完成条数)
|
|
10
|
+
*/
|
|
11
|
+
import type { GuiComponent, GuiComponentProps } from "@zhushanwen/extension-protocol";
|
|
1
12
|
import { describe, expect, it } from "vitest";
|
|
2
13
|
|
|
3
14
|
import { buildGui, type Todo } from "../model";
|
|
4
15
|
|
|
5
|
-
|
|
6
|
-
|
|
16
|
+
/** tab-bar props(根形状断言收口在此:非 tab-bar 根直接失败,段结构断言才有意义) */
|
|
17
|
+
function tabBarProps(gui: ReturnType<typeof buildGui>): GuiComponentProps["tab-bar"] {
|
|
18
|
+
if (gui.component.type !== "tab-bar") {
|
|
19
|
+
throw new Error(`内容根应为 tab-bar,实际 ${gui.component.type}`);
|
|
20
|
+
}
|
|
21
|
+
return gui.component.props as GuiComponentProps["tab-bar"];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 段必须是 list-tree:返回其 props(段形状先验,供 items 断言) */
|
|
25
|
+
function listTreeProps(section: GuiComponent | undefined): GuiComponentProps["list-tree"] {
|
|
26
|
+
if (!section || section.type !== "list-tree") {
|
|
27
|
+
throw new Error(`段应为 list-tree,实际 ${section?.type ?? "undefined"}`);
|
|
28
|
+
}
|
|
29
|
+
return section.props as GuiComponentProps["list-tree"];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("buildGui(meta head + tab-bar 双段架构)", () => {
|
|
33
|
+
it("内容根 = tab-bar:sections 与 tabs 等长(2 段),待办段 = 未完成项 numbered list-tree", () => {
|
|
7
34
|
const todos: Todo[] = [
|
|
8
35
|
{ id: 1, text: "pending task", status: "pending" },
|
|
9
36
|
{ id: 2, text: "active task", status: "in_progress" },
|
|
@@ -11,16 +38,67 @@ describe("buildGui(v1.1 meta head 架构)", () => {
|
|
|
11
38
|
];
|
|
12
39
|
const gui = buildGui(todos);
|
|
13
40
|
expect(gui.v).toBe(1);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
expect(
|
|
20
|
-
//
|
|
21
|
-
expect(
|
|
22
|
-
|
|
23
|
-
|
|
41
|
+
const { tabs, sections } = tabBarProps(gui);
|
|
42
|
+
// 标签计数与段内容同源(待办 = 未完成数,已完成 = completed 数)
|
|
43
|
+
expect(tabs.map((t) => t.label)).toEqual(["待办 2", "已完成 1"]);
|
|
44
|
+
// 首段带 active:容器化宿主据此建立初始 tab(此后本地切换不被推送重置)
|
|
45
|
+
expect(tabs[0]!.active).toBe(true);
|
|
46
|
+
expect(tabs[1]!.active).toBeUndefined();
|
|
47
|
+
// 与 tabs 等长(长度不等时宿主忽略 sections 退化为纯展示,段内容即丢失)
|
|
48
|
+
expect(sections).toHaveLength(tabs.length);
|
|
49
|
+
|
|
50
|
+
const openProps = listTreeProps(sections![0]![0]);
|
|
51
|
+
// 行首序号语义不变:numbered 开,label 纯文本(无 #N 前缀,序号由 ListTree 渲染)
|
|
52
|
+
expect(openProps.numbered).toBe(true);
|
|
53
|
+
expect(openProps.items).toEqual([
|
|
54
|
+
// pending → 无 status(guiResult 的 stripUndefined 删除 undefined 键),无 icon
|
|
55
|
+
{ label: "pending task", depth: 0 },
|
|
56
|
+
// in_progress → running
|
|
57
|
+
{ label: "active task", status: "running", depth: 0 },
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("已完成段 = 已完成项 list-tree(两段互斥且覆盖全量,行内 status 映射不变)", () => {
|
|
62
|
+
const todos: Todo[] = [
|
|
63
|
+
{ id: 1, text: "pending task", status: "pending" },
|
|
64
|
+
{ id: 2, text: "done task", status: "completed" },
|
|
65
|
+
{ id: 3, text: "another done", status: "completed" },
|
|
66
|
+
];
|
|
67
|
+
const { tabs, sections } = tabBarProps(buildGui(todos));
|
|
68
|
+
expect(tabs.map((t) => t.label)).toEqual(["待办 1", "已完成 2"]);
|
|
69
|
+
expect(sections).toHaveLength(2);
|
|
70
|
+
|
|
71
|
+
const doneProps = listTreeProps(sections![1]![0]);
|
|
72
|
+
expect(doneProps.numbered).toBe(true);
|
|
73
|
+
expect(doneProps.items).toEqual([
|
|
74
|
+
{ label: "done task", status: "done", depth: 0 },
|
|
75
|
+
{ label: "another done", status: "done", depth: 0 },
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
// 互斥且覆盖全量:两段 label 并集 = 原清单,无漏行(重复行由 items 精确断言承担)
|
|
79
|
+
const openLabels = listTreeProps(sections![0]![0]).items.map((i) => i.label);
|
|
80
|
+
const doneLabels = doneProps.items.map((i) => i.label);
|
|
81
|
+
expect([...openLabels, ...doneLabels].sort()).toEqual([
|
|
82
|
+
"another done",
|
|
83
|
+
"done task",
|
|
84
|
+
"pending task",
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("空段(无已完成项 / 无未完成项)= 空 items 的 list-tree:既有空态语义,无装饰性占位", () => {
|
|
89
|
+
const noDone = tabBarProps(buildGui([{ id: 1, text: "a", status: "pending" }]));
|
|
90
|
+
expect(noDone.tabs.map((t) => t.label)).toEqual(["待办 1", "已完成 0"]);
|
|
91
|
+
expect(noDone.sections![1]![0]).toEqual({
|
|
92
|
+
type: "list-tree",
|
|
93
|
+
props: { numbered: true, items: [] },
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const allDone = tabBarProps(buildGui([{ id: 1, text: "a", status: "completed" }]));
|
|
97
|
+
expect(allDone.tabs.map((t) => t.label)).toEqual(["待办 0", "已完成 1"]);
|
|
98
|
+
expect(allDone.sections![0]![0]).toEqual({
|
|
99
|
+
type: "list-tree",
|
|
100
|
+
props: { numbered: true, items: [] },
|
|
101
|
+
});
|
|
24
102
|
});
|
|
25
103
|
|
|
26
104
|
it("meta:title=Todo,progress=current/total 计数(head 渲染,body 不再有 progress-bar)", () => {
|
|
@@ -34,10 +112,26 @@ describe("buildGui(v1.1 meta head 架构)", () => {
|
|
|
34
112
|
title: "Todo",
|
|
35
113
|
status: "running",
|
|
36
114
|
progress: { current: 1, total: 3 },
|
|
115
|
+
icon: "list-checks",
|
|
116
|
+
badge: "2",
|
|
37
117
|
});
|
|
38
118
|
});
|
|
39
119
|
|
|
40
|
-
it("
|
|
120
|
+
it("meta.icon 推显式 'list-checks' key;badge = 未完成条数(与待办段计数同源)", () => {
|
|
121
|
+
const gui = buildGui([
|
|
122
|
+
{ id: 1, text: "a", status: "completed" },
|
|
123
|
+
{ id: 2, text: "b", status: "pending" },
|
|
124
|
+
]);
|
|
125
|
+
// 协议形状:string(宿主按 lucide 名解析)或 { paths }(自定义形状)。
|
|
126
|
+
// todo 只用 key 形态——与宿主内置 widgetKey 映射('todo'→ListChecks)同款,
|
|
127
|
+
// 宿主改自己的映射表也不会换掉 todo 图标。
|
|
128
|
+
expect(gui.meta!.icon).toBe("list-checks");
|
|
129
|
+
expect(gui.meta!.badge).toBe("1");
|
|
130
|
+
// badge 与首段标签计数同源(同一 open 计数,无双口径)
|
|
131
|
+
expect(tabBarProps(gui).tabs[0]!.label).toContain(gui.meta!.badge!);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("全部完成 → meta.status=done(badge 归零)", () => {
|
|
41
135
|
const todos: Todo[] = [
|
|
42
136
|
{ id: 1, text: "a", status: "completed" },
|
|
43
137
|
{ id: 2, text: "b", status: "completed" },
|
|
@@ -46,20 +140,32 @@ describe("buildGui(v1.1 meta head 架构)", () => {
|
|
|
46
140
|
title: "Todo",
|
|
47
141
|
status: "done",
|
|
48
142
|
progress: { current: 2, total: 2 },
|
|
143
|
+
icon: "list-checks",
|
|
144
|
+
badge: "0",
|
|
49
145
|
});
|
|
50
146
|
});
|
|
51
147
|
|
|
52
|
-
it("有 pending 无 in_progress → status=idle;empty todos → 无 progress", () => {
|
|
148
|
+
it("有 pending 无 in_progress → status=idle;empty todos → 无 progress + 双空段", () => {
|
|
53
149
|
const pendingOnly: Todo[] = [{ id: 1, text: "a", status: "pending" }];
|
|
54
150
|
expect(buildGui(pendingOnly).meta).toEqual({
|
|
55
151
|
title: "Todo",
|
|
56
152
|
status: "idle",
|
|
57
153
|
progress: { current: 0, total: 1 },
|
|
154
|
+
icon: "list-checks",
|
|
155
|
+
badge: "1",
|
|
156
|
+
});
|
|
157
|
+
expect(buildGui([]).meta).toEqual({
|
|
158
|
+
title: "Todo",
|
|
159
|
+
status: "idle",
|
|
160
|
+
icon: "list-checks",
|
|
161
|
+
badge: "0",
|
|
58
162
|
});
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
expect(
|
|
63
|
-
|
|
163
|
+
// 空清单:两段皆空 list-tree(numbered 仍开,items 空,无行渲染)
|
|
164
|
+
const empty = tabBarProps(buildGui([]));
|
|
165
|
+
expect(empty.tabs.map((t) => t.label)).toEqual(["待办 0", "已完成 0"]);
|
|
166
|
+
expect(empty.sections!.map((s) => s[0])).toEqual([
|
|
167
|
+
{ type: "list-tree", props: { numbered: true, items: [] } },
|
|
168
|
+
{ type: "list-tree", props: { numbered: true, items: [] } },
|
|
169
|
+
]);
|
|
64
170
|
});
|
|
65
171
|
});
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
3
|
+
|
|
4
|
+
// handlers.ts 模块级 getLogger —— 测试里 spy warn(脏条目汇总留痕断言面)
|
|
5
|
+
const loggerWarnSpy = vi.hoisted(() => vi.fn());
|
|
6
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
7
|
+
getLogger: () => ({ warn: loggerWarnSpy, debug: vi.fn(), error: vi.fn() }),
|
|
8
|
+
}));
|
|
3
9
|
|
|
4
10
|
import {
|
|
5
11
|
addTodos,
|
|
6
12
|
formatTodoLine,
|
|
7
13
|
formatTodoList,
|
|
14
|
+
isBlankUpdateText,
|
|
15
|
+
isValidTodoStatus,
|
|
8
16
|
migrateTodo,
|
|
9
17
|
type Todo,
|
|
10
18
|
updateTodos,
|
|
11
19
|
VALID_STATUSES,
|
|
12
20
|
} from "../model";
|
|
21
|
+
import { reconstructState } from "../handlers";
|
|
13
22
|
import { renderWidgetLines } from "../render";
|
|
14
23
|
import { createTodoSessionState } from "../state";
|
|
15
24
|
import { handleAdd, handleSingleUpdate } from "../tool";
|
|
@@ -30,6 +39,25 @@ describe("Todo data model", () => {
|
|
|
30
39
|
expect(VALID_STATUSES).toEqual(["pending", "in_progress", "completed"]);
|
|
31
40
|
});
|
|
32
41
|
|
|
42
|
+
// 共享校验原语:migrateTodo 迁移映射 / tool 单条 update / model 批量 update
|
|
43
|
+
// 三处共用的同一判据(校验收敛单点,文案由调用方编排)
|
|
44
|
+
it("isValidTodoStatus — 三态字面量为真、其余(含历史 verifying)为假", () => {
|
|
45
|
+
expect(isValidTodoStatus("pending")).toBe(true);
|
|
46
|
+
expect(isValidTodoStatus("in_progress")).toBe(true);
|
|
47
|
+
expect(isValidTodoStatus("completed")).toBe(true);
|
|
48
|
+
expect(isValidTodoStatus("verifying")).toBe(false);
|
|
49
|
+
expect(isValidTodoStatus("done")).toBe(false);
|
|
50
|
+
expect(isValidTodoStatus("")).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("isBlankUpdateText — trim 后空串为真(CT5 不只判 ===)、非空为假", () => {
|
|
54
|
+
expect(isBlankUpdateText("")).toBe(true);
|
|
55
|
+
expect(isBlankUpdateText(" ")).toBe(true);
|
|
56
|
+
expect(isBlankUpdateText("\t\n")).toBe(true);
|
|
57
|
+
expect(isBlankUpdateText("hello")).toBe(false);
|
|
58
|
+
expect(isBlankUpdateText(" a ")).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
33
61
|
it("should migrate verifying → in_progress", () => {
|
|
34
62
|
const oldTodo = { id: 1, text: "test", status: "verifying" } as unknown as Todo;
|
|
35
63
|
const migrated = migrateTodo(oldTodo);
|
|
@@ -67,6 +95,74 @@ describe("Todo data model", () => {
|
|
|
67
95
|
expect(() => migrateTodo(undefined)).toThrow(TypeError);
|
|
68
96
|
expect(() => migrateTodo("garbage")).toThrow(TypeError);
|
|
69
97
|
});
|
|
98
|
+
|
|
99
|
+
it("dirty id (string / NaN) → TypeError(不产 NaN 毒化 nextId)", () => {
|
|
100
|
+
expect(() => migrateTodo({ id: "1", text: "x", status: "pending" })).toThrow(/invalid id/);
|
|
101
|
+
expect(() => migrateTodo({ id: Number.NaN, text: "x", status: "pending" })).toThrow(/invalid id/);
|
|
102
|
+
expect(() => migrateTodo({ text: "missing id", status: "pending" })).toThrow(/invalid id/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("dirty text (number / missing) → TypeError", () => {
|
|
106
|
+
expect(() => migrateTodo({ id: 1, text: 42, status: "pending" })).toThrow(/invalid text/);
|
|
107
|
+
expect(() => migrateTodo({ id: 1, status: "pending" })).toThrow(/invalid text/);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── reconstructState 脏条目降级(汇总 warn + 不产 NaN)──
|
|
112
|
+
|
|
113
|
+
describe("reconstructState dirty entry degradation", () => {
|
|
114
|
+
/** 构造最小 ctx:sessionManager.getEntries 返回给定 toolResult 快照。 */
|
|
115
|
+
function makeCtx(details: unknown): Parameters<typeof reconstructState>[1] {
|
|
116
|
+
return {
|
|
117
|
+
sessionManager: {
|
|
118
|
+
getEntries: () => [
|
|
119
|
+
{ type: "message", message: { role: "toolResult", toolName: "todo", details } },
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
} as unknown as Parameters<typeof reconstructState>[1];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
loggerWarnSpy.mockClear();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("脏条目跳过 + 汇总一次 warn,好条目保留、nextId 不产 NaN", () => {
|
|
130
|
+
const state = createTodoSessionState();
|
|
131
|
+
reconstructState(
|
|
132
|
+
state,
|
|
133
|
+
makeCtx({
|
|
134
|
+
todos: [
|
|
135
|
+
{ id: 1, text: "ok", status: "completed" },
|
|
136
|
+
{ id: "bad", text: "dirty id" },
|
|
137
|
+
{ id: 2, text: 42 },
|
|
138
|
+
],
|
|
139
|
+
nextId: 9,
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
expect(state.todos).toHaveLength(1);
|
|
143
|
+
expect(state.todos[0]).toMatchObject({ id: 1, text: "ok", status: "completed" });
|
|
144
|
+
// details.nextId 存在 → 直接采用,Math.max 不见脏 id
|
|
145
|
+
expect(state.nextId).toBe(9);
|
|
146
|
+
// 计数可见:一次 warn 带跳过数与总量(不逐条刷屏)
|
|
147
|
+
expect(loggerWarnSpy).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(loggerWarnSpy).toHaveBeenCalledWith(
|
|
149
|
+
expect.stringContaining("skipped dirty todo entries"),
|
|
150
|
+
expect.objectContaining({ skipped: 2, total: 3, errors: expect.any(Array) }),
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("nextId 缺失 + 脏条目跳过 → nextId 从存活条目推导(不产 NaN)", () => {
|
|
155
|
+
const state = createTodoSessionState();
|
|
156
|
+
reconstructState(
|
|
157
|
+
state,
|
|
158
|
+
makeCtx({
|
|
159
|
+
todos: [{ id: "bad", text: "dirty" }, { id: 5, text: "ok", status: "pending" }],
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
expect(state.todos).toHaveLength(1);
|
|
163
|
+
expect(state.nextId).toBe(6);
|
|
164
|
+
expect(Number.isNaN(state.nextId)).toBe(false);
|
|
165
|
+
});
|
|
70
166
|
});
|
|
71
167
|
|
|
72
168
|
// ── todo add ────────────────────────────────────────
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* executeTodoAction handler 级测试 —— M17 后的两条路径:
|
|
3
3
|
* 1. tool result 无 __gui__(全模式统一——状态展示不再进 details)
|
|
4
|
-
* 2. refreshDisplay GUI widget 推送(rpc 推 marker
|
|
4
|
+
* 2. refreshDisplay GUI widget 推送(rpc 推 marker 编码的 tab-bar 双段信封 / tui 推纯文本行)
|
|
5
5
|
*
|
|
6
6
|
* 策略:executeTodoAction 未导出,通过 registerTodoTool + mock pi 捕获
|
|
7
7
|
* 已注册 tool,再以不同 ctx.mode 调 execute。setup 第三参传真实
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import { GUI_WIDGET_MARKER } from "@
|
|
13
|
+
import { GUI_WIDGET_MARKER } from "@zhushanwen/extension-protocol";
|
|
14
14
|
import { describe, expect, it, vi, type Mock } from "vitest";
|
|
15
15
|
|
|
16
16
|
import { makeRefreshDisplay } from "../index";
|
|
@@ -40,6 +40,7 @@ interface RegisteredTool {
|
|
|
40
40
|
onUpdate: unknown,
|
|
41
41
|
ctx: { mode: TestMode },
|
|
42
42
|
) => Promise<ExecuteResult>;
|
|
43
|
+
renderCall?: (args: Record<string, unknown>, theme: Theme, context?: unknown) => unknown;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
// pi-coding-agent 的 ExtensionContext 类型声明里没有 mode 字段(运行时实际有),
|
|
@@ -78,6 +79,31 @@ const stubTheme = {
|
|
|
78
79
|
getBashModeBorderColor: () => (text: string) => text,
|
|
79
80
|
} as unknown as Theme;
|
|
80
81
|
|
|
82
|
+
// ── renderCall 非法 args 安全渲染(TUI 渲染先于 schema 校验)──
|
|
83
|
+
|
|
84
|
+
/** 注册 → 捕获 tool → 以非法 args 调 renderCall,返回渲染文本(width 足够宽避免 wrap)。 */
|
|
85
|
+
function renderedCallText(args: unknown): string {
|
|
86
|
+
const { tool } = setup();
|
|
87
|
+
if (!tool.renderCall) throw new Error("todo tool did not register renderCall");
|
|
88
|
+
const node = tool.renderCall(args as Record<string, unknown>, stubTheme) as {
|
|
89
|
+
render: (width: number) => string[];
|
|
90
|
+
};
|
|
91
|
+
return node.render(400).join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe("renderCall — 非法 args 安全渲染", () => {
|
|
95
|
+
it("args 为 null / 原始类型 → 占位文本,不抛错", () => {
|
|
96
|
+
expect(renderedCallText(null)).toContain("(invalid args)");
|
|
97
|
+
expect(renderedCallText("bogus")).toContain("(invalid args)");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("字段错型(action 非字符串 / texts、ids 非数组)→ 不抛错,安全回落到标题", () => {
|
|
101
|
+
const text = renderedCallText({ action: 7, texts: "not-an-array", ids: 3, status: 1 });
|
|
102
|
+
expect(text).toContain("todo ");
|
|
103
|
+
expect(text).not.toContain("(invalid args)");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
81
107
|
/** 构造指定 mode 的 ctx,setWidget 为 vi.fn 供断言(refreshDisplay 推送出口)。 */
|
|
82
108
|
function makeCtx(mode: TestMode, hasUI: boolean): {
|
|
83
109
|
ctx: { mode: TestMode; hasUI: boolean; ui: { theme: Theme; setStatus: Mock; setWidget: Mock<SetWidgetFn> } };
|
|
@@ -100,6 +126,42 @@ const makeRpcCtx = () => makeCtx("rpc", false);
|
|
|
100
126
|
/** TUI 模式 ctx:hasUI=true。 */
|
|
101
127
|
const makeTuiCtx = () => makeCtx("tui", true);
|
|
102
128
|
|
|
129
|
+
// ── GUI 信封解析(wire 形状:只声明断言用到的字段)────
|
|
130
|
+
|
|
131
|
+
interface GuiEnvelope {
|
|
132
|
+
v: number;
|
|
133
|
+
component: {
|
|
134
|
+
type: string;
|
|
135
|
+
props: {
|
|
136
|
+
tabs: Array<{ label: string; active?: boolean }>;
|
|
137
|
+
// 段 = 子树(组件数组),与 tabs 等长一一对应
|
|
138
|
+
sections: Array<
|
|
139
|
+
Array<{
|
|
140
|
+
type: string;
|
|
141
|
+
props: {
|
|
142
|
+
numbered?: boolean;
|
|
143
|
+
items: Array<{ label: string; status?: string; depth?: number }>;
|
|
144
|
+
};
|
|
145
|
+
}>
|
|
146
|
+
>;
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
meta: {
|
|
150
|
+
title: string;
|
|
151
|
+
icon?: string;
|
|
152
|
+
badge?: string;
|
|
153
|
+
progress?: { current: number; total: number };
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 取最后一次 setWidget 推送的 marker 编码信封(G-1 / G-4 共用解析) */
|
|
158
|
+
function lastEnvelope(setWidget: Mock<SetWidgetFn>): GuiEnvelope {
|
|
159
|
+
const call = setWidget.mock.calls.at(-1);
|
|
160
|
+
const encoded = call?.[1]?.[0];
|
|
161
|
+
if (!encoded) throw new Error("setWidget 未收到 marker 编码载荷");
|
|
162
|
+
return JSON.parse(encoded.slice(GUI_WIDGET_MARKER.length)) as GuiEnvelope;
|
|
163
|
+
}
|
|
164
|
+
|
|
103
165
|
// ── tool result:无 __gui__(M17 后全模式统一)─────────
|
|
104
166
|
|
|
105
167
|
describe("executeTodoAction — tool result 无 __gui__(全模式)", () => {
|
|
@@ -155,7 +217,7 @@ describe("executeTodoAction — tool result 无 __gui__(全模式)", () => {
|
|
|
155
217
|
// ── refreshDisplay:GUI widget 推送(M17,真实实现)────
|
|
156
218
|
|
|
157
219
|
describe("refreshDisplay — GUI widget 推送(setup 传真实实现)", () => {
|
|
158
|
-
it("G-1: rpc + add → setWidget 收到 ('todo', [GUI_WIDGET_MARKER + JSON])
|
|
220
|
+
it("G-1: rpc + add → setWidget 收到 ('todo', [GUI_WIDGET_MARKER + JSON]),解析后为信封(tab-bar 双段 + meta)", async () => {
|
|
159
221
|
const { tool } = setup();
|
|
160
222
|
const { ctx, setWidget } = makeRpcCtx();
|
|
161
223
|
await tool.execute(
|
|
@@ -169,22 +231,48 @@ describe("refreshDisplay — GUI widget 推送(setup 传真实实现)", () =
|
|
|
169
231
|
const [key, value] = setWidget.mock.calls[0]!;
|
|
170
232
|
expect(key).toBe("todo");
|
|
171
233
|
expect(value).toHaveLength(1);
|
|
172
|
-
const encoded = value![0]!;
|
|
173
234
|
// marker 前缀用协议常量断言(不手写编码)
|
|
174
|
-
expect(
|
|
175
|
-
const parsed =
|
|
176
|
-
|
|
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 宿主元数据)
|
|
235
|
+
expect(value![0]!.startsWith(GUI_WIDGET_MARKER)).toBe(true);
|
|
236
|
+
const parsed = lastEnvelope(setWidget);
|
|
237
|
+
// wire:GuiRenderResult 信封(component + meta 宿主元数据)
|
|
181
238
|
expect(parsed.v).toBe(1);
|
|
182
|
-
expect(parsed.component.type).toBe("
|
|
183
|
-
|
|
184
|
-
expect(parsed.component.props.
|
|
185
|
-
expect(parsed.component.props.
|
|
186
|
-
|
|
187
|
-
expect(
|
|
239
|
+
expect(parsed.component.type).toBe("tab-bar");
|
|
240
|
+
// 两段与 tabs 等长(长度不等宿主即忽略 sections 退化为纯展示)
|
|
241
|
+
expect(parsed.component.props.tabs.map((t) => t.label)).toEqual(["待办 2", "已完成 0"]);
|
|
242
|
+
expect(parsed.component.props.sections).toHaveLength(2);
|
|
243
|
+
const openSection = parsed.component.props.sections[0]!;
|
|
244
|
+
expect(openSection).toHaveLength(1);
|
|
245
|
+
expect(openSection[0]!.type).toBe("list-tree");
|
|
246
|
+
expect(openSection[0]!.props.numbered).toBe(true);
|
|
247
|
+
expect(openSection[0]!.props.items.map((i) => i.label)).toEqual(["task A", "task B"]);
|
|
248
|
+
expect(parsed.meta).toMatchObject({
|
|
249
|
+
title: "Todo",
|
|
250
|
+
icon: "list-checks",
|
|
251
|
+
badge: "2",
|
|
252
|
+
progress: { current: 0, total: 2 },
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("G-4: rpc + update(completed) → 信封待办段剔除该项、已完成段含该项(badge 同步 -1)", async () => {
|
|
257
|
+
const { tool } = setup();
|
|
258
|
+
const { ctx, setWidget } = makeRpcCtx();
|
|
259
|
+
await tool.execute("id", { action: "add", texts: ["task A", "task B"] }, undefined, undefined, ctx);
|
|
260
|
+
await tool.execute(
|
|
261
|
+
"id",
|
|
262
|
+
{ action: "update", updates: [{ id: 1, status: "completed" }] },
|
|
263
|
+
undefined,
|
|
264
|
+
undefined,
|
|
265
|
+
ctx,
|
|
266
|
+
);
|
|
267
|
+
const parsed = lastEnvelope(setWidget);
|
|
268
|
+
expect(parsed.component.props.tabs.map((t) => t.label)).toEqual(["待办 1", "已完成 1"]);
|
|
269
|
+
expect(parsed.component.props.sections[0]![0]!.props.items.map((i) => i.label)).toEqual([
|
|
270
|
+
"task B",
|
|
271
|
+
]);
|
|
272
|
+
expect(parsed.component.props.sections[1]![0]!.props.items).toEqual([
|
|
273
|
+
{ label: "task A", status: "done", depth: 0 },
|
|
274
|
+
]);
|
|
275
|
+
expect(parsed.meta).toMatchObject({ badge: "1", progress: { current: 1, total: 2 } });
|
|
188
276
|
});
|
|
189
277
|
|
|
190
278
|
it("G-2: rpc + delete 清空列表 → setWidget 收到 ('todo', undefined)(清除语义)", async () => {
|
package/src/handlers.ts
CHANGED
|
@@ -75,18 +75,27 @@ export function reconstructState(state: TodoSessionState, ctx: ExtensionContext)
|
|
|
75
75
|
|
|
76
76
|
const details = msg.details as TodoDetails | undefined;
|
|
77
77
|
if (details?.todos && Array.isArray(details.todos)) {
|
|
78
|
-
// 脏数据降级:单条迁移失败(null/primitive
|
|
78
|
+
// 脏数据降级:单条迁移失败(null/primitive/脏 id/脏 text)跳过该条;跳过清单
|
|
79
|
+
// 汇总一次 warn(不逐条刷屏),全部失败则忽略整个快照,不中断回放
|
|
79
80
|
const migrated: TodoDetails["todos"] = [];
|
|
81
|
+
const skippedErrors: string[] = [];
|
|
80
82
|
for (const t of details.todos) {
|
|
81
83
|
try {
|
|
82
84
|
migrated.push(migrateTodo(t));
|
|
83
85
|
} catch (e) {
|
|
84
|
-
|
|
85
|
-
logger.debug("reconstructState: skipping dirty todo entry", { error: String(e) });
|
|
86
|
+
skippedErrors.push(String(e));
|
|
86
87
|
}
|
|
87
88
|
}
|
|
89
|
+
if (skippedErrors.length > 0) {
|
|
90
|
+
logger.warn("reconstructState: skipped dirty todo entries", {
|
|
91
|
+
skipped: skippedErrors.length,
|
|
92
|
+
total: details.todos.length,
|
|
93
|
+
errors: skippedErrors,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
88
96
|
if (migrated.length > 0) {
|
|
89
97
|
state.todos = migrated;
|
|
98
|
+
// 脏 id 已在 migrateTodo 被拒——Math.max 只见存活条目,不产 NaN
|
|
90
99
|
state.nextId = details.nextId ?? Math.max(...migrated.map((t) => t.id)) + 1;
|
|
91
100
|
}
|
|
92
101
|
}
|
package/src/index.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
25
|
-
import { setWidgetDual, type GuiContext } from "@
|
|
25
|
+
import { setWidgetDual, type GuiContext } from "@zhushanwen/extension-protocol";
|
|
26
26
|
|
|
27
27
|
import { registerTodosCommand } from "./commands";
|
|
28
28
|
import { registerTodoEventHandlers, type RefreshDisplayFn } from "./handlers";
|
package/src/model.ts
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
|
+
type GuiComponent,
|
|
7
8
|
type GuiRenderResult,
|
|
8
9
|
guiComponent,
|
|
9
10
|
guiResult,
|
|
10
11
|
type TreeItem,
|
|
11
12
|
type WidgetMeta,
|
|
12
|
-
} from "@
|
|
13
|
+
} from "@zhushanwen/extension-protocol";
|
|
13
14
|
|
|
14
15
|
// ── 数据模型 ─────────────────────────────────────────
|
|
15
16
|
|
|
@@ -27,10 +28,39 @@ export interface TodoDetails {
|
|
|
27
28
|
|
|
28
29
|
export const VALID_STATUSES = ["pending", "in_progress", "completed"] as const;
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
/** 合法状态三态的推导类型(导出供包内其余模块命名收窄后的类型)。 */
|
|
32
|
+
export type ValidStatus = (typeof VALID_STATUSES)[number];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* status 合法性判据(type guard):migrate 迁移映射 / tool 单条 update / model 批量
|
|
36
|
+
* update 三处共享的同一校验原语——判定规则单点,错误文案由各调用方按面向对象
|
|
37
|
+
* (迁移降级 / LLM 单条引导 / 批量 id 定位)自行编排,展示层差异不属规则差异。
|
|
38
|
+
*/
|
|
39
|
+
export function isValidTodoStatus(status: string): status is ValidStatus {
|
|
40
|
+
return (VALID_STATUSES as readonly string[]).includes(status);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* update text 有效性判据(CT5):trim 后空串 = 非法(不只判 ===),tool 单条与
|
|
45
|
+
* model 批量两条 update 路径共享。
|
|
46
|
+
*/
|
|
47
|
+
export function isBlankUpdateText(text: string): boolean {
|
|
48
|
+
return text.trim().length === 0;
|
|
49
|
+
}
|
|
31
50
|
|
|
32
51
|
// ── 迁移/兼容 ───────────────────────────────────────
|
|
33
52
|
|
|
53
|
+
/**
|
|
54
|
+
* 历史状态 → 现态映射(旧格式一次性降级)。
|
|
55
|
+
*
|
|
56
|
+
* 三态化:cancelled → completed 不丢数据,且解除 every(completed) 死锁。
|
|
57
|
+
*/
|
|
58
|
+
const LEGACY_STATUS: Record<string, ValidStatus> = {
|
|
59
|
+
verifying: "in_progress",
|
|
60
|
+
failed: "pending",
|
|
61
|
+
cancelled: "completed",
|
|
62
|
+
};
|
|
63
|
+
|
|
34
64
|
/** 旧格式迁移:verifying → in_progress,failed → pending,cancelled → completed(历史三态化降级),done:boolean → status */
|
|
35
65
|
export function migrateTodo(raw: unknown): Todo {
|
|
36
66
|
// raw 是任意旧格式数据(兼容 done:boolean 等历史结构),以 Record 方式安全访问字段
|
|
@@ -41,36 +71,48 @@ export function migrateTodo(raw: unknown): Todo {
|
|
|
41
71
|
);
|
|
42
72
|
}
|
|
43
73
|
const record = raw as Record<string, unknown>;
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
VALID_STATUSES.includes(record.status as ValidStatus);
|
|
74
|
+
const rawStatusField = record.status;
|
|
75
|
+
const hasValidStatus = typeof rawStatusField === "string" && isValidTodoStatus(rawStatusField);
|
|
47
76
|
|
|
48
77
|
let status: ValidStatus;
|
|
49
78
|
if (hasValidStatus) {
|
|
50
|
-
status =
|
|
79
|
+
status = rawStatusField;
|
|
51
80
|
} else {
|
|
52
81
|
// 极旧格式 done: boolean
|
|
53
82
|
const done = typeof record.done === "boolean" ? record.done : undefined;
|
|
54
83
|
status = done === true ? "completed" : "pending";
|
|
55
84
|
}
|
|
56
85
|
|
|
57
|
-
//
|
|
58
|
-
const rawStatus = record.status
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
86
|
+
// 历史状态映射(查表单点:三条互斥 if → 一张表,不再裸 cast)
|
|
87
|
+
const rawStatus = typeof record.status === "string" ? record.status : undefined;
|
|
88
|
+
const legacyStatus = rawStatus ? LEGACY_STATUS[rawStatus] : undefined;
|
|
89
|
+
if (legacyStatus) status = legacyStatus;
|
|
90
|
+
|
|
91
|
+
// id/text 契约校验:脏 id(非 number / NaN)会在 reconstructState 的 Math.max 推导
|
|
92
|
+
// nextId 时产出 NaN 毒化后续 add/update/delete 锚点,脏 text 破坏渲染与 add 的 trim
|
|
93
|
+
// 契约——按「脏数据明确报错 → 调用方单条跳过」契约,与上方 null/primitive 守卫同型
|
|
94
|
+
// throw TypeError(调用方 reconstructState 收集降级)。
|
|
95
|
+
const id = record.id;
|
|
96
|
+
if (typeof id !== "number" || Number.isNaN(id)) {
|
|
97
|
+
throw new TypeError(
|
|
98
|
+
`migrateTodo: invalid id (expected number, got ${typeof id}${Number.isNaN(id) ? " NaN" : ""})`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (typeof record.text !== "string") {
|
|
102
|
+
throw new TypeError(`migrateTodo: invalid text (expected string, got ${typeof record.text})`);
|
|
103
|
+
}
|
|
63
104
|
|
|
64
105
|
return {
|
|
65
|
-
id
|
|
66
|
-
text: record.text
|
|
106
|
+
id,
|
|
107
|
+
text: record.text,
|
|
67
108
|
status,
|
|
68
109
|
};
|
|
69
110
|
}
|
|
70
111
|
|
|
71
112
|
// ── GUI 渲染辅助 ─────────────────────────────────────
|
|
72
113
|
|
|
73
|
-
/** completed 计数单一来源:
|
|
114
|
+
/** completed 计数单一来源:renderStatusText / renderWidgetLines / component 三个消费点共用口径
|
|
115
|
+
* (buildGui 的 tab 计数与段内容改由 openTodos/doneTodos 同源复用,不再经本函数)。 */
|
|
74
116
|
export function todoProgress(todos: Todo[]): { completed: number; total: number } {
|
|
75
117
|
return {
|
|
76
118
|
completed: todos.filter((t) => t.status === "completed").length,
|
|
@@ -78,14 +120,37 @@ export function todoProgress(todos: Todo[]): { completed: number; total: number
|
|
|
78
120
|
};
|
|
79
121
|
}
|
|
80
122
|
|
|
123
|
+
/** 单段清单:两段共用同一构造(仅过滤条件不同),保住行首序号范式与 status 映射的单一口径。 */
|
|
124
|
+
function todoListTree(todos: Todo[]): GuiComponent {
|
|
125
|
+
const items: TreeItem[] = todos.map((t) => ({
|
|
126
|
+
label: t.text,
|
|
127
|
+
status:
|
|
128
|
+
t.status === "in_progress"
|
|
129
|
+
? "running"
|
|
130
|
+
: t.status === "completed"
|
|
131
|
+
? "done"
|
|
132
|
+
: undefined, // pending 无 status
|
|
133
|
+
depth: 0,
|
|
134
|
+
}));
|
|
135
|
+
return guiComponent("list-tree", { numbered: true, items });
|
|
136
|
+
}
|
|
137
|
+
|
|
81
138
|
/**
|
|
82
|
-
* 把 todos 组装为 GuiRenderResult(
|
|
139
|
+
* 把 todos 组装为 GuiRenderResult(meta head + tab-bar 双段架构)。
|
|
83
140
|
*
|
|
84
|
-
* - meta
|
|
85
|
-
* 替代 body 内 progress-bar(精简 body),全完成 status=done
|
|
86
|
-
*
|
|
141
|
+
* - meta(标题/状态/进度/icon/badge)由宿主壳层(widget 面板 head + 托盘)唯一渲染:
|
|
142
|
+
* 进度计数 "N/M" + mini bar 替代 body 内 progress-bar(精简 body),全完成 status=done
|
|
143
|
+
* (head 绿点 + bar 变绿);icon 点名 lucide key 'list-checks'(与宿主内置 widgetKey
|
|
144
|
+
* 映射 'todo'→ListChecks 同图标)——宿主映射只是兜底,显式声明后宿主调整自己的映射表
|
|
145
|
+
* 也不会换掉 todo 的图标;badge = 未完成条数(托盘 `[☑ 2]`,宿主超长 truncate)。
|
|
146
|
+
* - 内容根 = tab-bar 双段(tabs/sections 等长 2 段,宿主本地切换、不回传 extension):
|
|
147
|
+
* 待办段 = 未完成项(pending + in_progress),已完成段 = completed 项——两段互斥且覆盖
|
|
148
|
+
* 全量,tab 标签计数与段内容同源(否则「待办 N」与实际行数成双口径);分段而非堆叠,
|
|
149
|
+
* 使已完成项不占待办首屏(面板形态裁决,设计 D5)。
|
|
150
|
+
* - 两段同构 numbered list-tree:行首弱化序号(编辑器行号范式,ListTree 渲染),
|
|
87
151
|
* id 不再烧进 label——update/delete 锚点由模型经 list action 获取,用户引用
|
|
88
152
|
* 「第 N 项」即可;状态由行尾圆点单一表达(无 icon,v6 单一信息源裁决)。
|
|
153
|
+
* 空段 = items 为空的 list-tree(沿用既有空态语义,不加装饰性占位)。
|
|
89
154
|
*
|
|
90
155
|
* status → 圆点映射:
|
|
91
156
|
* pending → 无圆点(常态归零)
|
|
@@ -93,29 +158,37 @@ export function todoProgress(todos: Todo[]): { completed: number; total: number
|
|
|
93
158
|
* completed → done(success + label 弱化)
|
|
94
159
|
*/
|
|
95
160
|
export function buildGui(todos: Todo[]): GuiRenderResult {
|
|
96
|
-
|
|
97
|
-
|
|
161
|
+
// 先划分段数组,再复用同一批数组产出标签/内容/badge/inProgress——避免
|
|
162
|
+
// 「total - completed」与「两次 filter」两个口径各自推导同一事实。
|
|
163
|
+
const openTodos = todos.filter((t) => t.status !== "completed");
|
|
164
|
+
const doneTodos = todos.filter((t) => t.status === "completed");
|
|
165
|
+
const total = todos.length;
|
|
166
|
+
const completed = doneTodos.length;
|
|
167
|
+
const open = openTodos.length;
|
|
168
|
+
const inProgress = openTodos.filter((t) => t.status === "in_progress").length;
|
|
98
169
|
|
|
99
170
|
const status: WidgetMeta["status"] =
|
|
100
171
|
total > 0 && completed === total ? "done" : inProgress > 0 ? "running" : "idle";
|
|
101
172
|
|
|
102
|
-
const items: TreeItem[] = todos.map((t) => ({
|
|
103
|
-
label: t.text,
|
|
104
|
-
status:
|
|
105
|
-
t.status === "in_progress"
|
|
106
|
-
? "running"
|
|
107
|
-
: t.status === "completed"
|
|
108
|
-
? "done"
|
|
109
|
-
: undefined, // pending 无 status
|
|
110
|
-
depth: 0,
|
|
111
|
-
}));
|
|
112
|
-
|
|
113
173
|
return guiResult(
|
|
114
|
-
guiComponent("
|
|
174
|
+
guiComponent("tab-bar", {
|
|
175
|
+
tabs: [
|
|
176
|
+
// 首段带 active:容器化宿主据此建立初始 tab(此后本地切换不被推送重置)
|
|
177
|
+
{ label: `待办 ${open}`, active: true },
|
|
178
|
+
{ label: `已完成 ${completed}` },
|
|
179
|
+
],
|
|
180
|
+
// 段 = 子树(组件数组,与 tabs 等长一一对应;宿主渲染 active 段的全部子组件)
|
|
181
|
+
sections: [
|
|
182
|
+
[todoListTree(openTodos)],
|
|
183
|
+
[todoListTree(doneTodos)],
|
|
184
|
+
],
|
|
185
|
+
}),
|
|
115
186
|
{
|
|
116
187
|
title: "Todo",
|
|
117
188
|
status,
|
|
118
189
|
progress: total > 0 ? { current: completed, total } : undefined,
|
|
190
|
+
icon: "list-checks",
|
|
191
|
+
badge: String(open),
|
|
119
192
|
},
|
|
120
193
|
);
|
|
121
194
|
}
|
|
@@ -210,7 +283,7 @@ export function updateTodos(
|
|
|
210
283
|
): UpdateResult {
|
|
211
284
|
// text 校验统一(CT5):text 存在则 trim,空串 throw(不静默跳过)
|
|
212
285
|
for (const u of updates) {
|
|
213
|
-
if (u.text !== undefined && u.text
|
|
286
|
+
if (u.text !== undefined && isBlankUpdateText(u.text)) {
|
|
214
287
|
throw new Error(`update item id ${u.id}: text cannot be empty or whitespace-only`);
|
|
215
288
|
}
|
|
216
289
|
}
|
|
@@ -219,26 +292,30 @@ export function updateTodos(
|
|
|
219
292
|
if (new Set(ids).size !== ids.length) {
|
|
220
293
|
throw new Error("duplicate ids in updates");
|
|
221
294
|
}
|
|
295
|
+
// 校验遍同时产出 patch(非法输入在任何突变前 throw,文案不变);突变遍直接消
|
|
296
|
+
// patch,不再重复判定同一条件(旧实现第二遍 isValidTodoStatus 恒真)
|
|
297
|
+
const patches = new Map<number, { status?: Todo["status"]; text?: string }>();
|
|
222
298
|
for (const u of updates) {
|
|
223
|
-
|
|
224
|
-
if (!todo) {
|
|
299
|
+
if (!currentTodos.some((t) => t.id === u.id)) {
|
|
225
300
|
throw new Error(`Todo #${u.id} not found`);
|
|
226
301
|
}
|
|
227
302
|
if (!u.status && !u.text) {
|
|
228
303
|
throw new Error(`update item for id ${u.id} has neither status nor text`);
|
|
229
304
|
}
|
|
230
|
-
|
|
231
|
-
|
|
305
|
+
const patch: { status?: Todo["status"]; text?: string } = {};
|
|
306
|
+
if (u.status) {
|
|
307
|
+
if (!isValidTodoStatus(u.status)) {
|
|
308
|
+
throw new Error(`invalid status '${u.status}' for update item id ${u.id}`);
|
|
309
|
+
}
|
|
310
|
+
patch.status = u.status;
|
|
232
311
|
}
|
|
312
|
+
if (u.text !== undefined) patch.text = u.text.trim();
|
|
313
|
+
patches.set(u.id, patch);
|
|
233
314
|
}
|
|
234
315
|
|
|
235
316
|
const updated = currentTodos.map((t) => {
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
const patch: Partial<Todo> = {};
|
|
239
|
-
if (u.status) patch.status = u.status as Todo["status"];
|
|
240
|
-
if (u.text !== undefined) patch.text = u.text.trim();
|
|
241
|
-
return { ...t, ...patch };
|
|
317
|
+
const patch = patches.get(t.id);
|
|
318
|
+
return patch ? { ...t, ...patch } : t;
|
|
242
319
|
});
|
|
243
320
|
return {
|
|
244
321
|
updatedTodos: updated,
|
package/src/render.ts
CHANGED
|
@@ -154,7 +154,7 @@ function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, the
|
|
|
154
154
|
// ── Tool renderResult handler ────────────────────────
|
|
155
155
|
|
|
156
156
|
import { Text } from "@earendil-works/pi-tui";
|
|
157
|
-
import { firstContentText } from "@
|
|
157
|
+
import { firstContentText } from "@zhushanwen/extension-protocol";
|
|
158
158
|
|
|
159
159
|
export function renderTodoResult(result: unknown, options: { expanded: boolean }, theme: Theme): Text {
|
|
160
160
|
const r = result as { content: Array<{ type: string; text?: string }>; details?: unknown };
|
package/src/tool.ts
CHANGED
|
@@ -17,8 +17,10 @@ import { type Static, Type } from "typebox";
|
|
|
17
17
|
import {
|
|
18
18
|
addTodos,
|
|
19
19
|
formatTodoList,
|
|
20
|
-
|
|
20
|
+
isBlankUpdateText,
|
|
21
|
+
isValidTodoStatus,
|
|
21
22
|
type TodoDetails,
|
|
23
|
+
type ValidStatus,
|
|
22
24
|
updateTodos,
|
|
23
25
|
VALID_STATUSES,
|
|
24
26
|
} from "./model";
|
|
@@ -122,20 +124,23 @@ export function handleSingleUpdate(state: TodoSessionState, params: TodoParamsT)
|
|
|
122
124
|
throw new Error(
|
|
123
125
|
'update requires at least status or text parameter. Correct: {"action":"update","id":<n>,"status":"in_progress"}',
|
|
124
126
|
);
|
|
125
|
-
// text
|
|
126
|
-
|
|
127
|
+
// text/status 校验判据与 model.updateTodos 批量路径共享同一原语(CT5);文案
|
|
128
|
+
// 保持单条引导形态(LLM 单条修正提示),展示层差异不属规则差异。校验遍同时产出
|
|
129
|
+
// 收窄结果 nextStatus——非法输入仍在任何突变前 throw,突变遍不再重复判定同一条件
|
|
130
|
+
if (params.text !== undefined && isBlankUpdateText(params.text))
|
|
127
131
|
throw new Error("text cannot be empty or whitespace-only");
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
!
|
|
131
|
-
|
|
132
|
-
|
|
132
|
+
let nextStatus: ValidStatus | undefined;
|
|
133
|
+
if (params.status !== undefined) {
|
|
134
|
+
if (!isValidTodoStatus(params.status)) {
|
|
135
|
+
throw new Error(`status only accepts ${VALID_STATUSES.join(" / ")}`);
|
|
136
|
+
}
|
|
137
|
+
nextStatus = params.status;
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
const todo = state.todos.find((t) => t.id === params.id);
|
|
136
141
|
if (!todo) throw new Error(`Todo #${params.id} not found`);
|
|
137
142
|
|
|
138
|
-
if (
|
|
143
|
+
if (nextStatus !== undefined) todo.status = nextStatus;
|
|
139
144
|
if (params.text !== undefined) todo.text = params.text.trim();
|
|
140
145
|
|
|
141
146
|
const parts: string[] = [`Updated todo #${todo.id}`];
|
|
@@ -227,7 +232,8 @@ function executeTodoAction(
|
|
|
227
232
|
nextId: state.nextId,
|
|
228
233
|
};
|
|
229
234
|
// 状态展示不再进 tool result(GUI 渲染字段已移除):GUI 走 refreshDisplay 的
|
|
230
|
-
//
|
|
235
|
+
// setWidgetDual 推送(GUI 臂 = guiSetWidget/marker 通道,低层原语不单独调用;渲染终点 =
|
|
236
|
+
// composer 任务托盘的协议 widget 区),TUI 走原生文本渲染(contentText 已在 content 中)。
|
|
231
237
|
return {
|
|
232
238
|
content: [{ type: "text" as const, text: contentText }],
|
|
233
239
|
details,
|
|
@@ -275,14 +281,24 @@ export function registerTodoTool(
|
|
|
275
281
|
},
|
|
276
282
|
|
|
277
283
|
renderCall(args: Record<string, unknown>, theme: Theme, _context?: unknown) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
284
|
+
// TUI 渲染先于 schema 校验(args 是未经 schema 收窄的原始形态):非对象整体
|
|
285
|
+
// 走安全占位,字段逐个 typeof/Array.isArray 守卫替代裸断言——非法形态不抛错
|
|
286
|
+
if (typeof args !== "object" || args === null) {
|
|
287
|
+
return new Text(
|
|
288
|
+
theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", "(invalid args)"),
|
|
289
|
+
0,
|
|
290
|
+
0,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
const action = typeof args.action === "string" ? args.action : "";
|
|
294
|
+
const texts = Array.isArray(args.texts) ? args.texts : undefined;
|
|
295
|
+
const ids = Array.isArray(args.ids) ? args.ids : undefined;
|
|
296
|
+
let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", action);
|
|
281
297
|
if (texts && texts.length > 0) text += ` ${theme.fg("dim", `(${texts.length} items)`)}`;
|
|
282
298
|
if (ids && ids.length > 0) text += ` ${theme.fg("accent", `#${ids.join(", #")}`)}`;
|
|
283
299
|
if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
|
|
284
300
|
if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
|
|
285
|
-
if (args.status) text += ` ${theme.fg("warning", args.status
|
|
301
|
+
if (typeof args.status === "string" && args.status) text += ` ${theme.fg("warning", args.status)}`;
|
|
286
302
|
return new Text(text, 0, 0);
|
|
287
303
|
},
|
|
288
304
|
|