@zhushanwen/pi-todo 0.1.6 → 0.2.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 +199 -594
- package/src/commands.ts +5 -26
- package/src/component.ts +11 -34
- package/src/handlers.ts +72 -108
- package/src/index.ts +2 -3
- package/src/model.ts +25 -137
- package/src/render.ts +101 -66
- package/src/state.ts +7 -1
- package/src/tool.ts +28 -130
package/src/model.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Todo 数据模型 — 纯函数,不依赖 Pi 运行时。
|
|
3
|
-
*
|
|
3
|
+
* 三态: pending → in_progress → completed
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
// ── 数据模型 ─────────────────────────────────────────
|
|
@@ -8,11 +8,7 @@
|
|
|
8
8
|
export interface Todo {
|
|
9
9
|
id: number;
|
|
10
10
|
text: string;
|
|
11
|
-
|
|
12
|
-
status: "pending" | "in_progress" | "verifying" | "completed" | "failed";
|
|
13
|
-
verifyAttempts: number;
|
|
14
|
-
/** 验证结论,verifying/completed 时写入 */
|
|
15
|
-
evidence?: string;
|
|
11
|
+
status: "pending" | "in_progress" | "completed";
|
|
16
12
|
}
|
|
17
13
|
|
|
18
14
|
export interface TodoDetails {
|
|
@@ -30,50 +26,49 @@ export interface TodoDetails {
|
|
|
30
26
|
};
|
|
31
27
|
}
|
|
32
28
|
|
|
33
|
-
export const VALID_STATUSES = ["pending", "in_progress", "
|
|
29
|
+
export const VALID_STATUSES = ["pending", "in_progress", "completed"] as const;
|
|
34
30
|
|
|
35
31
|
export type ValidStatus = (typeof VALID_STATUSES)[number];
|
|
36
32
|
|
|
37
33
|
// ── 迁移/兼容 ───────────────────────────────────────
|
|
38
34
|
|
|
39
|
-
// ── Stale Context Detection ──────────────────────────
|
|
40
|
-
|
|
41
35
|
const STALE_CONTEXT_PATTERNS = ["aborted", "context canceled", "stale context", "stalecontext", "extension context no longer active"];
|
|
42
36
|
|
|
43
|
-
/** 检查错误是否表示 stale / canceled context
|
|
37
|
+
/** 检查错误是否表示 stale / canceled context */
|
|
44
38
|
export function isStaleContextError(error: Error | unknown): boolean {
|
|
45
39
|
const msg = error instanceof Error ? error.message : String(error);
|
|
46
40
|
const lower = msg.toLowerCase();
|
|
47
41
|
return STALE_CONTEXT_PATTERNS.some((p) => lower.includes(p));
|
|
48
42
|
}
|
|
49
43
|
|
|
50
|
-
|
|
51
|
-
/** 兼容旧格式:旧 entry 可能有 done: boolean,转换为 status;旧数据缺少 verifyText/verifyAttempts 时填充默认值 */
|
|
44
|
+
/** 旧格式迁移:verifying → in_progress,failed → pending,done:boolean → status */
|
|
52
45
|
export function migrateTodo(raw: Todo): Todo {
|
|
53
46
|
const record = raw as unknown as Record<string, unknown>;
|
|
54
47
|
const hasValidStatus =
|
|
55
48
|
typeof record.status === "string" &&
|
|
56
49
|
VALID_STATUSES.includes(record.status as ValidStatus);
|
|
57
50
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
51
|
+
let status: ValidStatus;
|
|
52
|
+
if (hasValidStatus) {
|
|
53
|
+
status = record.status as ValidStatus;
|
|
54
|
+
} else {
|
|
55
|
+
// 极旧格式 done: boolean
|
|
56
|
+
const { done } = record as { done?: boolean };
|
|
57
|
+
status = done === true ? "completed" : "pending";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 旧版五态映射(先转 string 避免类型收窄后无法比较)
|
|
61
|
+
const rawStatus = record.status as string | undefined;
|
|
62
|
+
if (rawStatus === "verifying") status = "in_progress";
|
|
63
|
+
if (rawStatus === "failed") status = "pending";
|
|
64
64
|
|
|
65
65
|
return {
|
|
66
66
|
id: record.id as number,
|
|
67
67
|
text: record.text as string,
|
|
68
68
|
status,
|
|
69
|
-
verifyText: typeof record.verifyText === "string" ? (record.verifyText as string) : undefined,
|
|
70
|
-
verifyAttempts: typeof record.verifyAttempts === "number" ? (record.verifyAttempts as number) : 0,
|
|
71
|
-
evidence: typeof record.evidence === "string" ? (record.evidence as string) : undefined,
|
|
72
69
|
};
|
|
73
70
|
}
|
|
74
71
|
|
|
75
|
-
|
|
76
|
-
|
|
77
72
|
// ── 渲染辅助 ─────────────────────────────────────────
|
|
78
73
|
|
|
79
74
|
export function buildRender(todoList: Todo[]): TodoDetails["_render"] {
|
|
@@ -93,7 +88,7 @@ export function getDisplayStatus(t: Todo): string {
|
|
|
93
88
|
return migrateTodo(t).status;
|
|
94
89
|
}
|
|
95
90
|
|
|
96
|
-
// ── Add
|
|
91
|
+
// ── Add 逻辑 ─────────────────────────────────────────
|
|
97
92
|
|
|
98
93
|
export interface AddResult {
|
|
99
94
|
newTodos: Todo[];
|
|
@@ -102,18 +97,10 @@ export interface AddResult {
|
|
|
102
97
|
resultText?: string;
|
|
103
98
|
}
|
|
104
99
|
|
|
105
|
-
/**
|
|
106
|
-
* 处理 todo add 的核心逻辑。
|
|
107
|
-
* @param currentTodos 当前 todo 列表
|
|
108
|
-
* @param currentNextId 当前 nextId
|
|
109
|
-
* @param texts 要添加的文本列表
|
|
110
|
-
* @param verifyTexts 可选的验证文本列表
|
|
111
|
-
*/
|
|
112
100
|
export function addTodos(
|
|
113
101
|
currentTodos: Todo[],
|
|
114
102
|
currentNextId: number,
|
|
115
103
|
texts: string[],
|
|
116
|
-
verifyTexts?: string[],
|
|
117
104
|
): AddResult {
|
|
118
105
|
if (!texts || texts.length === 0) {
|
|
119
106
|
return {
|
|
@@ -134,15 +121,6 @@ export function addTodos(
|
|
|
134
121
|
};
|
|
135
122
|
}
|
|
136
123
|
|
|
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
124
|
const startId = currentNextId;
|
|
147
125
|
const newTodos = [...currentTodos];
|
|
148
126
|
let nextId = currentNextId;
|
|
@@ -151,8 +129,6 @@ export function addTodos(
|
|
|
151
129
|
id: nextId++,
|
|
152
130
|
text: trimmed[i],
|
|
153
131
|
status: "pending" as const,
|
|
154
|
-
verifyText: verifyTexts?.[i],
|
|
155
|
-
verifyAttempts: 0,
|
|
156
132
|
});
|
|
157
133
|
}
|
|
158
134
|
const endId = nextId - 1;
|
|
@@ -164,36 +140,18 @@ export function addTodos(
|
|
|
164
140
|
};
|
|
165
141
|
}
|
|
166
142
|
|
|
167
|
-
// ── Update
|
|
143
|
+
// ── Update 逻辑 ──────────────────────────────────────
|
|
168
144
|
|
|
169
145
|
export interface UpdateResult {
|
|
170
146
|
updatedTodos: Todo[];
|
|
171
147
|
error?: string;
|
|
172
148
|
resultText?: string;
|
|
173
|
-
/** 拦截的任务 ID 及其原因 */
|
|
174
|
-
blocked?: Array<{ id: number; text: string; reason: string }>;
|
|
175
149
|
}
|
|
176
150
|
|
|
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
151
|
export function updateTodos(
|
|
193
152
|
currentTodos: Todo[],
|
|
194
|
-
updates: Array<{ id: number; status?: string; text?: string
|
|
153
|
+
updates: Array<{ id: number; status?: string; text?: string }>,
|
|
195
154
|
): UpdateResult {
|
|
196
|
-
// 验证: no duplicate ids
|
|
197
155
|
const ids = updates.map((u) => u.id);
|
|
198
156
|
if (new Set(ids).size !== ids.length) {
|
|
199
157
|
return {
|
|
@@ -202,7 +160,6 @@ export function updateTodos(
|
|
|
202
160
|
resultText: "Error: duplicate ids in updates",
|
|
203
161
|
};
|
|
204
162
|
}
|
|
205
|
-
// 验证: all ids exist and each has at least one change, and valid status
|
|
206
163
|
for (const u of updates) {
|
|
207
164
|
const todo = currentTodos.find((t) => t.id === u.id);
|
|
208
165
|
if (!todo) {
|
|
@@ -228,68 +185,12 @@ export function updateTodos(
|
|
|
228
185
|
}
|
|
229
186
|
}
|
|
230
187
|
|
|
231
|
-
// 状态转换拦截
|
|
232
|
-
const blockedItems: Array<{ id: number; text: string; reason: string }> = [];
|
|
233
|
-
|
|
234
|
-
for (const u of updates) {
|
|
235
|
-
if (!u.status) continue;
|
|
236
|
-
const todo = currentTodos.find((t) => t.id === u.id)!;
|
|
237
|
-
|
|
238
|
-
if (u.status === "verifying") {
|
|
239
|
-
// → verifying: 必须有 verifyText + evidence
|
|
240
|
-
if (!todo.verifyText) {
|
|
241
|
-
blockedItems.push({ id: todo.id, text: todo.text, reason: "无 verifyText 的任务不能进入 verifying 状态" });
|
|
242
|
-
} else if (!u.evidence || u.evidence.trim().length < MIN_EVIDENCE_LENGTH) {
|
|
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
|
-
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
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
188
|
const updated = currentTodos.map((t) => {
|
|
275
189
|
const u = updates.find((u) => u.id === t.id);
|
|
276
190
|
if (!u) return t;
|
|
277
191
|
const patch: Partial<Todo> = {};
|
|
278
192
|
if (u.status) patch.status = u.status as Todo["status"];
|
|
279
193
|
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
194
|
return { ...t, ...patch };
|
|
294
195
|
});
|
|
295
196
|
return {
|
|
@@ -300,25 +201,12 @@ export function updateTodos(
|
|
|
300
201
|
|
|
301
202
|
// ── 格式化辅助 ───────────────────────────────────────
|
|
302
203
|
|
|
303
|
-
/** 格式化单条 todo 为纯文本行(AI 可读),含 verifyText 原文 */
|
|
304
204
|
export function formatTodoLine(t: Todo): string {
|
|
305
205
|
const mark =
|
|
306
206
|
t.status === "completed"
|
|
307
207
|
? "x"
|
|
308
|
-
: t.status === "
|
|
309
|
-
? "
|
|
310
|
-
:
|
|
311
|
-
|
|
312
|
-
: t.status === "failed"
|
|
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;
|
|
208
|
+
: t.status === "in_progress"
|
|
209
|
+
? "~"
|
|
210
|
+
: " ";
|
|
211
|
+
return `[${mark}] #${t.id}: ${t.text}`;
|
|
324
212
|
}
|
package/src/render.ts
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
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
9
|
buildRender,
|
|
@@ -16,59 +12,123 @@ import {
|
|
|
16
12
|
type TodoDetails,
|
|
17
13
|
} from "./model";
|
|
18
14
|
|
|
15
|
+
// ── 常量 ────────────────────────────────────────────
|
|
16
|
+
|
|
19
17
|
const MAX_COLLAPSED_ITEMS = 5;
|
|
18
|
+
export const FALLBACK_TERM_WIDTH = 80;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Pi 对单个 extension widget 的最大字符串行数为 10(InteractiveMode.MAX_WIDGET_LINES)。
|
|
22
|
+
* 扩展侧保守使用 max - 1 = 9 行作为阈值,超过时切换为双列布局,避免触发截断。
|
|
23
|
+
*/
|
|
24
|
+
const WIDGET_MAX_LINES = 9;
|
|
25
|
+
const SINGLE_COLUMN_BUDGET = WIDGET_MAX_LINES - 1;
|
|
26
|
+
|
|
27
|
+
/** 垂直分割线视觉宽度(" │ ") */
|
|
28
|
+
const DIVIDER_VISUAL_WIDTH = 3;
|
|
29
|
+
const ELLIPSIS_MIN_WIDTH = 3;
|
|
30
|
+
|
|
31
|
+
/** 截断或补齐到精确视觉宽度,截断时追加 "..." */
|
|
32
|
+
function fixedWidth(text: string, width: number): string {
|
|
33
|
+
const len = visibleWidth(text);
|
|
34
|
+
if (len <= width) {
|
|
35
|
+
return text + " ".repeat(width - len);
|
|
36
|
+
}
|
|
37
|
+
if (width <= ELLIPSIS_MIN_WIDTH) return "...".slice(0, width);
|
|
38
|
+
return truncateToWidth(text, width - ELLIPSIS_MIN_WIDTH) + "...";
|
|
39
|
+
}
|
|
20
40
|
|
|
21
41
|
// ── 状态栏 ────────────────────────────────────────────
|
|
22
42
|
|
|
23
|
-
/** 渲染状态栏文本 */
|
|
24
43
|
export function renderStatusText(todoList: Todo[], th: Theme): string {
|
|
25
44
|
if (todoList.length === 0) return "";
|
|
26
45
|
|
|
27
46
|
const completed = todoList.filter((t) => getDisplayStatus(t) === "completed").length;
|
|
28
47
|
const total = todoList.length;
|
|
29
48
|
|
|
30
|
-
// 全部完成
|
|
31
49
|
if (completed === total) {
|
|
32
50
|
return th.fg("success", `\u2713 ${completed}/${total}`);
|
|
33
51
|
}
|
|
34
|
-
// 有未完成
|
|
35
52
|
return th.fg("accent", "\u2611") + th.fg("muted", ` ${completed}/${total}`);
|
|
36
53
|
}
|
|
37
54
|
|
|
38
|
-
|
|
39
|
-
|
|
55
|
+
// ── Widget 双列渲染 ──────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/** 渲染单条 todo 的 widget 行(不含缩进),供 component.ts 复用 */
|
|
58
|
+
export function renderWidgetItem(t: Todo, th: Theme): string {
|
|
59
|
+
const mark =
|
|
60
|
+
t.status === "completed"
|
|
61
|
+
? th.fg("success", "\u2713")
|
|
62
|
+
: t.status === "in_progress"
|
|
63
|
+
? th.fg("warning", "\u25cf")
|
|
64
|
+
: th.fg("dim", "\u25cb");
|
|
65
|
+
const id = th.fg("accent", `#${t.id}`);
|
|
66
|
+
const text = t.status === "completed" ? th.fg("dim", t.text) : th.fg("text", t.text);
|
|
67
|
+
return `${mark} ${id} ${text}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 单列布局渲染(widget 少量任务时使用) */
|
|
71
|
+
const PI_TEXT_PADDING = 2;
|
|
72
|
+
|
|
73
|
+
export function renderSingleColumn(
|
|
74
|
+
todos: Todo[],
|
|
75
|
+
th: Theme,
|
|
76
|
+
termWidth: number,
|
|
77
|
+
indent: string,
|
|
78
|
+
): string[] {
|
|
79
|
+
const maxWidth = Math.max(1, termWidth - PI_TEXT_PADDING);
|
|
80
|
+
return todos.map((t) => truncateToWidth(indent + renderWidgetItem(t, th), maxWidth));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 双列布局渲染,供 widget 和 component 复用 */
|
|
84
|
+
const COLUMN_COUNT = 2;
|
|
85
|
+
|
|
86
|
+
export function renderDualColumn(
|
|
87
|
+
todos: Todo[],
|
|
88
|
+
th: Theme,
|
|
89
|
+
termWidth: number,
|
|
90
|
+
indent: string,
|
|
91
|
+
): string[] {
|
|
92
|
+
const colWidth = Math.floor((termWidth - indent.length - DIVIDER_VISUAL_WIDTH) / COLUMN_COUNT);
|
|
93
|
+
const lines: string[] = [];
|
|
94
|
+
const half = Math.ceil(todos.length / COLUMN_COUNT);
|
|
95
|
+
const divider = " " + th.fg("borderMuted", "\u2502") + " ";
|
|
96
|
+
for (let row = 0; row < half; row++) {
|
|
97
|
+
const left = fixedWidth(indent + renderWidgetItem(todos[row], th), colWidth);
|
|
98
|
+
const rightIdx = row + half;
|
|
99
|
+
const right = rightIdx < todos.length
|
|
100
|
+
? fixedWidth(renderWidgetItem(todos[rightIdx], th), colWidth)
|
|
101
|
+
: " ".repeat(colWidth);
|
|
102
|
+
lines.push(left + divider + right);
|
|
103
|
+
}
|
|
104
|
+
return lines;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 渲染 widget 行(根据任务数自动选择单列或双列布局) */
|
|
108
|
+
export function renderWidgetLines(
|
|
109
|
+
todoList: Todo[],
|
|
110
|
+
th: Theme,
|
|
111
|
+
termWidth?: number,
|
|
112
|
+
): string[] {
|
|
40
113
|
if (todoList.length === 0) return [];
|
|
41
114
|
|
|
115
|
+
const width = termWidth ?? (process.stdout.columns || FALLBACK_TERM_WIDTH);
|
|
42
116
|
const lines: string[] = [];
|
|
43
117
|
const completed = todoList.filter((t) => getDisplayStatus(t) === "completed").length;
|
|
44
118
|
const total = todoList.length;
|
|
45
119
|
|
|
46
120
|
lines.push(th.fg("accent", "\u2611") + th.fg("muted", ` ${completed}/${total}`));
|
|
47
121
|
|
|
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", " [无需验证]");
|
|
122
|
+
// 标题占 1 行;任务部分超过 WIDGET_MAX_LINES - 1 时启用双列
|
|
123
|
+
const indent = " ";
|
|
124
|
+
if (todoList.length <= SINGLE_COLUMN_BUDGET) {
|
|
125
|
+
for (const line of renderSingleColumn(todoList, th, width, indent)) {
|
|
126
|
+
lines.push(line);
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
for (const line of renderDualColumn(todoList, th, width, indent)) {
|
|
130
|
+
lines.push(line);
|
|
70
131
|
}
|
|
71
|
-
lines.push(` ${mark} ${id} ${text}${verifyTag}`);
|
|
72
132
|
}
|
|
73
133
|
|
|
74
134
|
return lines;
|
|
@@ -76,7 +136,6 @@ export function renderWidgetLines(todoList: Todo[], th: Theme): string[] {
|
|
|
76
136
|
|
|
77
137
|
// ── 列表渲染辅助函数 ─────────────────────────────────
|
|
78
138
|
|
|
79
|
-
/** 拼装 todo 列表的纯文本表示(AI/工具结果消费) */
|
|
80
139
|
export function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, theme: Theme): string {
|
|
81
140
|
if (todoList.length === 0) {
|
|
82
141
|
return theme.fg("dim", "No todos");
|
|
@@ -88,26 +147,12 @@ export function buildTodoListText(todoList: Todo[], options: { expanded: boolean
|
|
|
88
147
|
const mark =
|
|
89
148
|
status === "completed"
|
|
90
149
|
? theme.fg("success", "\u2713")
|
|
91
|
-
: status === "
|
|
92
|
-
? theme.fg("warning", "\
|
|
93
|
-
:
|
|
94
|
-
? theme.fg("warning", "\u25cf")
|
|
95
|
-
: status === "failed"
|
|
96
|
-
? theme.fg("error", "\u2717")
|
|
97
|
-
: theme.fg("dim", "\u25cb");
|
|
150
|
+
: status === "in_progress"
|
|
151
|
+
? theme.fg("warning", "\u25cf")
|
|
152
|
+
: theme.fg("dim", "\u25cb");
|
|
98
153
|
const itemText =
|
|
99
154
|
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}`;
|
|
155
|
+
listText += `\n${mark} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
|
|
111
156
|
}
|
|
112
157
|
if (!options.expanded && todoList.length > MAX_COLLAPSED_ITEMS) {
|
|
113
158
|
listText += `\n${theme.fg("dim", `... ${todoList.length - MAX_COLLAPSED_ITEMS} more`)}`;
|
|
@@ -117,7 +162,8 @@ export function buildTodoListText(todoList: Todo[], options: { expanded: boolean
|
|
|
117
162
|
|
|
118
163
|
// ── Tool renderResult handler ────────────────────────
|
|
119
164
|
|
|
120
|
-
|
|
165
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
166
|
+
|
|
121
167
|
export function renderTodoResult(result: unknown, options: { expanded: boolean }, theme: Theme): Text {
|
|
122
168
|
const r = result as { content: Array<{ type: string; text?: string }>; details?: unknown };
|
|
123
169
|
const details = r.details as TodoDetails | undefined;
|
|
@@ -137,17 +183,7 @@ export function renderTodoResult(result: unknown, options: { expanded: boolean }
|
|
|
137
183
|
return new Text(buildTodoListText(todoList, options, theme), 0, 0);
|
|
138
184
|
}
|
|
139
185
|
|
|
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
|
-
|
|
186
|
+
case "add":
|
|
151
187
|
case "update":
|
|
152
188
|
case "delete":
|
|
153
189
|
case "clear": {
|
|
@@ -169,5 +205,4 @@ export function renderTodoResult(result: unknown, options: { expanded: boolean }
|
|
|
169
205
|
}
|
|
170
206
|
}
|
|
171
207
|
|
|
172
|
-
// 重新导出 buildRender 供 tool.ts 使用(避免循环引用)
|
|
173
208
|
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
|
}
|