@zhushanwen/pi-todo 0.9.1 → 0.9.3
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 +1 -1
- package/package.json +3 -3
- package/src/__tests__/render.test.ts +52 -1
- package/src/__tests__/steer.test.ts +7 -7
- package/src/__tests__/todo.test.ts +15 -17
- package/src/__tests__/tool-rpc.test.ts +2 -2
- package/src/component.ts +2 -3
- package/src/handlers.ts +10 -11
- package/src/index.ts +15 -19
- package/src/model.ts +24 -28
- package/src/render.ts +25 -36
- package/src/tool.ts +10 -11
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ pi install npm:@zhushanwen/pi-todo
|
|
|
37
37
|
|
|
38
38
|
### 错误处理约定
|
|
39
39
|
|
|
40
|
-
handler 失败**直接 `throw new Error()`**,不返回错误成功模式(见
|
|
40
|
+
handler 失败**直接 `throw new Error()`**,不返回错误成功模式(见 docs/extensions/extension-conventions.md「Tool 设计」)。常见错误:
|
|
41
41
|
|
|
42
42
|
| 触发 | 错误信息 |
|
|
43
43
|
|------|---------|
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-todo",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
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",
|
|
7
|
-
"
|
|
7
|
+
"taiji": {
|
|
8
8
|
"role": "universal"
|
|
9
9
|
},
|
|
10
10
|
"pi": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"vitest": "^4.1.8"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@
|
|
32
|
+
"@taiji/extension-protocol": "0.11.0",
|
|
33
33
|
"@zhushanwen/pi-extension-logger": "0.6.0"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
3
|
import { describe, expect, it } from "vitest";
|
|
3
4
|
|
|
4
5
|
import type { Todo, TodoDetails } from "../model";
|
|
5
|
-
import { renderTodoResult } from "../render";
|
|
6
|
+
import { renderDualColumn, renderTodoResult } from "../render";
|
|
6
7
|
|
|
7
8
|
// ── mock theme(fg 直通,便于断言纯文本)────────────
|
|
8
9
|
|
|
@@ -100,3 +101,53 @@ describe("renderTodoResult", () => {
|
|
|
100
101
|
expect(renderedText(result)).toContain("Done");
|
|
101
102
|
});
|
|
102
103
|
});
|
|
104
|
+
|
|
105
|
+
// ── renderDualColumn 列宽行为锁定 ──────────────────────
|
|
106
|
+
// 锁定 D14 替换前的列宽补齐/截断可见输出(ext-simplify 裁决零行为变化):
|
|
107
|
+
// 截断形态是「前缀 + 6 个点」(内层省略号 + 外层省略号的旧组合)——这是锁定旧行为,不是认可该形态。
|
|
108
|
+
|
|
109
|
+
describe("renderDualColumn column fitting", () => {
|
|
110
|
+
const longTodos: Todo[] = [
|
|
111
|
+
{ id: 1, text: "x".repeat(30), status: "pending" },
|
|
112
|
+
{ id: 2, text: "y".repeat(30), status: "pending" },
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
it("超宽列文本截断为 前缀+6点,两列均恰好列宽", () => {
|
|
116
|
+
const termWidth = 27; // colWidth = floor((27 - 2 - 3) / 2) = 11
|
|
117
|
+
const lines = renderDualColumn(longTodos, mockTheme, termWidth, " ");
|
|
118
|
+
expect(lines).toHaveLength(1);
|
|
119
|
+
const row = stripTerminalSequences(lines[0]);
|
|
120
|
+
expect(visibleWidth(row)).toBe(11 + 3 + 11);
|
|
121
|
+
const divIdx = row.indexOf("│");
|
|
122
|
+
// divider(" │ ")的前后空格不属于列内容,trim 后再断言
|
|
123
|
+
const left = row.slice(0, divIdx).trimEnd();
|
|
124
|
+
const right = row.slice(divIdx + 1).trimStart();
|
|
125
|
+
// 两列均以 6 个点收尾(前缀+6点旧形态)
|
|
126
|
+
expect(left.endsWith("......")).toBe(true);
|
|
127
|
+
expect(right.endsWith("......")).toBe(true);
|
|
128
|
+
// 点前的前缀可见宽恰为 colWidth - 6
|
|
129
|
+
expect(visibleWidth(left.slice(0, left.length - 6))).toBe(11 - 6);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("列内文本未超宽时不产生省略号,补齐到列宽", () => {
|
|
133
|
+
const shortTodos: Todo[] = [
|
|
134
|
+
{ id: 1, text: "ab", status: "pending" },
|
|
135
|
+
{ id: 2, text: "cd", status: "pending" },
|
|
136
|
+
];
|
|
137
|
+
const lines = renderDualColumn(shortTodos, mockTheme, 27, " ");
|
|
138
|
+
const row = stripTerminalSequences(lines[0]);
|
|
139
|
+
expect(visibleWidth(row)).toBe(11 + 3 + 11);
|
|
140
|
+
expect(row).not.toContain(".");
|
|
141
|
+
expect(row).toContain("#1 ab");
|
|
142
|
+
expect(row).toContain("#2 cd");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("colWidth <= 3 时超宽列退化为省略号截片", () => {
|
|
146
|
+
const termWidth = 11; // colWidth = floor((11 - 2 - 3) / 2) = 3
|
|
147
|
+
const lines = renderDualColumn(longTodos, mockTheme, termWidth, " ");
|
|
148
|
+
const row = stripTerminalSequences(lines[0]);
|
|
149
|
+
expect(visibleWidth(row)).toBe(3 + 3 + 3);
|
|
150
|
+
const divIdx = row.indexOf("│");
|
|
151
|
+
expect(row.slice(0, divIdx).trimEnd()).toBe("...");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -68,13 +68,13 @@ describe("handleCompletionSteer", () => {
|
|
|
68
68
|
describe("handleAutoClear", () => {
|
|
69
69
|
it("does not handle when not all completed, and resets anchor", () => {
|
|
70
70
|
const s = makeState([{ id: 1, text: "a", status: "pending" }], { allCompletedAtCount: 3 });
|
|
71
|
-
expect(handleAutoClear(s)).
|
|
71
|
+
expect(handleAutoClear(s)).toBe(false);
|
|
72
72
|
expect(s.allCompletedAtCount).toBeNull();
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
it("anchors on first all-completed round without clearing", () => {
|
|
76
76
|
const s = makeState([{ id: 1, text: "a", status: "completed" }], { userMessageCount: 5 });
|
|
77
|
-
expect(handleAutoClear(s)).
|
|
77
|
+
expect(handleAutoClear(s)).toBe(false);
|
|
78
78
|
expect(s.allCompletedAtCount).toBe(5);
|
|
79
79
|
expect(s.todos).toHaveLength(1);
|
|
80
80
|
});
|
|
@@ -83,14 +83,14 @@ describe("handleAutoClear", () => {
|
|
|
83
83
|
const s = makeState([{ id: 1, text: "a", status: "completed" }], {
|
|
84
84
|
userMessageCount: 5, allCompletedAtCount: 4,
|
|
85
85
|
});
|
|
86
|
-
expect(handleAutoClear(s)).
|
|
86
|
+
expect(handleAutoClear(s)).toBe(false);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
89
|
it("clears and resets flags after delay elapses", () => {
|
|
90
90
|
const s = makeState([{ id: 1, text: "a", status: "completed" }], {
|
|
91
91
|
userMessageCount: 6, allCompletedAtCount: 4, completionSteered: true,
|
|
92
92
|
});
|
|
93
|
-
expect(handleAutoClear(s)).
|
|
93
|
+
expect(handleAutoClear(s)).toBe(true);
|
|
94
94
|
expect(s.todos).toEqual([]);
|
|
95
95
|
expect(s.nextId).toBe(1);
|
|
96
96
|
expect(s.allCompletedAtCount).toBeNull();
|
|
@@ -229,10 +229,10 @@ describe("reconstructState", () => {
|
|
|
229
229
|
describe("agent_end short-circuit order", () => {
|
|
230
230
|
it("completion steer fires before auto-clear (completion does not short-circuit)", () => {
|
|
231
231
|
const s = makeState([{ id: 1, text: "a", status: "completed" }], { userMessageCount: 5 });
|
|
232
|
-
// 模拟 agent_end: handleCompletionSteer(不短路) → handleAutoClear
|
|
232
|
+
// 模拟 agent_end: handleCompletionSteer(不短路) → handleAutoClear
|
|
233
233
|
expect(handleCompletionSteer(s)).toBe(true);
|
|
234
234
|
expect(s.pendingSteerMessage).toContain("交付质量");
|
|
235
|
-
expect(handleAutoClear(s)).
|
|
235
|
+
expect(handleAutoClear(s)).toBe(false);
|
|
236
236
|
expect(s.allCompletedAtCount).toBe(5);
|
|
237
237
|
});
|
|
238
238
|
|
|
@@ -243,7 +243,7 @@ describe("agent_end short-circuit order", () => {
|
|
|
243
243
|
completionSteered: true, pendingSteerMessage: "<queued>",
|
|
244
244
|
});
|
|
245
245
|
expect(handleCompletionSteer(s)).toBe(false); // 已 steered,不重复
|
|
246
|
-
expect(handleAutoClear(s)
|
|
246
|
+
expect(handleAutoClear(s)).toBe(true);
|
|
247
247
|
expect(s.todos).toEqual([]);
|
|
248
248
|
// pendingSteerMessage 仍保留,由下一 turn before_agent_start 消费(此时 todos 已空)
|
|
249
249
|
expect(s.pendingSteerMessage).toBe("<queued>");
|
|
@@ -198,7 +198,6 @@ describe("todo update batch", () => {
|
|
|
198
198
|
{ id: 3, status: "completed", text: "C done" },
|
|
199
199
|
]);
|
|
200
200
|
|
|
201
|
-
expect(result.error).toBeUndefined();
|
|
202
201
|
expect(result.updatedTodos).toHaveLength(3);
|
|
203
202
|
expect(result.updatedTodos[0].status).toBe("completed");
|
|
204
203
|
expect(result.updatedTodos[0].text).toBe("A");
|
|
@@ -211,7 +210,6 @@ describe("todo update batch", () => {
|
|
|
211
210
|
it("TC6: trims text on apply(批量路径)", () => {
|
|
212
211
|
const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
|
|
213
212
|
const result = updateTodos(todos, [{ id: 1, text: " B updated " }]);
|
|
214
|
-
expect(result.error).toBeUndefined();
|
|
215
213
|
expect(result.updatedTodos[0].text).toBe("B updated");
|
|
216
214
|
});
|
|
217
215
|
|
|
@@ -222,30 +220,33 @@ describe("todo update batch", () => {
|
|
|
222
220
|
|
|
223
221
|
it("should reject duplicate ids in updates[]", () => {
|
|
224
222
|
const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
223
|
+
expect(() =>
|
|
224
|
+
updateTodos(todos, [
|
|
225
|
+
{ id: 1, status: "completed" },
|
|
226
|
+
{ id: 1, status: "pending" },
|
|
227
|
+
]),
|
|
228
|
+
).toThrow("duplicate ids in updates");
|
|
231
229
|
});
|
|
232
230
|
|
|
233
231
|
it("should reject non-existent ids", () => {
|
|
234
232
|
const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
|
|
235
|
-
|
|
236
|
-
|
|
233
|
+
expect(() => updateTodos(todos, [{ id: 999, status: "pending" }])).toThrow(
|
|
234
|
+
"Todo #999 not found",
|
|
235
|
+
);
|
|
237
236
|
});
|
|
238
237
|
|
|
239
238
|
it("should reject updates[] item missing both status and text", () => {
|
|
240
239
|
const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
|
|
241
|
-
|
|
242
|
-
|
|
240
|
+
expect(() => updateTodos(todos, [{ id: 1 }])).toThrow(
|
|
241
|
+
"update item for id 1 has neither status nor text",
|
|
242
|
+
);
|
|
243
243
|
});
|
|
244
244
|
|
|
245
245
|
it("should reject invalid status values", () => {
|
|
246
246
|
const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
expect(() => updateTodos(todos, [{ id: 1, status: "banana" }])).toThrow(
|
|
248
|
+
"invalid status 'banana' for update item id 1",
|
|
249
|
+
);
|
|
249
250
|
});
|
|
250
251
|
});
|
|
251
252
|
|
|
@@ -312,14 +313,12 @@ describe("completed without interception", () => {
|
|
|
312
313
|
it("should allow in_progress → completed directly", () => {
|
|
313
314
|
const todos: Todo[] = [{ id: 1, text: "simple", status: "in_progress" }];
|
|
314
315
|
const result = updateTodos(todos, [{ id: 1, status: "completed" }]);
|
|
315
|
-
expect(result.error).toBeUndefined();
|
|
316
316
|
expect(result.updatedTodos[0].status).toBe("completed");
|
|
317
317
|
});
|
|
318
318
|
|
|
319
319
|
it("should allow pending → completed directly", () => {
|
|
320
320
|
const todos: Todo[] = [{ id: 1, text: "skip", status: "pending" }];
|
|
321
321
|
const result = updateTodos(todos, [{ id: 1, status: "completed" }]);
|
|
322
|
-
expect(result.error).toBeUndefined();
|
|
323
322
|
expect(result.updatedTodos[0].status).toBe("completed");
|
|
324
323
|
});
|
|
325
324
|
|
|
@@ -332,7 +331,6 @@ describe("completed without interception", () => {
|
|
|
332
331
|
{ id: 1, status: "completed" },
|
|
333
332
|
{ id: 2, status: "completed" },
|
|
334
333
|
]);
|
|
335
|
-
expect(result.error).toBeUndefined();
|
|
336
334
|
expect(result.updatedTodos[0].status).toBe("completed");
|
|
337
335
|
expect(result.updatedTodos[1].status).toBe("completed");
|
|
338
336
|
});
|
|
@@ -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 "@taiji/extension-protocol";
|
|
14
14
|
import { describe, expect, it, vi, type Mock } from "vitest";
|
|
15
15
|
|
|
16
16
|
import { makeRefreshDisplay } from "../index";
|
|
@@ -203,7 +203,7 @@ describe("refreshDisplay — GUI widget 推送(setup 传真实实现)", () =
|
|
|
203
203
|
const [, value] = setWidget.mock.calls[0]!;
|
|
204
204
|
expect(value!.length).toBeGreaterThan(0);
|
|
205
205
|
for (const line of value!) {
|
|
206
|
-
//
|
|
206
|
+
// setWidgetDual 模式分派生效(守卫单点在 protocol helper):TUI 行不含 GUI marker 编码
|
|
207
207
|
expect(line.startsWith(GUI_WIDGET_MARKER)).toBe(false);
|
|
208
208
|
}
|
|
209
209
|
});
|
package/src/component.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
7
7
|
|
|
8
|
-
import type
|
|
8
|
+
import { todoProgress, type Todo } from "./model";
|
|
9
9
|
import { FALLBACK_TERM_WIDTH, renderDualColumn } from "./render";
|
|
10
10
|
|
|
11
11
|
const HEADER_PREFIX_DASHES = 3;
|
|
@@ -50,8 +50,7 @@ export class TodoListComponent {
|
|
|
50
50
|
if (this.todos.length === 0) {
|
|
51
51
|
lines.push(truncateToWidth(`${indent}${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, termWidth));
|
|
52
52
|
} else {
|
|
53
|
-
const completed = this.todos
|
|
54
|
-
const total = this.todos.length;
|
|
53
|
+
const { completed, total } = todoProgress(this.todos);
|
|
55
54
|
lines.push(truncateToWidth(`${indent}${th.fg("muted", `${completed}/${total} completed`)}`, termWidth));
|
|
56
55
|
lines.push("");
|
|
57
56
|
|
package/src/handlers.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
migrateTodo,
|
|
11
11
|
type TodoDetails,
|
|
12
12
|
} from "./model";
|
|
13
|
+
import { renderStatusText } from "./render";
|
|
13
14
|
import type { TodoSessionState } from "./state";
|
|
14
15
|
|
|
15
16
|
const logger = getLogger("todo");
|
|
@@ -94,11 +95,12 @@ export function reconstructState(state: TodoSessionState, ctx: ExtensionContext)
|
|
|
94
95
|
|
|
95
96
|
// ── agent_end 子函数 ────────────────────────────────
|
|
96
97
|
|
|
97
|
-
|
|
98
|
+
/** 返回是否已清空(false 含三种形态:未全完成 / 本轮刚锚定 / 延迟未到——调用方均无需刷新) */
|
|
99
|
+
export function handleAutoClear(state: TodoSessionState): boolean {
|
|
98
100
|
const allCompleted = state.todos.every((t) => t.status === "completed");
|
|
99
101
|
if (!allCompleted) {
|
|
100
102
|
state.allCompletedAtCount = null;
|
|
101
|
-
return
|
|
103
|
+
return false;
|
|
102
104
|
}
|
|
103
105
|
if (state.allCompletedAtCount === null) {
|
|
104
106
|
state.allCompletedAtCount = state.userMessageCount;
|
|
@@ -108,9 +110,9 @@ export function handleAutoClear(state: TodoSessionState): { handled: boolean; cl
|
|
|
108
110
|
state.nextId = 1;
|
|
109
111
|
state.allCompletedAtCount = null;
|
|
110
112
|
state.completionSteered = false;
|
|
111
|
-
return
|
|
113
|
+
return true;
|
|
112
114
|
}
|
|
113
|
-
return
|
|
115
|
+
return false;
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
export function handleCompletionSteer(state: TodoSessionState): boolean {
|
|
@@ -147,7 +149,8 @@ export function registerTodoEventHandlers(
|
|
|
147
149
|
try {
|
|
148
150
|
const pendingTodos = state.todos.filter(isPending);
|
|
149
151
|
if (pendingTodos.length > 0) {
|
|
150
|
-
|
|
152
|
+
// 文案与 refreshDisplay 共用 renderStatusText(N/M 口径),避免同一 status 槽两种格式交替
|
|
153
|
+
ctx.ui.setStatus("todo", renderStatusText(state.todos, ctx.ui.theme));
|
|
151
154
|
}
|
|
152
155
|
// 优先级 1: agent_end 设置的延迟 steer
|
|
153
156
|
if (state.pendingSteerMessage) {
|
|
@@ -170,12 +173,8 @@ export function registerTodoEventHandlers(
|
|
|
170
173
|
// 全部 completed → 总检查 steer(仅一次)
|
|
171
174
|
handleCompletionSteer(state);
|
|
172
175
|
|
|
173
|
-
// auto-clear
|
|
174
|
-
|
|
175
|
-
if (ac.handled) {
|
|
176
|
-
if (ac.cleared) refreshDisplay(ctx);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
176
|
+
// auto-clear(全部完成后延迟清理;已清空才需要刷新显示)
|
|
177
|
+
if (handleAutoClear(state)) refreshDisplay(ctx);
|
|
179
178
|
} catch (e) {
|
|
180
179
|
// best-effort:agent_end 事件处理器出错不阻断会话主流程,仅记录调试日志
|
|
181
180
|
logger.debug("agent_end error", { error: String(e) });
|
package/src/index.ts
CHANGED
|
@@ -16,15 +16,16 @@
|
|
|
16
16
|
* - commands.ts: /todos 命令注册
|
|
17
17
|
* - index.ts(本文件): 工厂入口(创建 state + 注册 tool/command/event + makeRefreshDisplay)
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* 错误处理:包内单一 throw 协议——handler 与 model 层纯函数(addTodos / updateTodos)
|
|
20
|
+
* 校验失败均直接 throw(见 docs/extensions/extension-conventions.md「Tool 设计」),
|
|
21
|
+
* 不返回错误成功模式。
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
24
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
24
|
-
import {
|
|
25
|
+
import { setWidgetDual, type GuiContext } from "@taiji/extension-protocol";
|
|
25
26
|
|
|
26
27
|
import { registerTodosCommand } from "./commands";
|
|
27
|
-
import { registerTodoEventHandlers } from "./handlers";
|
|
28
|
+
import { registerTodoEventHandlers, type RefreshDisplayFn } from "./handlers";
|
|
28
29
|
import { buildGui } from "./model";
|
|
29
30
|
import { renderStatusText, renderWidgetLines } from "./render";
|
|
30
31
|
import { createTodoSessionState, type TodoSessionState } from "./state";
|
|
@@ -38,28 +39,23 @@ import { registerTodoTool } from "./tool";
|
|
|
38
39
|
* 类型断言根因:pi 的 ExtensionContext.ui.custom 是泛型方法(返回 Promise<T>),
|
|
39
40
|
* 与 GuiContext.ui.custom 的具体返回类型静态不兼容,传参需断言收窄;
|
|
40
41
|
* mode/hasUI/ui.setWidget 形状一致(ExtensionMode 与 GuiContext.mode union 完全
|
|
41
|
-
* 相同),单层直接断言可过 tsc(
|
|
42
|
-
*
|
|
42
|
+
* 相同),单层直接断言可过 tsc(setWidgetDual 只读 mode 与 ui.setWidget,
|
|
43
|
+
* 不读 custom)。
|
|
43
44
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* TUI 模式误调会把 marker 编码行推给原生 widget 造成乱码。
|
|
45
|
+
* 推送/清屏 × GUI/TUI 模式分派由 protocol setWidgetDual 单点内化
|
|
46
|
+
* (守卫单点化说明见 extension-protocol helpers.ts,本文件不再自持 isGui 判定)。
|
|
47
47
|
*/
|
|
48
|
-
export function makeRefreshDisplay(state: TodoSessionState):
|
|
48
|
+
export function makeRefreshDisplay(state: TodoSessionState): RefreshDisplayFn {
|
|
49
49
|
return function refreshDisplay(ctx: ExtensionContext): void {
|
|
50
50
|
const statusText = renderStatusText(state.todos, ctx.ui.theme);
|
|
51
51
|
ctx.ui.setStatus("todo", statusText || undefined);
|
|
52
|
-
const isGui = isGuiCapable(ctx as GuiContext);
|
|
53
52
|
if (state.todos.length === 0) {
|
|
54
|
-
|
|
55
|
-
guiSetWidget(ctx as GuiContext, "todo", undefined);
|
|
56
|
-
} else {
|
|
57
|
-
ctx.ui.setWidget("todo", undefined);
|
|
58
|
-
}
|
|
59
|
-
} else if (isGui) {
|
|
60
|
-
guiSetWidget(ctx as GuiContext, "todo", buildGui(state.todos));
|
|
53
|
+
setWidgetDual(ctx as GuiContext, "todo", undefined);
|
|
61
54
|
} else {
|
|
62
|
-
ctx
|
|
55
|
+
setWidgetDual(ctx as GuiContext, "todo", {
|
|
56
|
+
gui: buildGui(state.todos),
|
|
57
|
+
text: renderWidgetLines(state.todos, ctx.ui.theme),
|
|
58
|
+
});
|
|
63
59
|
}
|
|
64
60
|
};
|
|
65
61
|
}
|
package/src/model.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
guiResult,
|
|
10
10
|
type TreeItem,
|
|
11
11
|
type WidgetMeta,
|
|
12
|
-
} from "@
|
|
12
|
+
} from "@taiji/extension-protocol";
|
|
13
13
|
|
|
14
14
|
// ── 数据模型 ─────────────────────────────────────────
|
|
15
15
|
|
|
@@ -27,7 +27,7 @@ export interface TodoDetails {
|
|
|
27
27
|
|
|
28
28
|
export const VALID_STATUSES = ["pending", "in_progress", "completed"] as const;
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
type ValidStatus = (typeof VALID_STATUSES)[number];
|
|
31
31
|
|
|
32
32
|
// ── 迁移/兼容 ───────────────────────────────────────
|
|
33
33
|
|
|
@@ -70,6 +70,14 @@ export function migrateTodo(raw: unknown): Todo {
|
|
|
70
70
|
|
|
71
71
|
// ── GUI 渲染辅助 ─────────────────────────────────────
|
|
72
72
|
|
|
73
|
+
/** completed 计数单一来源:buildGui / renderStatusText / renderWidgetLines / component 四个消费点共用口径 */
|
|
74
|
+
export function todoProgress(todos: Todo[]): { completed: number; total: number } {
|
|
75
|
+
return {
|
|
76
|
+
completed: todos.filter((t) => t.status === "completed").length,
|
|
77
|
+
total: todos.length,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
/**
|
|
74
82
|
* 把 todos 组装为 GuiRenderResult(v1.1 meta head 架构,对齐 extension-protocol@0.3.0)。
|
|
75
83
|
*
|
|
@@ -85,8 +93,7 @@ export function migrateTodo(raw: unknown): Todo {
|
|
|
85
93
|
* completed → done(success + label 弱化)
|
|
86
94
|
*/
|
|
87
95
|
export function buildGui(todos: Todo[]): GuiRenderResult {
|
|
88
|
-
const total = todos
|
|
89
|
-
const completed = todos.filter((t) => t.status === "completed").length;
|
|
96
|
+
const { completed, total } = todoProgress(todos);
|
|
90
97
|
const inProgress = todos.filter((t) => t.status === "in_progress").length;
|
|
91
98
|
|
|
92
99
|
const status: WidgetMeta["status"] =
|
|
@@ -118,7 +125,7 @@ export function buildGui(todos: Todo[]): GuiRenderResult {
|
|
|
118
125
|
/** 建议的单 session todo 数上限(软约束:超限提醒,不硬拒绝) */
|
|
119
126
|
export const RECOMMENDED_MAX_TODOS = 10;
|
|
120
127
|
|
|
121
|
-
|
|
128
|
+
interface AddResult {
|
|
122
129
|
newTodos: Todo[];
|
|
123
130
|
newNextId: number;
|
|
124
131
|
resultText: string;
|
|
@@ -186,12 +193,17 @@ export function addTodos(
|
|
|
186
193
|
|
|
187
194
|
// ── Update 逻辑 ──────────────────────────────────────
|
|
188
195
|
|
|
189
|
-
|
|
196
|
+
/** updateTodos 成功返回形状;校验失败直接 throw(包内单一错误协议)。 */
|
|
197
|
+
interface UpdateResult {
|
|
190
198
|
updatedTodos: Todo[];
|
|
191
|
-
|
|
192
|
-
resultText?: string;
|
|
199
|
+
resultText: string;
|
|
193
200
|
}
|
|
194
201
|
|
|
202
|
+
/**
|
|
203
|
+
* 批量更新 todo。校验失败(重复 id / id 不存在 / 无 status 无 text / 非法 status)
|
|
204
|
+
* 直接 throw——与 addTodos / handler 同一 throw 协议,文案不带 "Error: " 前缀
|
|
205
|
+
* (错误形态由 pi 工具错误通道表达);throw 发生在任何突变之前,state 保持不变。
|
|
206
|
+
*/
|
|
195
207
|
export function updateTodos(
|
|
196
208
|
currentTodos: Todo[],
|
|
197
209
|
updates: Array<{ id: number; status?: string; text?: string }>,
|
|
@@ -205,34 +217,18 @@ export function updateTodos(
|
|
|
205
217
|
|
|
206
218
|
const ids = updates.map((u) => u.id);
|
|
207
219
|
if (new Set(ids).size !== ids.length) {
|
|
208
|
-
|
|
209
|
-
updatedTodos: currentTodos,
|
|
210
|
-
error: "duplicate ids in updates",
|
|
211
|
-
resultText: "Error: duplicate ids in updates",
|
|
212
|
-
};
|
|
220
|
+
throw new Error("duplicate ids in updates");
|
|
213
221
|
}
|
|
214
222
|
for (const u of updates) {
|
|
215
223
|
const todo = currentTodos.find((t) => t.id === u.id);
|
|
216
224
|
if (!todo) {
|
|
217
|
-
|
|
218
|
-
updatedTodos: currentTodos,
|
|
219
|
-
error: `id ${u.id} not found`,
|
|
220
|
-
resultText: `Error: Todo #${u.id} not found`,
|
|
221
|
-
};
|
|
225
|
+
throw new Error(`Todo #${u.id} not found`);
|
|
222
226
|
}
|
|
223
227
|
if (!u.status && !u.text) {
|
|
224
|
-
|
|
225
|
-
updatedTodos: currentTodos,
|
|
226
|
-
error: `update item for id ${u.id} has neither status nor text`,
|
|
227
|
-
resultText: `Error: update item for id ${u.id} has neither status nor text`,
|
|
228
|
-
};
|
|
228
|
+
throw new Error(`update item for id ${u.id} has neither status nor text`);
|
|
229
229
|
}
|
|
230
230
|
if (u.status && !VALID_STATUSES.includes(u.status as (typeof VALID_STATUSES)[number])) {
|
|
231
|
-
|
|
232
|
-
updatedTodos: currentTodos,
|
|
233
|
-
error: `invalid status: ${u.status}`,
|
|
234
|
-
resultText: `Error: invalid status '${u.status}' for update item id ${u.id}`,
|
|
235
|
-
};
|
|
231
|
+
throw new Error(`invalid status '${u.status}' for update item id ${u.id}`);
|
|
236
232
|
}
|
|
237
233
|
}
|
|
238
234
|
|
package/src/render.ts
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Todo 渲染函数 — 状态栏、widget(双列)、tool result 渲染。
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
todoProgress,
|
|
9
10
|
type Todo,
|
|
10
11
|
type TodoDetails,
|
|
11
12
|
} from "./model";
|
|
@@ -24,25 +25,15 @@ const SINGLE_COLUMN_BUDGET = WIDGET_MAX_LINES - 1;
|
|
|
24
25
|
|
|
25
26
|
/** 垂直分割线视觉宽度(" │ ") */
|
|
26
27
|
const DIVIDER_VISUAL_WIDTH = 3;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
/** 截断或补齐到精确视觉宽度,截断时追加 "..." */
|
|
30
|
-
function fixedWidth(text: string, width: number): string {
|
|
31
|
-
const len = visibleWidth(text);
|
|
32
|
-
if (len <= width) {
|
|
33
|
-
return text + " ".repeat(width - len);
|
|
34
|
-
}
|
|
35
|
-
if (width <= ELLIPSIS_MIN_WIDTH) return "...".slice(0, width);
|
|
36
|
-
return truncateToWidth(text, width - ELLIPSIS_MIN_WIDTH) + "...";
|
|
37
|
-
}
|
|
28
|
+
/** 省略号视觉宽度("...") */
|
|
29
|
+
const ELLIPSIS_WIDTH = 3;
|
|
38
30
|
|
|
39
31
|
// ── 状态栏 ────────────────────────────────────────────
|
|
40
32
|
|
|
41
33
|
export function renderStatusText(todoList: Todo[], th: Theme): string {
|
|
42
34
|
if (todoList.length === 0) return "";
|
|
43
35
|
|
|
44
|
-
const completed = todoList
|
|
45
|
-
const total = todoList.length;
|
|
36
|
+
const { completed, total } = todoProgress(todoList);
|
|
46
37
|
|
|
47
38
|
if (completed === total) {
|
|
48
39
|
return th.fg("success", `\u2713 ${completed}/${total}`);
|
|
@@ -52,8 +43,9 @@ export function renderStatusText(todoList: Todo[], th: Theme): string {
|
|
|
52
43
|
|
|
53
44
|
// ── Widget 双列渲染 ──────────────────────────────────
|
|
54
45
|
|
|
55
|
-
/** 渲染单条 todo 的 widget 行(不含缩进),供 component.ts
|
|
56
|
-
|
|
46
|
+
/** 渲染单条 todo 的 widget 行(不含缩进),供 component.ts 复用。
|
|
47
|
+
* textColor = 非完成态文本颜色(widget 用 "text",tool result 列表用 "muted"——历史差异显式保留,未做视觉统一)。 */
|
|
48
|
+
function renderWidgetItem(t: Todo, th: Theme, textColor: ThemeColor = "text"): string {
|
|
57
49
|
const mark =
|
|
58
50
|
t.status === "completed"
|
|
59
51
|
? th.fg("success", "\u2713")
|
|
@@ -61,7 +53,7 @@ function renderWidgetItem(t: Todo, th: Theme): string {
|
|
|
61
53
|
? th.fg("warning", "\u25cf")
|
|
62
54
|
: th.fg("dim", "\u25cb"); // pending
|
|
63
55
|
const id = th.fg("accent", `#${t.id}`);
|
|
64
|
-
const text = t.status === "completed" ? th.fg("dim", t.text) : th.fg(
|
|
56
|
+
const text = t.status === "completed" ? th.fg("dim", t.text) : th.fg(textColor, t.text);
|
|
65
57
|
return `${mark} ${id} ${text}`;
|
|
66
58
|
}
|
|
67
59
|
|
|
@@ -91,11 +83,22 @@ export function renderDualColumn(
|
|
|
91
83
|
const lines: string[] = [];
|
|
92
84
|
const half = Math.ceil(todos.length / COLUMN_COUNT);
|
|
93
85
|
const divider = " " + th.fg("borderMuted", "\u2502") + " ";
|
|
86
|
+
// 补齐/截断到列宽:可见输出与 D14 替换前的列宽逻辑逐字符一致(裁决零行为变化)——
|
|
87
|
+
// 截断列保持「前缀+6点」旧形态,不可简化为单调用 truncateToWidth(text, colWidth, "...", true)
|
|
88
|
+
// (会改为前缀+3点);行为由 __tests__/render.test.ts 锁定。colWidth <= ELLIPSIS_WIDTH 走 pi-tui
|
|
89
|
+
// clipped-ellipsis 路径,可见一致(ANSI reset 包裹差异不影响渲染)。负列宽(病态窄终端)pi-tui 对
|
|
90
|
+
// maxWidth<=0 恒空串,无法复现旧 slice 负索引语义,保留原特判。
|
|
91
|
+
const fit = (text: string): string =>
|
|
92
|
+
colWidth < 0
|
|
93
|
+
? "...".slice(0, colWidth)
|
|
94
|
+
: visibleWidth(text) <= colWidth || colWidth <= ELLIPSIS_WIDTH
|
|
95
|
+
? truncateToWidth(text, colWidth, "...", true)
|
|
96
|
+
: truncateToWidth(text, colWidth - ELLIPSIS_WIDTH) + "...";
|
|
94
97
|
for (let row = 0; row < half; row++) {
|
|
95
|
-
const left =
|
|
98
|
+
const left = fit(indent + renderWidgetItem(todos[row], th));
|
|
96
99
|
const rightIdx = row + half;
|
|
97
100
|
const right = rightIdx < todos.length
|
|
98
|
-
?
|
|
101
|
+
? fit(renderWidgetItem(todos[rightIdx], th))
|
|
99
102
|
: " ".repeat(colWidth);
|
|
100
103
|
lines.push(left + divider + right);
|
|
101
104
|
}
|
|
@@ -112,8 +115,7 @@ export function renderWidgetLines(
|
|
|
112
115
|
|
|
113
116
|
const width = termWidth ?? (process.stdout.columns || FALLBACK_TERM_WIDTH);
|
|
114
117
|
const lines: string[] = [];
|
|
115
|
-
const completed = todoList
|
|
116
|
-
const total = todoList.length;
|
|
118
|
+
const { completed, total } = todoProgress(todoList);
|
|
117
119
|
|
|
118
120
|
lines.push(th.fg("accent", "\u2611") + th.fg("muted", ` ${completed}/${total}`));
|
|
119
121
|
|
|
@@ -141,15 +143,7 @@ function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, the
|
|
|
141
143
|
let listText = theme.fg("muted", `${todoList.length} todos:`);
|
|
142
144
|
const display = options.expanded ? todoList : todoList.slice(0, MAX_COLLAPSED_ITEMS);
|
|
143
145
|
for (const t of display) {
|
|
144
|
-
|
|
145
|
-
const mark =
|
|
146
|
-
status === "completed"
|
|
147
|
-
? theme.fg("success", "\u2713")
|
|
148
|
-
: status === "in_progress"
|
|
149
|
-
? theme.fg("warning", "\u25cf")
|
|
150
|
-
: theme.fg("dim", "\u25cb"); // pending
|
|
151
|
-
const itemText = status === "completed" ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
|
|
152
|
-
listText += `\n${mark} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
|
|
146
|
+
listText += `\n${renderWidgetItem(t, theme, "muted")}`;
|
|
153
147
|
}
|
|
154
148
|
if (!options.expanded && todoList.length > MAX_COLLAPSED_ITEMS) {
|
|
155
149
|
listText += `\n${theme.fg("dim", `... ${todoList.length - MAX_COLLAPSED_ITEMS} more`)}`;
|
|
@@ -160,12 +154,7 @@ function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, the
|
|
|
160
154
|
// ── Tool renderResult handler ────────────────────────
|
|
161
155
|
|
|
162
156
|
import { Text } from "@earendil-works/pi-tui";
|
|
163
|
-
|
|
164
|
-
/** content[0] 提取 text(缺失/非 text 类型 → 空串) */
|
|
165
|
-
function firstContentText(r: { content: Array<{ type: string; text?: string }> }): string {
|
|
166
|
-
const text = r.content[0];
|
|
167
|
-
return text?.type === "text" ? (text.text ?? "") : "";
|
|
168
|
-
}
|
|
157
|
+
import { firstContentText } from "@taiji/extension-protocol";
|
|
169
158
|
|
|
170
159
|
export function renderTodoResult(result: unknown, options: { expanded: boolean }, theme: Theme): Text {
|
|
171
160
|
const r = result as { content: Array<{ type: string; text?: string }>; details?: unknown };
|
package/src/tool.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
VALID_STATUSES,
|
|
24
24
|
} from "./model";
|
|
25
25
|
import { renderTodoResult } from "./render";
|
|
26
|
+
import type { RefreshDisplayFn } from "./handlers";
|
|
26
27
|
import type { TodoSessionState } from "./state";
|
|
27
28
|
|
|
28
29
|
// ── TodoParams schema(扁平 Type.Object,OpenAI 兼容)──────────
|
|
@@ -64,10 +65,10 @@ export const TodoParams = Type.Object(
|
|
|
64
65
|
export type TodoParamsT = Static<typeof TodoParams>;
|
|
65
66
|
|
|
66
67
|
// ── 4 个 action handler ──────────────────────────────
|
|
67
|
-
// 错误处理约定(见
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
68
|
+
// 错误处理约定(见 docs/extensions/extension-conventions.md「Tool 设计」):
|
|
69
|
+
// 包内单一 throw 协议——handler 与 model 层纯函数(addTodos / updateTodos)
|
|
70
|
+
// 校验失败均直接 throw,把文案交给 Pi 框架以工具错误展示,不返回「错误成功模式」,
|
|
71
|
+
// handler 也不做 error→throw 翻译。
|
|
71
72
|
|
|
72
73
|
/** list action — 返回完整格式化列表 */
|
|
73
74
|
function handleList(state: TodoSessionState): string {
|
|
@@ -107,9 +108,8 @@ export function handleAdd(state: TodoSessionState, params: TodoParamsT): string
|
|
|
107
108
|
/** update action: batch — 失败抛错 */
|
|
108
109
|
function handleBatchUpdate(state: TodoSessionState, params: TodoParamsT): string {
|
|
109
110
|
const r = updateTodos(state.todos, params.updates ?? []);
|
|
110
|
-
if (r.error) throw new Error(r.resultText);
|
|
111
111
|
state.todos = r.updatedTodos;
|
|
112
|
-
return r.resultText
|
|
112
|
+
return r.resultText;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
/** update action: single — 失败抛错 */
|
|
@@ -186,7 +186,7 @@ function executeTodoAction(
|
|
|
186
186
|
params: TodoParamsT,
|
|
187
187
|
state: TodoSessionState,
|
|
188
188
|
ctx: ExtensionContext,
|
|
189
|
-
refreshDisplay:
|
|
189
|
+
refreshDisplay: RefreshDisplayFn,
|
|
190
190
|
): {
|
|
191
191
|
content: Array<{ type: "text"; text: string }>;
|
|
192
192
|
details: TodoDetails;
|
|
@@ -213,11 +213,10 @@ function executeTodoAction(
|
|
|
213
213
|
|
|
214
214
|
refreshDisplay(ctx);
|
|
215
215
|
|
|
216
|
-
// content 组装(T3
|
|
216
|
+
// content 组装(T3):突变附带完整列表(复用 handleList 的空列表兜底);list 已含列表
|
|
217
217
|
let contentText: string;
|
|
218
218
|
if (isMutation) {
|
|
219
|
-
|
|
220
|
-
contentText = `${resultText}\n${listText}`;
|
|
219
|
+
contentText = `${resultText}\n${handleList(state)}`;
|
|
221
220
|
} else {
|
|
222
221
|
contentText = resultText;
|
|
223
222
|
}
|
|
@@ -240,7 +239,7 @@ function executeTodoAction(
|
|
|
240
239
|
export function registerTodoTool(
|
|
241
240
|
pi: ExtensionAPI,
|
|
242
241
|
state: TodoSessionState,
|
|
243
|
-
refreshDisplay:
|
|
242
|
+
refreshDisplay: RefreshDisplayFn,
|
|
244
243
|
): void {
|
|
245
244
|
pi.registerTool({
|
|
246
245
|
name: "todo",
|