@ilikexiaoni/pi-task-list 0.4.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.
@@ -0,0 +1,527 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
3
+ import { matchesKey, Text, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
4
+ import { Type } from "typebox";
5
+ import {
6
+ TASK_PRIORITIES,
7
+ TASK_STATUSES,
8
+ applyTaskAction,
9
+ cloneState,
10
+ filterTasks,
11
+ isTaskReady,
12
+ normalizeState,
13
+ priorityLabel,
14
+ statusLabel,
15
+ summarizeState,
16
+ type Task,
17
+ type TaskAction,
18
+ type TaskCommand,
19
+ type TaskPriority,
20
+ type TaskState,
21
+ type TaskStatus,
22
+ } from "../lib/task-list-core.ts";
23
+
24
+ const TOOL_NAME = "task_list";
25
+ const WIDGET_KEY = "task-list-widget";
26
+ const STATUS_KEY = "task-list-status";
27
+
28
+ interface TaskListDetails {
29
+ action: TaskAction;
30
+ state: TaskState;
31
+ changedIds: number[];
32
+ visibleTasks: Task[];
33
+ selectedTask?: Task;
34
+ message: string;
35
+ }
36
+
37
+ const TaskListParams = Type.Object({
38
+ action: StringEnum(["create", "update", "list", "get", "clear"] as const),
39
+ id: Type.Optional(Type.Number({ minimum: 1, description: "目标任务 ID" })),
40
+ subject: Type.Optional(Type.String({ description: "任务标题;create 必填" })),
41
+ description: Type.Optional(Type.String({ description: "任务背景和范围" })),
42
+ activeForm: Type.Optional(Type.String({ description: "进行中时显示的动作短语" })),
43
+ acceptanceCriteria: Type.Optional(Type.Array(Type.String(), { maxItems: 50, description: "可验收的完成条件" })),
44
+ evidence: Type.Optional(Type.Array(Type.String(), { maxItems: 50, description: "已取得的证据;可替换现有证据" })),
45
+ addEvidence: Type.Optional(Type.Array(Type.String(), { maxItems: 50, description: "追加证据" })),
46
+ status: Type.Optional(StringEnum(TASK_STATUSES)),
47
+ priority: Type.Optional(StringEnum(TASK_PRIORITIES)),
48
+ blockedBy: Type.Optional(Type.Array(Type.Number({ minimum: 1 }), { maxItems: 50, description: "硬依赖任务 ID" })),
49
+ addBlockedBy: Type.Optional(Type.Array(Type.Number({ minimum: 1 }), { maxItems: 50 })),
50
+ removeBlockedBy: Type.Optional(Type.Array(Type.Number({ minimum: 1 }), { maxItems: 50 })),
51
+ blockReason: Type.Optional(Type.String({ description: "阻塞原因;空字符串可清除" })),
52
+ tags: Type.Optional(Type.Array(Type.String(), { maxItems: 50 })),
53
+ addTags: Type.Optional(Type.Array(Type.String(), { maxItems: 50 })),
54
+ removeTags: Type.Optional(Type.Array(Type.String(), { maxItems: 50 })),
55
+ owner: Type.Optional(Type.String({ description: "负责人或执行单元" })),
56
+ completionNote: Type.Optional(Type.String({ description: "完成结论或验收说明" })),
57
+ note: Type.Optional(Type.String({ description: "追加一条进度记录" })),
58
+ query: Type.Optional(Type.String({ description: "list 的标题、描述、标签搜索词" })),
59
+ includeCancelled: Type.Optional(Type.Boolean()),
60
+ scope: Type.Optional(StringEnum(["finished", "all"] as const)),
61
+ confirm: Type.Optional(Type.Boolean({ description: "clear 的显式确认" })),
62
+ });
63
+
64
+ function compact(value: string, max = 76): string {
65
+ const text = value.replace(/\s+/g, " ").trim();
66
+ return text.length > max ? `${text.slice(0, Math.max(1, max - 3))}...` : text;
67
+ }
68
+
69
+ function priorityToken(priority: TaskPriority): string {
70
+ return priority === "high" ? "H" : priority === "medium" ? "M" : "L";
71
+ }
72
+
73
+ function taskStateSummary(state: TaskState): string {
74
+ const summary = summarizeState(state);
75
+ return `${summary.completed}/${summary.total} 已完成 | ${summary.active} 进行中 | ${summary.blocked} 阻塞 | ${summary.ready} 可开始${summary.cancelled ? ` | ${summary.cancelled} 已取消` : ""}`;
76
+ }
77
+
78
+ function taskLine(task: Task, state: TaskState, detailed = false): string[] {
79
+ const lines = [`#${task.id} [${statusLabel(task.status)}] [${priorityLabel(task.priority)}] ${task.subject}`];
80
+ if (detailed && task.activeForm && task.activeForm !== task.subject) lines.push(` 当前动作:${task.activeForm}`);
81
+ if (detailed && task.description) lines.push(` 说明:${task.description}`);
82
+ if (task.acceptanceCriteria.length > 0) {
83
+ lines.push(` 完成条件:${task.acceptanceCriteria.length} 项,证据 ${task.evidence.length} 项`);
84
+ if (detailed) task.acceptanceCriteria.forEach((item, index) => lines.push(` ${index + 1}. ${item}`));
85
+ }
86
+ if (task.evidence.length > 0 && detailed) task.evidence.forEach((item) => lines.push(` 证据:${item}`));
87
+ if (task.blockedBy.length > 0) lines.push(` 依赖:${task.blockedBy.map((id) => `#${id}`).join(", ")}`);
88
+ if (task.status === "blocked" && task.blockReason) lines.push(` 阻塞原因:${task.blockReason}`);
89
+ if (task.owner) lines.push(` 负责人:${task.owner}`);
90
+ if (task.tags.length > 0) lines.push(` 标签:${task.tags.join(", ")}`);
91
+ if (task.completionNote && task.status === "completed") lines.push(` 完成说明:${task.completionNote}`);
92
+ if (detailed && task.notes.length > 0) {
93
+ for (const note of task.notes.slice(-3)) lines.push(` 记录${note.at ? ` [${note.at}]` : ""}:${note.text}`);
94
+ }
95
+ if (!detailed && task.status === "pending" && !isTaskReady(state, task)) lines.push(" 等待依赖完成");
96
+ return lines;
97
+ }
98
+
99
+ function formatForModel(result: Pick<ApplyResultLike, "action" | "message" | "visibleTasks" | "selectedTask" | "state">): string {
100
+ const lines = [result.message, taskStateSummary(result.state)];
101
+ const tasks = result.action === "get" && result.selectedTask ? [result.selectedTask] : result.visibleTasks;
102
+ if (tasks.length === 0) {
103
+ lines.push("(没有符合条件的任务)");
104
+ } else {
105
+ const display = result.action === "list" ? tasks.slice(0, 50) : tasks.slice(0, 5);
106
+ for (const task of display) lines.push(...taskLine(task, result.state, result.action === "get"));
107
+ if (display.length < tasks.length) lines.push(`... 还有 ${tasks.length - display.length} 个任务,请使用 list 的 query/status 分批查看`);
108
+ }
109
+ return lines.join("\n");
110
+ }
111
+
112
+ interface ApplyResultLike {
113
+ action: TaskAction;
114
+ message: string;
115
+ visibleTasks: Task[];
116
+ selectedTask?: Task;
117
+ state: TaskState;
118
+ }
119
+
120
+ interface CompactWidgetTask {
121
+ id: number;
122
+ priority: TaskPriority;
123
+ subject: string;
124
+ activeForm: string;
125
+ blockReason: string;
126
+ }
127
+
128
+ interface CompactWidgetModel {
129
+ summary: ReturnType<typeof summarizeState>;
130
+ active?: CompactWidgetTask;
131
+ blocked?: CompactWidgetTask;
132
+ ready?: CompactWidgetTask;
133
+ recent?: CompactWidgetTask;
134
+ activeCount: number;
135
+ blockedCount: number;
136
+ readyCount: number;
137
+ }
138
+
139
+ function compactWidgetTask(task: Task | undefined): CompactWidgetTask | undefined {
140
+ if (!task) return undefined;
141
+ return {
142
+ id: task.id,
143
+ priority: task.priority,
144
+ subject: compact(task.subject),
145
+ activeForm: compact(task.activeForm || task.subject),
146
+ blockReason: compact(task.blockReason, 60),
147
+ };
148
+ }
149
+
150
+ function preferredWidgetTask(current: Task | undefined, candidate: Task): Task {
151
+ if (!current) return candidate;
152
+ const rank: Record<TaskPriority, number> = { high: 0, medium: 1, low: 2 };
153
+ return rank[candidate.priority] < rank[current.priority] || (candidate.priority === current.priority && candidate.id < current.id)
154
+ ? candidate
155
+ : current;
156
+ }
157
+
158
+ function compactWidgetModel(state: TaskState): CompactWidgetModel {
159
+ const summary = summarizeState(state);
160
+ const statusById = new Map(state.tasks.map((task) => [task.id, task.status]));
161
+ let active: Task | undefined;
162
+ let blocked: Task | undefined;
163
+ let ready: Task | undefined;
164
+ let recent: Task | undefined;
165
+
166
+ for (const task of state.tasks) {
167
+ if (task.status === "in_progress") active = preferredWidgetTask(active, task);
168
+ if (task.status === "blocked") blocked = preferredWidgetTask(blocked, task);
169
+ if (task.status === "pending") {
170
+ const dependenciesResolved = task.blockedBy.every((id) => {
171
+ const status = statusById.get(id);
172
+ return status === undefined || status === "completed" || status === "cancelled";
173
+ });
174
+ if (dependenciesResolved) ready = preferredWidgetTask(ready, task);
175
+ }
176
+ if (
177
+ task.status === "completed" &&
178
+ (!recent || task.updatedAt > recent.updatedAt || (task.updatedAt === recent.updatedAt && task.id > recent.id))
179
+ ) {
180
+ recent = task;
181
+ }
182
+ }
183
+
184
+ return {
185
+ summary,
186
+ active: compactWidgetTask(active),
187
+ blocked: compactWidgetTask(blocked),
188
+ ready: compactWidgetTask(ready),
189
+ recent: compactWidgetTask(recent),
190
+ activeCount: summary.active,
191
+ blockedCount: summary.blocked,
192
+ readyCount: summary.ready,
193
+ };
194
+ }
195
+
196
+ function extraCount(count: number): string {
197
+ return count > 1 ? ` +${count - 1}` : "";
198
+ }
199
+
200
+ function compactWidgetLines(model: CompactWidgetModel, theme: Theme, width: number): string[] {
201
+ const { summary } = model;
202
+ const percent = summary.total === 0 ? 0 : Math.round((summary.completed / summary.total) * 100);
203
+ const barWidth = width >= 88 ? 12 : width >= 60 ? 8 : 5;
204
+ const filled = summary.total === 0 ? 0 : Math.round((summary.completed / summary.total) * barWidth);
205
+ const barColor = percent === 100 ? "success" : "accent";
206
+ const bar = theme.fg(barColor, "━".repeat(filled)) + theme.fg("borderMuted", "─".repeat(barWidth - filled));
207
+ const counts = width >= 66 ? `进行中 ${summary.active} 阻塞 ${summary.blocked}` : `A${summary.active} B${summary.blocked}`;
208
+ const header = `${theme.fg("borderMuted", "┌─")} ${theme.bold(theme.fg("accent", "TASKS"))} ${theme.fg("text", `${summary.completed}/${summary.total}`)} ${bar} ${theme.fg("muted", `${percent}%`)} ${summary.blocked > 0 ? theme.fg("error", counts) : theme.fg("muted", counts)}`;
209
+
210
+ const activeBody = model.active
211
+ ? `${theme.fg("accent", `#${model.active.id}`)} ${theme.fg("muted", `[${priorityToken(model.active.priority)}]`)} ${theme.fg("text", model.active.activeForm)}${theme.fg("muted", extraCount(model.activeCount))}`
212
+ : theme.fg("muted", "暂无进行中任务");
213
+
214
+ const focusBody = model.blocked
215
+ ? `${theme.fg("error", `#${model.blocked.id}`)} ${theme.fg("text", model.blocked.subject)}${model.blocked.blockReason ? theme.fg("muted", ` · ${model.blocked.blockReason}`) : ""}${theme.fg("muted", extraCount(model.blockedCount))}`
216
+ : model.ready
217
+ ? `${theme.fg("accent", `#${model.ready.id}`)} ${theme.fg("muted", `[${priorityToken(model.ready.priority)}]`)} ${theme.fg("text", model.ready.subject)}${theme.fg("muted", extraCount(model.readyCount))}`
218
+ : theme.fg("muted", "暂无可开始任务");
219
+
220
+ const recentBody = model.recent
221
+ ? `${theme.fg("success", `#${model.recent.id}`)} ${theme.fg("muted", model.recent.subject)}${summary.completed > 1 ? theme.fg("muted", ` · 另 ${summary.completed - 1} 项已完成`) : ""}`
222
+ : theme.fg("muted", "尚无已完成任务");
223
+
224
+ const focusLabel = model.blocked ? theme.fg("error", "! 阻塞 ") : theme.fg("accent", "○ 下一步");
225
+ const lines = [
226
+ header,
227
+ `${theme.fg("borderMuted", "│")} ${theme.fg("accent", "▶ 当前 ")} ${activeBody}`,
228
+ `${theme.fg("borderMuted", "│")} ${focusLabel} ${focusBody}`,
229
+ `${theme.fg("borderMuted", "│")} ${theme.fg("success", "✓ 最近 ")} ${recentBody}`,
230
+ `${theme.fg("borderMuted", "└─")} ${theme.fg("accent", "/tasks")} ${theme.fg("muted", "完整清单 · 验收条件 · 证据")}`,
231
+ ];
232
+
233
+ return lines.map((line) => truncateToWidth(line, Math.max(1, width)));
234
+ }
235
+
236
+ class CompactTaskWidget {
237
+ private cachedWidth?: number;
238
+ private cachedLines?: string[];
239
+
240
+ constructor(
241
+ private model: CompactWidgetModel,
242
+ private fingerprint: string,
243
+ private readonly theme: Theme,
244
+ private readonly tui: TUI,
245
+ ) {}
246
+
247
+ update(model: CompactWidgetModel, fingerprint: string): boolean {
248
+ this.model = model;
249
+ if (fingerprint === this.fingerprint) return false;
250
+ this.fingerprint = fingerprint;
251
+ this.invalidate();
252
+ this.tui.requestRender();
253
+ return true;
254
+ }
255
+
256
+ render(width: number): string[] {
257
+ if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
258
+ this.cachedWidth = width;
259
+ this.cachedLines = compactWidgetLines(this.model, this.theme, width);
260
+ return this.cachedLines;
261
+ }
262
+
263
+ invalidate(): void {
264
+ this.cachedWidth = undefined;
265
+ this.cachedLines = undefined;
266
+ }
267
+ }
268
+
269
+ function hasPersistedState(value: unknown): value is { state?: unknown; tasks?: unknown[] } {
270
+ return typeof value === "object" && value !== null && ("state" in value || "tasks" in value);
271
+ }
272
+
273
+ function restoreState(ctx: ExtensionContext): TaskState {
274
+ let restored: TaskState | undefined;
275
+ for (const entry of ctx.sessionManager.getBranch()) {
276
+ if (entry.type === "custom" && entry.customType === "task-list-state" && hasPersistedState(entry.data)) {
277
+ restored = normalizeState(entry.data.state ?? entry.data);
278
+ continue;
279
+ }
280
+ if (entry.type !== "message") continue;
281
+ const message = entry.message;
282
+ if (message.role !== "toolResult" || message.toolName !== TOOL_NAME) continue;
283
+ const details = message.details as Partial<TaskListDetails> | undefined;
284
+ if (details?.state) restored = normalizeState(details.state);
285
+ }
286
+ return restored ?? normalizeState(undefined);
287
+ }
288
+
289
+ function statusColor(theme: Theme, status: TaskStatus, text: string): string {
290
+ switch (status) {
291
+ case "completed":
292
+ return theme.fg("success", text);
293
+ case "blocked":
294
+ return theme.fg("error", text);
295
+ case "in_progress":
296
+ return theme.fg("accent", text);
297
+ case "cancelled":
298
+ return theme.fg("dim", text);
299
+ default:
300
+ return theme.fg("text", text);
301
+ }
302
+ }
303
+
304
+ class TaskListComponent {
305
+ private readonly state: TaskState;
306
+ private readonly theme: Theme;
307
+ private readonly title: string;
308
+ private cachedWidth?: number;
309
+ private cachedLines?: string[];
310
+ private readonly onClose: () => void;
311
+
312
+ constructor(state: TaskState, theme: Theme, title: string, onClose: () => void) {
313
+ this.state = state;
314
+ this.theme = theme;
315
+ this.title = title;
316
+ this.onClose = onClose;
317
+ }
318
+
319
+ handleInput(data: string): void {
320
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) this.onClose();
321
+ }
322
+
323
+ render(width: number): string[] {
324
+ if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
325
+ const lines: string[] = [];
326
+ const summary = summarizeState(this.state);
327
+ const header = this.theme.fg("borderMuted", "─".repeat(3)) + this.theme.fg("accent", ` ${this.title} `) + this.theme.fg("borderMuted", "─".repeat(Math.max(0, width - 12)));
328
+ lines.push("");
329
+ lines.push(truncateToWidth(header, width));
330
+ lines.push(truncateToWidth(` ${taskStateSummary(this.state)}`, width));
331
+ lines.push("");
332
+
333
+ const tasks = filterTasks(this.state, { includeCancelled: true });
334
+ if (tasks.length === 0) {
335
+ lines.push(truncateToWidth(` ${this.theme.fg("dim", "当前没有任务")}`, width));
336
+ } else {
337
+ for (const task of tasks) {
338
+ const mark = task.status === "completed" ? "[x]" : task.status === "cancelled" ? "[-]" : task.status === "blocked" ? "[!]" : task.status === "in_progress" ? "[>]" : "[ ]";
339
+ lines.push(truncateToWidth(` ${statusColor(this.theme, task.status, mark)} ${this.theme.fg("accent", `#${task.id}`)} [${priorityLabel(task.priority)}] ${task.subject}`, width));
340
+ if (task.status === "in_progress") lines.push(truncateToWidth(` 动作:${task.activeForm}`, width));
341
+ if (task.description) lines.push(truncateToWidth(` 说明:${task.description}`, width));
342
+ if (task.acceptanceCriteria.length > 0) {
343
+ lines.push(truncateToWidth(` 完成条件:${task.evidence.length}/${task.acceptanceCriteria.length} 项已有证据`, width));
344
+ for (const criterion of task.acceptanceCriteria) {
345
+ lines.push(truncateToWidth(` - ${criterion}`, width));
346
+ }
347
+ }
348
+ if (task.evidence.length > 0) {
349
+ lines.push(truncateToWidth(" 证据:", width));
350
+ for (const evidence of task.evidence) lines.push(truncateToWidth(` - ${evidence}`, width));
351
+ }
352
+ if (task.blockedBy.length > 0) lines.push(truncateToWidth(` 依赖:${task.blockedBy.map((id) => `#${id}`).join(", ")}`, width));
353
+ if (task.status === "blocked" && task.blockReason) lines.push(truncateToWidth(` 阻塞:${task.blockReason}`, width));
354
+ if (task.notes.length > 0) lines.push(truncateToWidth(` 最近记录:${task.notes.at(-1)?.text ?? ""}`, width));
355
+ if (task.status === "completed" && task.completionNote) lines.push(truncateToWidth(` 完成说明:${task.completionNote}`, width));
356
+ }
357
+ }
358
+ lines.push("");
359
+ lines.push(truncateToWidth(` ${this.theme.fg("dim", "按 Escape 关闭")}`, width));
360
+ lines.push("");
361
+ this.cachedWidth = width;
362
+ this.cachedLines = lines;
363
+ return lines;
364
+ }
365
+
366
+ invalidate(): void {
367
+ this.cachedWidth = undefined;
368
+ this.cachedLines = undefined;
369
+ }
370
+ }
371
+
372
+ function commandFilter(state: TaskState, args: string): { state: TaskState; title: string } {
373
+ const filter = args.trim();
374
+ if (!filter || filter.toLowerCase() === "all") return { state: cloneState(state), title: "Tasks" };
375
+ const lower = filter.toLowerCase();
376
+ if (/^#?\d+$/.test(lower)) {
377
+ const id = Number(lower.replace(/^#/, ""));
378
+ return { state: { ...cloneState(state), tasks: state.tasks.filter((task) => task.id === id) }, title: `Task #${id}` };
379
+ }
380
+ const statusMap: Record<string, TaskStatus> = {
381
+ active: "in_progress",
382
+ in_progress: "in_progress",
383
+ blocked: "blocked",
384
+ pending: "pending",
385
+ completed: "completed",
386
+ done: "completed",
387
+ cancelled: "cancelled",
388
+ };
389
+ if (statusMap[lower]) {
390
+ return { state: { ...cloneState(state), tasks: filterTasks(state, { status: statusMap[lower], includeCancelled: true }) }, title: `Tasks: ${statusLabel(statusMap[lower])}` };
391
+ }
392
+ return { state: { ...cloneState(state), tasks: filterTasks(state, { query: filter, includeCancelled: true }) }, title: `Tasks: ${filter}` };
393
+ }
394
+
395
+ export default function taskListExtension(pi: ExtensionAPI) {
396
+ let state = normalizeState(undefined);
397
+ let compactWidget: CompactTaskWidget | undefined;
398
+ let rpcWidgetFingerprint: string | undefined;
399
+ let lastStatusText: string | undefined;
400
+
401
+ const refreshUi = (ctx: ExtensionContext): void => {
402
+ if (!ctx.hasUI) return;
403
+
404
+ const model = compactWidgetModel(state);
405
+ const fingerprint = JSON.stringify(model);
406
+ if (ctx.mode === "tui") {
407
+ if (compactWidget) {
408
+ compactWidget.update(model, fingerprint);
409
+ } else {
410
+ ctx.ui.setWidget(WIDGET_KEY, (tui, theme) => {
411
+ compactWidget = new CompactTaskWidget(model, fingerprint, theme, tui);
412
+ return compactWidget;
413
+ });
414
+ }
415
+ } else if (rpcWidgetFingerprint !== fingerprint) {
416
+ ctx.ui.setWidget(WIDGET_KEY, compactWidgetLines(model, ctx.ui.theme, 96));
417
+ rpcWidgetFingerprint = fingerprint;
418
+ }
419
+
420
+ const currentText = model.active ? `#${model.active.id} ${compact(model.active.activeForm, 38)}` : "无进行中任务";
421
+ const statusText = `Tasks ${model.summary.completed}/${model.summary.total} · ${currentText} · 阻塞 ${model.summary.blocked}`;
422
+ if (statusText !== lastStatusText) {
423
+ ctx.ui.setStatus(STATUS_KEY, statusText);
424
+ lastStatusText = statusText;
425
+ }
426
+ };
427
+
428
+ const clearUi = (ctx: ExtensionContext): void => {
429
+ if (!ctx.hasUI) return;
430
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
431
+ ctx.ui.setStatus(STATUS_KEY, undefined);
432
+ compactWidget = undefined;
433
+ rpcWidgetFingerprint = undefined;
434
+ lastStatusText = undefined;
435
+ };
436
+
437
+ const restoreAndRefresh = (ctx: ExtensionContext) => {
438
+ state = restoreState(ctx);
439
+ refreshUi(ctx);
440
+ };
441
+
442
+ pi.on("session_start", async (_event, ctx) => restoreAndRefresh(ctx));
443
+ pi.on("session_tree", async (_event, ctx) => restoreAndRefresh(ctx));
444
+ pi.on("session_shutdown", async (_event, ctx) => clearUi(ctx));
445
+ pi.on("session_compact", async () => {
446
+ pi.appendEntry("task-list-state", { state: cloneState(state) });
447
+ });
448
+
449
+ pi.registerTool({
450
+ name: TOOL_NAME,
451
+ label: "Task List",
452
+ description: "Manage a structured task list with incremental updates, acceptance criteria, evidence, dependencies, blockers, priorities, notes, and branch-aware session state.",
453
+ promptSnippet: "Manage the structured task list with dependencies, evidence, and visible progress",
454
+ promptGuidelines: [
455
+ "Use task_list for multi-step work instead of maintaining an unstructured plan in prose.",
456
+ "Create atomic tasks with a clear subject and acceptanceCriteria that can be checked independently.",
457
+ "Use task_list update to mark work in_progress before starting; each acceptance criterion needs an evidence entry before completion.",
458
+ "Use task_list update with note to record a meaningful milestone, blocker, or next action; do not hide a blocker in a completed task.",
459
+ "Use blockedBy for hard task dependencies; dependency blockers are managed automatically, while manual blockers require an explicit blockReason and explicit release.",
460
+ "After changing task state, explain to the user what completed, what is current, and what happens next.",
461
+ ],
462
+ parameters: TaskListParams,
463
+ executionMode: "sequential",
464
+
465
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
466
+ if (signal?.aborted) throw new Error("Task list operation cancelled");
467
+ const result = applyTaskAction(state, params as TaskCommand);
468
+ state = result.state;
469
+ if (result.mutated) {
470
+ pi.appendEntry("task-list-state", { state: cloneState(state) });
471
+ }
472
+ refreshUi(ctx);
473
+
474
+ const details: TaskListDetails = {
475
+ action: result.action,
476
+ state: cloneState(state),
477
+ changedIds: [...result.changedIds],
478
+ visibleTasks: result.visibleTasks.map((task) => ({ ...task, acceptanceCriteria: [...task.acceptanceCriteria], evidence: [...task.evidence], blockedBy: [...task.blockedBy], tags: [...task.tags], notes: task.notes.map((note) => ({ ...note })) })),
479
+ selectedTask: result.selectedTask,
480
+ message: result.message,
481
+ };
482
+
483
+ return {
484
+ content: [{ type: "text", text: formatForModel(result) }],
485
+ details,
486
+ };
487
+ },
488
+
489
+ renderCall(args, theme) {
490
+ let text = theme.fg("toolTitle", theme.bold("task_list ")) + theme.fg("muted", args.action);
491
+ if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
492
+ if (args.subject) text += ` ${theme.fg("dim", `"${compact(args.subject, 64)}"`)}`;
493
+ if (args.status) text += ` ${theme.fg("muted", `[${args.status}]`)}`;
494
+ return new Text(text, 0, 0);
495
+ },
496
+
497
+ renderResult(result, { expanded }, theme, context) {
498
+ const details = result.details as TaskListDetails | undefined;
499
+ const fallback = result.content.find((part) => part.type === "text");
500
+ if (!details) return new Text(theme.fg(context.isError ? "error" : "muted", fallback?.type === "text" ? fallback.text : ""), 0, 0);
501
+
502
+ const lines = [theme.fg(context.isError ? "error" : "success", details.message), theme.fg("muted", taskStateSummary(details.state))];
503
+ const tasks = details.action === "get" && details.selectedTask ? [details.selectedTask] : details.visibleTasks;
504
+ const display = expanded ? tasks.slice(0, 20) : tasks.slice(0, 4);
505
+ for (const task of display) {
506
+ const mark = task.status === "completed" ? "✓" : task.status === "blocked" ? "!" : task.status === "in_progress" ? ">" : task.status === "cancelled" ? "-" : "○";
507
+ lines.push(`${statusColor(theme, task.status, mark)} ${theme.fg("accent", `#${task.id}`)} ${theme.fg(task.status === "completed" ? "dim" : "text", task.subject)} ${theme.fg("dim", `[${priorityLabel(task.priority)}]`)}`);
508
+ if (expanded && task.status === "blocked" && task.blockReason) lines.push(` ${theme.fg("error", task.blockReason)}`);
509
+ if (expanded && task.acceptanceCriteria.length > 0) lines.push(` ${theme.fg("muted", `完成条件证据:${task.evidence.length}/${task.acceptanceCriteria.length}`)}`);
510
+ }
511
+ if (display.length < tasks.length) lines.push(theme.fg("dim", `... 还有 ${tasks.length - display.length} 个任务`));
512
+ return new Text(lines.join("\n"), 0, 0);
513
+ },
514
+ });
515
+
516
+ pi.registerCommand("tasks", {
517
+ description: "查看当前分支的完整任务清单;可按 all、active、blocked、pending、done、ID 或关键词筛选",
518
+ handler: async (args, ctx) => {
519
+ const selected = commandFilter(state, args);
520
+ if (ctx.mode !== "tui") {
521
+ ctx.ui.notify(formatForModel({ action: "list", message: selected.title, visibleTasks: selected.state.tasks, state: selected.state }), "info");
522
+ return;
523
+ }
524
+ await ctx.ui.custom<void>((_tui, theme, _keybindings, done) => new TaskListComponent(selected.state, theme, selected.title, () => done()));
525
+ },
526
+ });
527
+ }