@zhushanwen/pi-todo 0.1.6 → 0.3.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 +1 -1
- package/src/__tests__/todo.test.ts +263 -586
- package/src/commands.ts +5 -26
- package/src/component.ts +11 -34
- package/src/handlers.ts +73 -109
- package/src/index.ts +5 -3
- package/src/model.ts +48 -144
- package/src/render.ts +108 -70
- package/src/state.ts +7 -1
- package/src/tool.ts +56 -144
package/src/model.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Todo 数据模型 — 纯函数,不依赖 Pi 运行时。
|
|
3
|
-
*
|
|
3
|
+
* 四态: pending → in_progress → completed;任一状态 → cancelled
|
|
4
|
+
* (cancelled 不可恢复;isVerification 标记验证任务,FR-6 completion audit 用)
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
// ── 数据模型 ─────────────────────────────────────────
|
|
@@ -8,11 +9,9 @@
|
|
|
8
9
|
export interface Todo {
|
|
9
10
|
id: number;
|
|
10
11
|
text: string;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
/** 验证结论,verifying/completed 时写入 */
|
|
15
|
-
evidence?: string;
|
|
12
|
+
status: "pending" | "in_progress" | "completed" | "cancelled";
|
|
13
|
+
/** 验证任务标记(FR-6 completion audit)。验证任务必须 completed,不可 cancelled。 */
|
|
14
|
+
isVerification?: boolean;
|
|
16
15
|
}
|
|
17
16
|
|
|
18
17
|
export interface TodoDetails {
|
|
@@ -30,50 +29,42 @@ export interface TodoDetails {
|
|
|
30
29
|
};
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
export const VALID_STATUSES = ["pending", "in_progress", "
|
|
32
|
+
export const VALID_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const;
|
|
34
33
|
|
|
35
34
|
export type ValidStatus = (typeof VALID_STATUSES)[number];
|
|
36
35
|
|
|
37
36
|
// ── 迁移/兼容 ───────────────────────────────────────
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const STALE_CONTEXT_PATTERNS = ["aborted", "context canceled", "stale context", "stalecontext", "extension context no longer active"];
|
|
42
|
-
|
|
43
|
-
/** 检查错误是否表示 stale / canceled context(如 session 重建 / compact 后) */
|
|
44
|
-
export function isStaleContextError(error: Error | unknown): boolean {
|
|
45
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
46
|
-
const lower = msg.toLowerCase();
|
|
47
|
-
return STALE_CONTEXT_PATTERNS.some((p) => lower.includes(p));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
/** 兼容旧格式:旧 entry 可能有 done: boolean,转换为 status;旧数据缺少 verifyText/verifyAttempts 时填充默认值 */
|
|
38
|
+
/** 旧格式迁移:verifying → in_progress,failed → pending,done:boolean → status */
|
|
52
39
|
export function migrateTodo(raw: Todo): Todo {
|
|
53
40
|
const record = raw as unknown as Record<string, unknown>;
|
|
54
41
|
const hasValidStatus =
|
|
55
42
|
typeof record.status === "string" &&
|
|
56
43
|
VALID_STATUSES.includes(record.status as ValidStatus);
|
|
57
44
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
45
|
+
let status: ValidStatus;
|
|
46
|
+
if (hasValidStatus) {
|
|
47
|
+
status = record.status as ValidStatus;
|
|
48
|
+
} else {
|
|
49
|
+
// 极旧格式 done: boolean
|
|
50
|
+
const { done } = record as { done?: boolean };
|
|
51
|
+
status = done === true ? "completed" : "pending";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 旧版五态映射(先转 string 避免类型收窄后无法比较)
|
|
55
|
+
const rawStatus = record.status as string | undefined;
|
|
56
|
+
if (rawStatus === "verifying") status = "in_progress";
|
|
57
|
+
if (rawStatus === "failed") status = "pending";
|
|
64
58
|
|
|
65
59
|
return {
|
|
66
60
|
id: record.id as number,
|
|
67
61
|
text: record.text as string,
|
|
68
62
|
status,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
evidence: typeof record.evidence === "string" ? (record.evidence as string) : undefined,
|
|
63
|
+
// FR-6: 保留 isVerification 标记(可选字段,旧数据可能缺失)
|
|
64
|
+
isVerification: record.isVerification === true ? true : undefined,
|
|
72
65
|
};
|
|
73
66
|
}
|
|
74
67
|
|
|
75
|
-
|
|
76
|
-
|
|
77
68
|
// ── 渲染辅助 ─────────────────────────────────────────
|
|
78
69
|
|
|
79
70
|
export function buildRender(todoList: Todo[]): TodoDetails["_render"] {
|
|
@@ -93,7 +84,7 @@ export function getDisplayStatus(t: Todo): string {
|
|
|
93
84
|
return migrateTodo(t).status;
|
|
94
85
|
}
|
|
95
86
|
|
|
96
|
-
// ── Add
|
|
87
|
+
// ── Add 逻辑 ─────────────────────────────────────────
|
|
97
88
|
|
|
98
89
|
export interface AddResult {
|
|
99
90
|
newTodos: Todo[];
|
|
@@ -102,18 +93,11 @@ export interface AddResult {
|
|
|
102
93
|
resultText?: string;
|
|
103
94
|
}
|
|
104
95
|
|
|
105
|
-
/**
|
|
106
|
-
* 处理 todo add 的核心逻辑。
|
|
107
|
-
* @param currentTodos 当前 todo 列表
|
|
108
|
-
* @param currentNextId 当前 nextId
|
|
109
|
-
* @param texts 要添加的文本列表
|
|
110
|
-
* @param verifyTexts 可选的验证文本列表
|
|
111
|
-
*/
|
|
112
96
|
export function addTodos(
|
|
113
97
|
currentTodos: Todo[],
|
|
114
98
|
currentNextId: number,
|
|
115
99
|
texts: string[],
|
|
116
|
-
|
|
100
|
+
isVerification?: boolean,
|
|
117
101
|
): AddResult {
|
|
118
102
|
if (!texts || texts.length === 0) {
|
|
119
103
|
return {
|
|
@@ -134,15 +118,6 @@ export function addTodos(
|
|
|
134
118
|
};
|
|
135
119
|
}
|
|
136
120
|
|
|
137
|
-
if (verifyTexts !== undefined && verifyTexts.length > trimmed.length) {
|
|
138
|
-
return {
|
|
139
|
-
newTodos: currentTodos,
|
|
140
|
-
newNextId: currentNextId,
|
|
141
|
-
error: "verifyTexts too long",
|
|
142
|
-
resultText: "Error: verifyTexts length cannot exceed texts length",
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
|
|
146
121
|
const startId = currentNextId;
|
|
147
122
|
const newTodos = [...currentTodos];
|
|
148
123
|
let nextId = currentNextId;
|
|
@@ -151,8 +126,8 @@ export function addTodos(
|
|
|
151
126
|
id: nextId++,
|
|
152
127
|
text: trimmed[i],
|
|
153
128
|
status: "pending" as const,
|
|
154
|
-
|
|
155
|
-
|
|
129
|
+
// FR-6: isVerification 标记验证任务(可选,仅 add 时可设)
|
|
130
|
+
isVerification: isVerification === true ? true : undefined,
|
|
156
131
|
});
|
|
157
132
|
}
|
|
158
133
|
const endId = nextId - 1;
|
|
@@ -164,36 +139,18 @@ export function addTodos(
|
|
|
164
139
|
};
|
|
165
140
|
}
|
|
166
141
|
|
|
167
|
-
// ── Update
|
|
142
|
+
// ── Update 逻辑 ──────────────────────────────────────
|
|
168
143
|
|
|
169
144
|
export interface UpdateResult {
|
|
170
145
|
updatedTodos: Todo[];
|
|
171
146
|
error?: string;
|
|
172
147
|
resultText?: string;
|
|
173
|
-
/** 拦截的任务 ID 及其原因 */
|
|
174
|
-
blocked?: Array<{ id: number; text: string; reason: string }>;
|
|
175
148
|
}
|
|
176
149
|
|
|
177
|
-
/** evidence 最小长度 */
|
|
178
|
-
const MIN_EVIDENCE_LENGTH = 10;
|
|
179
|
-
/** 最大验证重试次数 */
|
|
180
|
-
const MAX_VERIFY_ATTEMPTS = 2;
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* 处理 todo batch update 的核心逻辑。
|
|
184
|
-
* All-or-nothing: 任一验证失败,所有变更不生效。
|
|
185
|
-
*
|
|
186
|
-
* 状态转换规则:
|
|
187
|
-
* - 无 verifyText: in_progress → completed 直接通过
|
|
188
|
-
* - 有 verifyText: in_progress → verifying (需 evidence) → completed (需 evidence)
|
|
189
|
-
* - 有 verifyText: in_progress → completed 需 verified=true + evidence(跳过 verifying)
|
|
190
|
-
* - verifying → in_progress: verifyAttempts++
|
|
191
|
-
*/
|
|
192
150
|
export function updateTodos(
|
|
193
151
|
currentTodos: Todo[],
|
|
194
|
-
updates: Array<{ id: number; status?: string; text?: string
|
|
152
|
+
updates: Array<{ id: number; status?: string; text?: string }>,
|
|
195
153
|
): UpdateResult {
|
|
196
|
-
// 验证: no duplicate ids
|
|
197
154
|
const ids = updates.map((u) => u.id);
|
|
198
155
|
if (new Set(ids).size !== ids.length) {
|
|
199
156
|
return {
|
|
@@ -202,7 +159,6 @@ export function updateTodos(
|
|
|
202
159
|
resultText: "Error: duplicate ids in updates",
|
|
203
160
|
};
|
|
204
161
|
}
|
|
205
|
-
// 验证: all ids exist and each has at least one change, and valid status
|
|
206
162
|
for (const u of updates) {
|
|
207
163
|
const todo = currentTodos.find((t) => t.id === u.id);
|
|
208
164
|
if (!todo) {
|
|
@@ -226,70 +182,29 @@ export function updateTodos(
|
|
|
226
182
|
resultText: `Error: invalid status '${u.status}' for update item id ${u.id}`,
|
|
227
183
|
};
|
|
228
184
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
blockedItems.push({ id: todo.id, text: todo.text, reason: `进入 verifying 需要 evidence(≥${MIN_EVIDENCE_LENGTH} 字符),说明当前验证进度` });
|
|
244
|
-
}
|
|
245
|
-
} else if (u.status === "completed") {
|
|
246
|
-
if (todo.verifyText && todo.status !== "verifying") {
|
|
247
|
-
// 有 verifyText 但未经过 verifying → 需要 verified=true + evidence
|
|
248
|
-
if (u.verified !== true) {
|
|
249
|
-
blockedItems.push({ id: todo.id, text: todo.text, reason: `有验证要求,请先标 verifying 并传 evidence,或传 verified=true + evidence 跳过` });
|
|
250
|
-
} else if (!u.evidence || u.evidence.trim().length < MIN_EVIDENCE_LENGTH) {
|
|
251
|
-
blockedItems.push({ id: todo.id, text: todo.text, reason: `跳过 verifying 直接 completed 时需要 evidence(≥${MIN_EVIDENCE_LENGTH} 字符)` });
|
|
252
|
-
}
|
|
253
|
-
} else if (todo.status === "verifying") {
|
|
254
|
-
// verifying → completed: 需要 evidence
|
|
255
|
-
if (!u.evidence || u.evidence.trim().length < MIN_EVIDENCE_LENGTH) {
|
|
256
|
-
blockedItems.push({ id: todo.id, text: todo.text, reason: `从 verifying 到 completed 需要 evidence(≥${MIN_EVIDENCE_LENGTH} 字符),说明验证结论` });
|
|
257
|
-
}
|
|
258
|
-
}
|
|
185
|
+
// FR-6 不变量守卫:(a) cancelled 不可恢复;(b) 验证任务不可 cancelled
|
|
186
|
+
if (todo.status === "cancelled" && u.status !== undefined) {
|
|
187
|
+
return {
|
|
188
|
+
updatedTodos: currentTodos,
|
|
189
|
+
error: `id ${u.id} is cancelled`,
|
|
190
|
+
resultText: `Error: Todo #${u.id} is cancelled and cannot be restored`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (todo.isVerification && u.status === "cancelled") {
|
|
194
|
+
return {
|
|
195
|
+
updatedTodos: currentTodos,
|
|
196
|
+
error: `id ${u.id} is verification todo`,
|
|
197
|
+
resultText: `Error: Todo #${u.id} is a verification todo and cannot be cancelled`,
|
|
198
|
+
};
|
|
259
199
|
}
|
|
260
200
|
}
|
|
261
201
|
|
|
262
|
-
if (blockedItems.length > 0) {
|
|
263
|
-
const lines = blockedItems.map(
|
|
264
|
-
(b) => ` #${b.id}: ${b.text} — ${b.reason}`,
|
|
265
|
-
);
|
|
266
|
-
return {
|
|
267
|
-
updatedTodos: currentTodos,
|
|
268
|
-
resultText: `⚠️ 以下任务被拦截:\n${lines.join("\n")}`,
|
|
269
|
-
blocked: blockedItems,
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Apply all (safe since all validated)
|
|
274
202
|
const updated = currentTodos.map((t) => {
|
|
275
203
|
const u = updates.find((u) => u.id === t.id);
|
|
276
204
|
if (!u) return t;
|
|
277
205
|
const patch: Partial<Todo> = {};
|
|
278
206
|
if (u.status) patch.status = u.status as Todo["status"];
|
|
279
207
|
if (u.text) patch.text = u.text;
|
|
280
|
-
// verifying 或 completed 时写入 evidence
|
|
281
|
-
if (u.evidence && (u.status === "verifying" || u.status === "completed")) {
|
|
282
|
-
patch.evidence = u.evidence.trim();
|
|
283
|
-
}
|
|
284
|
-
// verifying/completed → in_progress: verifyAttempts++(验证失败回退)
|
|
285
|
-
if (
|
|
286
|
-
u.status === "in_progress" &&
|
|
287
|
-
t.verifyText &&
|
|
288
|
-
t.verifyAttempts < MAX_VERIFY_ATTEMPTS &&
|
|
289
|
-
(t.status === "completed" || t.status === "verifying")
|
|
290
|
-
) {
|
|
291
|
-
patch.verifyAttempts = t.verifyAttempts + 1;
|
|
292
|
-
}
|
|
293
208
|
return { ...t, ...patch };
|
|
294
209
|
});
|
|
295
210
|
return {
|
|
@@ -300,25 +215,14 @@ export function updateTodos(
|
|
|
300
215
|
|
|
301
216
|
// ── 格式化辅助 ───────────────────────────────────────
|
|
302
217
|
|
|
303
|
-
/** 格式化单条 todo 为纯文本行(AI 可读),含 verifyText 原文 */
|
|
304
218
|
export function formatTodoLine(t: Todo): string {
|
|
305
219
|
const mark =
|
|
306
220
|
t.status === "completed"
|
|
307
221
|
? "x"
|
|
308
|
-
: t.status === "
|
|
309
|
-
? "
|
|
310
|
-
: t.status === "
|
|
311
|
-
? "
|
|
312
|
-
:
|
|
313
|
-
|
|
314
|
-
: " ";
|
|
315
|
-
let line = `[${mark}] #${t.id}: ${t.text}`;
|
|
316
|
-
if (t.status === "verifying" && t.evidence) {
|
|
317
|
-
line += ` | 验证中: ${t.evidence}`;
|
|
318
|
-
} else if (t.status === "completed" && t.evidence) {
|
|
319
|
-
line += ` | 已验证: ${t.evidence}`;
|
|
320
|
-
} else if (t.verifyText) {
|
|
321
|
-
line += ` | 验证: ${t.verifyText}`;
|
|
322
|
-
}
|
|
323
|
-
return line;
|
|
222
|
+
: t.status === "in_progress"
|
|
223
|
+
? "~"
|
|
224
|
+
: t.status === "cancelled"
|
|
225
|
+
? "-"
|
|
226
|
+
: " ";
|
|
227
|
+
return `[${mark}] #${t.id}: ${t.text}`;
|
|
324
228
|
}
|
package/src/render.ts
CHANGED
|
@@ -1,74 +1,136 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Todo 渲染函数 — 状态栏、widget
|
|
3
|
-
*
|
|
4
|
-
* 拆分理由:原 src/index.ts 把渲染逻辑与业务逻辑混在一起。提取后 index.ts
|
|
5
|
-
* 工厂只需调用 register* 函数;这些纯函数接受 Todo[] / theme 等入参,
|
|
6
|
-
* 不依赖闭包状态,可独立测试。
|
|
2
|
+
* Todo 渲染函数 — 状态栏、widget(双列)、tool result 渲染。
|
|
7
3
|
*/
|
|
8
4
|
|
|
9
5
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
10
|
-
import {
|
|
6
|
+
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
11
7
|
|
|
12
8
|
import {
|
|
13
|
-
buildRender,
|
|
14
9
|
getDisplayStatus,
|
|
15
10
|
type Todo,
|
|
16
11
|
type TodoDetails,
|
|
17
12
|
} from "./model";
|
|
18
13
|
|
|
14
|
+
// ── 常量 ────────────────────────────────────────────
|
|
15
|
+
|
|
19
16
|
const MAX_COLLAPSED_ITEMS = 5;
|
|
17
|
+
export const FALLBACK_TERM_WIDTH = 80;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Pi 对单个 extension widget 的最大字符串行数为 10(InteractiveMode.MAX_WIDGET_LINES)。
|
|
21
|
+
* 扩展侧保守使用 max - 1 = 9 行作为阈值,超过时切换为双列布局,避免触发截断。
|
|
22
|
+
*/
|
|
23
|
+
const WIDGET_MAX_LINES = 9;
|
|
24
|
+
const SINGLE_COLUMN_BUDGET = WIDGET_MAX_LINES - 1;
|
|
25
|
+
|
|
26
|
+
/** 垂直分割线视觉宽度(" │ ") */
|
|
27
|
+
const DIVIDER_VISUAL_WIDTH = 3;
|
|
28
|
+
const ELLIPSIS_MIN_WIDTH = 3;
|
|
29
|
+
|
|
30
|
+
/** 截断或补齐到精确视觉宽度,截断时追加 "..." */
|
|
31
|
+
function fixedWidth(text: string, width: number): string {
|
|
32
|
+
const len = visibleWidth(text);
|
|
33
|
+
if (len <= width) {
|
|
34
|
+
return text + " ".repeat(width - len);
|
|
35
|
+
}
|
|
36
|
+
if (width <= ELLIPSIS_MIN_WIDTH) return "...".slice(0, width);
|
|
37
|
+
return truncateToWidth(text, width - ELLIPSIS_MIN_WIDTH) + "...";
|
|
38
|
+
}
|
|
20
39
|
|
|
21
40
|
// ── 状态栏 ────────────────────────────────────────────
|
|
22
41
|
|
|
23
|
-
/** 渲染状态栏文本 */
|
|
24
42
|
export function renderStatusText(todoList: Todo[], th: Theme): string {
|
|
25
43
|
if (todoList.length === 0) return "";
|
|
26
44
|
|
|
27
45
|
const completed = todoList.filter((t) => getDisplayStatus(t) === "completed").length;
|
|
28
46
|
const total = todoList.length;
|
|
29
47
|
|
|
30
|
-
// 全部完成
|
|
31
48
|
if (completed === total) {
|
|
32
49
|
return th.fg("success", `\u2713 ${completed}/${total}`);
|
|
33
50
|
}
|
|
34
|
-
// 有未完成
|
|
35
51
|
return th.fg("accent", "\u2611") + th.fg("muted", ` ${completed}/${total}`);
|
|
36
52
|
}
|
|
37
53
|
|
|
38
|
-
|
|
39
|
-
|
|
54
|
+
// ── Widget 双列渲染 ──────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/** 渲染单条 todo 的 widget 行(不含缩进),供 component.ts 复用 */
|
|
57
|
+
function renderWidgetItem(t: Todo, th: Theme): string {
|
|
58
|
+
const mark =
|
|
59
|
+
t.status === "completed"
|
|
60
|
+
? th.fg("success", "\u2713")
|
|
61
|
+
: t.status === "in_progress"
|
|
62
|
+
? th.fg("warning", "\u25cf")
|
|
63
|
+
: t.status === "cancelled"
|
|
64
|
+
? th.fg("error", "\u2715")
|
|
65
|
+
: th.fg("dim", "\u25cb");
|
|
66
|
+
const id = th.fg("accent", `#${t.id}`);
|
|
67
|
+
const text =
|
|
68
|
+
t.status === "completed" || t.status === "cancelled" ? th.fg("dim", t.text) : th.fg("text", t.text);
|
|
69
|
+
return `${mark} ${id} ${text}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 单列布局渲染(widget 少量任务时使用) */
|
|
73
|
+
const PI_TEXT_PADDING = 2;
|
|
74
|
+
|
|
75
|
+
function renderSingleColumn(
|
|
76
|
+
todos: Todo[],
|
|
77
|
+
th: Theme,
|
|
78
|
+
termWidth: number,
|
|
79
|
+
indent: string,
|
|
80
|
+
): string[] {
|
|
81
|
+
const maxWidth = Math.max(1, termWidth - PI_TEXT_PADDING);
|
|
82
|
+
return todos.map((t) => truncateToWidth(indent + renderWidgetItem(t, th), maxWidth));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 双列布局渲染,供 widget 和 component 复用 */
|
|
86
|
+
const COLUMN_COUNT = 2;
|
|
87
|
+
|
|
88
|
+
export function renderDualColumn(
|
|
89
|
+
todos: Todo[],
|
|
90
|
+
th: Theme,
|
|
91
|
+
termWidth: number,
|
|
92
|
+
indent: string,
|
|
93
|
+
): string[] {
|
|
94
|
+
const colWidth = Math.floor((termWidth - indent.length - DIVIDER_VISUAL_WIDTH) / COLUMN_COUNT);
|
|
95
|
+
const lines: string[] = [];
|
|
96
|
+
const half = Math.ceil(todos.length / COLUMN_COUNT);
|
|
97
|
+
const divider = " " + th.fg("borderMuted", "\u2502") + " ";
|
|
98
|
+
for (let row = 0; row < half; row++) {
|
|
99
|
+
const left = fixedWidth(indent + renderWidgetItem(todos[row], th), colWidth);
|
|
100
|
+
const rightIdx = row + half;
|
|
101
|
+
const right = rightIdx < todos.length
|
|
102
|
+
? fixedWidth(renderWidgetItem(todos[rightIdx], th), colWidth)
|
|
103
|
+
: " ".repeat(colWidth);
|
|
104
|
+
lines.push(left + divider + right);
|
|
105
|
+
}
|
|
106
|
+
return lines;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 渲染 widget 行(根据任务数自动选择单列或双列布局) */
|
|
110
|
+
export function renderWidgetLines(
|
|
111
|
+
todoList: Todo[],
|
|
112
|
+
th: Theme,
|
|
113
|
+
termWidth?: number,
|
|
114
|
+
): string[] {
|
|
40
115
|
if (todoList.length === 0) return [];
|
|
41
116
|
|
|
117
|
+
const width = termWidth ?? (process.stdout.columns || FALLBACK_TERM_WIDTH);
|
|
42
118
|
const lines: string[] = [];
|
|
43
119
|
const completed = todoList.filter((t) => getDisplayStatus(t) === "completed").length;
|
|
44
120
|
const total = todoList.length;
|
|
45
121
|
|
|
46
122
|
lines.push(th.fg("accent", "\u2611") + th.fg("muted", ` ${completed}/${total}`));
|
|
47
123
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
? th.fg("error", "\u2717")
|
|
58
|
-
: th.fg("dim", "\u25cb");
|
|
59
|
-
const id = th.fg("accent", `#${t.id}`);
|
|
60
|
-
const text = t.status === "completed" ? th.fg("dim", t.text) : th.fg("text", t.text);
|
|
61
|
-
let verifyTag = "";
|
|
62
|
-
if (t.status === "verifying") {
|
|
63
|
-
verifyTag = th.fg("warning", ` [验证中${t.evidence ? ": " + t.evidence.slice(0, 30) : ""}]`);
|
|
64
|
-
} else if (t.verifyText && t.status !== "completed") {
|
|
65
|
-
verifyTag = th.fg("warning", " [待验证]");
|
|
66
|
-
} else if (t.status === "completed" && t.verifyText) {
|
|
67
|
-
verifyTag = th.fg("success", " [已验证]");
|
|
68
|
-
} else if (t.verifyText === undefined) {
|
|
69
|
-
verifyTag = th.fg("dim", " [无需验证]");
|
|
124
|
+
// 标题占 1 行;任务部分超过 WIDGET_MAX_LINES - 1 时启用双列
|
|
125
|
+
const indent = " ";
|
|
126
|
+
if (todoList.length <= SINGLE_COLUMN_BUDGET) {
|
|
127
|
+
for (const line of renderSingleColumn(todoList, th, width, indent)) {
|
|
128
|
+
lines.push(line);
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
for (const line of renderDualColumn(todoList, th, width, indent)) {
|
|
132
|
+
lines.push(line);
|
|
70
133
|
}
|
|
71
|
-
lines.push(` ${mark} ${id} ${text}${verifyTag}`);
|
|
72
134
|
}
|
|
73
135
|
|
|
74
136
|
return lines;
|
|
@@ -76,8 +138,7 @@ export function renderWidgetLines(todoList: Todo[], th: Theme): string[] {
|
|
|
76
138
|
|
|
77
139
|
// ── 列表渲染辅助函数 ─────────────────────────────────
|
|
78
140
|
|
|
79
|
-
|
|
80
|
-
export function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, theme: Theme): string {
|
|
141
|
+
function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, theme: Theme): string {
|
|
81
142
|
if (todoList.length === 0) {
|
|
82
143
|
return theme.fg("dim", "No todos");
|
|
83
144
|
}
|
|
@@ -88,26 +149,14 @@ export function buildTodoListText(todoList: Todo[], options: { expanded: boolean
|
|
|
88
149
|
const mark =
|
|
89
150
|
status === "completed"
|
|
90
151
|
? theme.fg("success", "\u2713")
|
|
91
|
-
: status === "
|
|
92
|
-
? theme.fg("warning", "\
|
|
93
|
-
: status === "
|
|
94
|
-
? theme.fg("
|
|
95
|
-
:
|
|
96
|
-
? theme.fg("error", "\u2717")
|
|
97
|
-
: theme.fg("dim", "\u25cb");
|
|
152
|
+
: status === "in_progress"
|
|
153
|
+
? theme.fg("warning", "\u25cf")
|
|
154
|
+
: status === "cancelled"
|
|
155
|
+
? theme.fg("error", "\u2715")
|
|
156
|
+
: theme.fg("dim", "\u25cb");
|
|
98
157
|
const itemText =
|
|
99
|
-
status === "completed" ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
|
|
100
|
-
|
|
101
|
-
if (status === "verifying") {
|
|
102
|
-
verifyTag = theme.fg("warning", ` [验证中${t.evidence ? ": " + t.evidence.slice(0, 30) : ""}]`);
|
|
103
|
-
} else if (t.verifyText && status !== "completed") {
|
|
104
|
-
verifyTag = theme.fg("warning", " [待验证]");
|
|
105
|
-
} else if (status === "completed" && t.verifyText) {
|
|
106
|
-
verifyTag = theme.fg("success", " [已验证]");
|
|
107
|
-
} else if (t.verifyText === undefined) {
|
|
108
|
-
verifyTag = theme.fg("dim", " [无需验证]");
|
|
109
|
-
}
|
|
110
|
-
listText += `\n${mark} ${theme.fg("accent", `#${t.id}`)} ${itemText}${verifyTag}`;
|
|
158
|
+
status === "completed" || status === "cancelled" ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
|
|
159
|
+
listText += `\n${mark} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
|
|
111
160
|
}
|
|
112
161
|
if (!options.expanded && todoList.length > MAX_COLLAPSED_ITEMS) {
|
|
113
162
|
listText += `\n${theme.fg("dim", `... ${todoList.length - MAX_COLLAPSED_ITEMS} more`)}`;
|
|
@@ -117,7 +166,8 @@ export function buildTodoListText(todoList: Todo[], options: { expanded: boolean
|
|
|
117
166
|
|
|
118
167
|
// ── Tool renderResult handler ────────────────────────
|
|
119
168
|
|
|
120
|
-
|
|
169
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
170
|
+
|
|
121
171
|
export function renderTodoResult(result: unknown, options: { expanded: boolean }, theme: Theme): Text {
|
|
122
172
|
const r = result as { content: Array<{ type: string; text?: string }>; details?: unknown };
|
|
123
173
|
const details = r.details as TodoDetails | undefined;
|
|
@@ -137,17 +187,7 @@ export function renderTodoResult(result: unknown, options: { expanded: boolean }
|
|
|
137
187
|
return new Text(buildTodoListText(todoList, options, theme), 0, 0);
|
|
138
188
|
}
|
|
139
189
|
|
|
140
|
-
case "add":
|
|
141
|
-
const text = r.content[0];
|
|
142
|
-
const msg = text?.type === "text" ? (text.text ?? "") : "";
|
|
143
|
-
const listText = buildTodoListText(todoList, options, theme);
|
|
144
|
-
return new Text(
|
|
145
|
-
theme.fg("success", "\u2713 ") + theme.fg("muted", msg) + "\n\n" + listText,
|
|
146
|
-
0,
|
|
147
|
-
0,
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
|
|
190
|
+
case "add":
|
|
151
191
|
case "update":
|
|
152
192
|
case "delete":
|
|
153
193
|
case "clear": {
|
|
@@ -169,5 +209,3 @@ export function renderTodoResult(result: unknown, options: { expanded: boolean }
|
|
|
169
209
|
}
|
|
170
210
|
}
|
|
171
211
|
|
|
172
|
-
// 重新导出 buildRender 供 tool.ts 使用(避免循环引用)
|
|
173
|
-
export { buildRender };
|
package/src/state.ts
CHANGED
|
@@ -12,11 +12,15 @@ import type { Todo } from "./model";
|
|
|
12
12
|
export interface TodoSessionState {
|
|
13
13
|
todos: Todo[];
|
|
14
14
|
nextId: number;
|
|
15
|
-
//
|
|
15
|
+
// 用户消息轮数与提醒追踪
|
|
16
16
|
userMessageCount: number;
|
|
17
17
|
lastTodoCallCount: number;
|
|
18
18
|
stallNotified: boolean;
|
|
19
19
|
allCompletedAtCount: number | null;
|
|
20
|
+
/** 全部 completed 时已注入 steer,防止重复 */
|
|
21
|
+
completionSteered: boolean;
|
|
22
|
+
/** agent_end 设置、before_agent_start 消费的延迟 steer 消息 */
|
|
23
|
+
pendingSteerMessage: string | null;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
export function createTodoSessionState(): TodoSessionState {
|
|
@@ -27,5 +31,7 @@ export function createTodoSessionState(): TodoSessionState {
|
|
|
27
31
|
lastTodoCallCount: 0,
|
|
28
32
|
stallNotified: false,
|
|
29
33
|
allCompletedAtCount: null,
|
|
34
|
+
completionSteered: false,
|
|
35
|
+
pendingSteerMessage: null,
|
|
30
36
|
};
|
|
31
37
|
}
|