@ilikexiaoni/pi-task-list 0.4.0 → 0.4.2

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.
@@ -1,68 +1,88 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
3
- import { matchesKey, Text, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
3
+ import { matchesKey, Text, truncateToWidth, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
4
4
  import { Type } from "typebox";
5
5
  import {
6
+ MAX_FIELD_LENGTH,
7
+ MAX_LIST_ITEMS,
8
+ MAX_SUBJECT_LENGTH,
6
9
  TASK_PRIORITIES,
7
10
  TASK_STATUSES,
8
11
  applyTaskAction,
9
12
  cloneState,
13
+ createTaskOperation,
10
14
  filterTasks,
15
+ isPersistedTaskState,
11
16
  isTaskReady,
12
17
  normalizeState,
13
18
  priorityLabel,
14
19
  statusLabel,
20
+ sanitizeTaskText,
15
21
  summarizeState,
16
22
  type Task,
17
23
  type TaskAction,
18
24
  type TaskCommand,
25
+ type TaskOperation,
19
26
  type TaskPriority,
20
27
  type TaskState,
21
28
  type TaskStatus,
29
+ type TaskSummary,
22
30
  } from "../lib/task-list-core.ts";
31
+ import { restoreTaskListState, TASK_LIST_STATE_ENTRY_TYPE } from "../lib/task-list-persistence.ts";
23
32
 
24
33
  const TOOL_NAME = "task_list";
25
34
  const WIDGET_KEY = "task-list-widget";
26
35
  const STATUS_KEY = "task-list-status";
36
+ const MAX_MODEL_OUTPUT_LENGTH = 12_000;
37
+ const MAX_DETAIL_LINES = 1_200;
38
+ const RPC_WIDGET_WIDTH = 80;
27
39
 
28
40
  interface TaskListDetails {
41
+ schemaVersion?: 2;
29
42
  action: TaskAction;
30
- state: TaskState;
43
+ operation?: TaskOperation;
44
+ summary?: TaskSummary;
45
+ /** Full state written by releases before operation-based persistence. */
46
+ state?: unknown;
31
47
  changedIds: number[];
32
48
  visibleTasks: Task[];
33
49
  selectedTask?: Task;
34
50
  message: string;
35
51
  }
36
52
 
53
+ const boundedString = (maxLength: number, description?: string) => Type.String({ maxLength, description });
54
+ const taskTextList = (description?: string) => Type.Array(boundedString(MAX_FIELD_LENGTH), { maxItems: MAX_LIST_ITEMS, description });
55
+ const taskIdList = (description?: string) => Type.Array(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), { maxItems: MAX_LIST_ITEMS, description });
56
+
37
57
  const TaskListParams = Type.Object({
38
58
  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: "追加证据" })),
59
+ id: Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: "目标任务 ID" })),
60
+ subject: Type.Optional(boundedString(MAX_SUBJECT_LENGTH, "任务标题;create 必填")),
61
+ description: Type.Optional(boundedString(MAX_FIELD_LENGTH, "任务背景和范围")),
62
+ activeForm: Type.Optional(boundedString(MAX_SUBJECT_LENGTH, "进行中时显示的动作短语")),
63
+ acceptanceCriteria: Type.Optional(taskTextList("可验收的完成条件")),
64
+ evidence: Type.Optional(taskTextList("已取得的证据;可替换现有证据")),
65
+ addEvidence: Type.Optional(taskTextList("追加证据")),
46
66
  status: Type.Optional(StringEnum(TASK_STATUSES)),
47
67
  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 的标题、描述、标签搜索词" })),
68
+ blockedBy: Type.Optional(taskIdList("硬依赖任务 ID")),
69
+ addBlockedBy: Type.Optional(taskIdList()),
70
+ removeBlockedBy: Type.Optional(taskIdList()),
71
+ blockReason: Type.Optional(boundedString(MAX_FIELD_LENGTH, "阻塞原因;空字符串可清除")),
72
+ tags: Type.Optional(taskTextList()),
73
+ addTags: Type.Optional(taskTextList()),
74
+ removeTags: Type.Optional(taskTextList()),
75
+ owner: Type.Optional(boundedString(200, "负责人或执行单元")),
76
+ completionNote: Type.Optional(boundedString(MAX_FIELD_LENGTH, "完成结论或验收说明")),
77
+ note: Type.Optional(boundedString(MAX_FIELD_LENGTH, "追加一条进度记录")),
78
+ query: Type.Optional(boundedString(MAX_FIELD_LENGTH, "list 的标题、描述、标签搜索词")),
59
79
  includeCancelled: Type.Optional(Type.Boolean()),
60
80
  scope: Type.Optional(StringEnum(["finished", "all"] as const)),
61
81
  confirm: Type.Optional(Type.Boolean({ description: "clear 的显式确认" })),
62
82
  });
63
83
 
64
84
  function compact(value: string, max = 76): string {
65
- const text = value.replace(/\s+/g, " ").trim();
85
+ const text = sanitizeTaskText(value);
66
86
  return text.length > max ? `${text.slice(0, Math.max(1, max - 3))}...` : text;
67
87
  }
68
88
 
@@ -70,43 +90,49 @@ function priorityToken(priority: TaskPriority): string {
70
90
  return priority === "high" ? "H" : priority === "medium" ? "M" : "L";
71
91
  }
72
92
 
73
- function taskStateSummary(state: TaskState): string {
74
- const summary = summarizeState(state);
93
+ function taskSummaryText(summary: TaskSummary): string {
75
94
  return `${summary.completed}/${summary.total} 已完成 | ${summary.active} 进行中 | ${summary.blocked} 阻塞 | ${summary.ready} 可开始${summary.cancelled ? ` | ${summary.cancelled} 已取消` : ""}`;
76
95
  }
77
96
 
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}`);
97
+ function taskStateSummary(state: TaskState, dependencyState: TaskState = state): string {
98
+ return taskSummaryText(summarizeState(state, dependencyState));
99
+ }
100
+
101
+ function taskLine(task: Task, state: TaskState, detailed = false, dependencyState: TaskState = state): string[] {
102
+ const lines = [`#${task.id} [${statusLabel(task.status)}] [${priorityLabel(task.priority)}] ${compact(task.subject, MAX_SUBJECT_LENGTH)}`];
103
+ if (detailed && task.activeForm && task.activeForm !== task.subject) lines.push(` 当前动作:${compact(task.activeForm, MAX_SUBJECT_LENGTH)}`);
104
+ if (detailed && task.description) lines.push(` 说明:${compact(task.description, MAX_FIELD_LENGTH)}`);
82
105
  if (task.acceptanceCriteria.length > 0) {
83
106
  lines.push(` 完成条件:${task.acceptanceCriteria.length} 项,证据 ${task.evidence.length} 项`);
84
- if (detailed) task.acceptanceCriteria.forEach((item, index) => lines.push(` ${index + 1}. ${item}`));
107
+ if (detailed) task.acceptanceCriteria.forEach((item, index) => lines.push(` ${index + 1}. ${compact(item, MAX_FIELD_LENGTH)}`));
85
108
  }
86
- if (task.evidence.length > 0 && detailed) task.evidence.forEach((item) => lines.push(` 证据:${item}`));
109
+ if (task.evidence.length > 0 && detailed) task.evidence.forEach((item) => lines.push(` 证据:${compact(item, MAX_FIELD_LENGTH)}`));
87
110
  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}`);
111
+ if (task.status === "blocked" && task.blockReason) lines.push(` 阻塞原因:${compact(task.blockReason, MAX_FIELD_LENGTH)}`);
112
+ if (task.owner) lines.push(` 负责人:${compact(task.owner, 200)}`);
113
+ if (task.tags.length > 0) lines.push(` 标签:${task.tags.map((tag) => compact(tag, 120)).join(", ")}`);
114
+ if (task.completionNote && task.status === "completed") lines.push(` 完成说明:${compact(task.completionNote, MAX_FIELD_LENGTH)}`);
92
115
  if (detailed && task.notes.length > 0) {
93
- for (const note of task.notes.slice(-3)) lines.push(` 记录${note.at ? ` [${note.at}]` : ""}:${note.text}`);
116
+ for (const note of task.notes.slice(-3)) lines.push(` 记录${note.at ? ` [${compact(note.at, 40)}]` : ""}:${compact(note.text, MAX_FIELD_LENGTH)}`);
94
117
  }
95
- if (!detailed && task.status === "pending" && !isTaskReady(state, task)) lines.push(" 等待依赖完成");
118
+ if (!detailed && task.status === "pending" && !isTaskReady(dependencyState, task)) lines.push(" 等待依赖完成");
96
119
  return lines;
97
120
  }
98
121
 
99
- function formatForModel(result: Pick<ApplyResultLike, "action" | "message" | "visibleTasks" | "selectedTask" | "state">): string {
100
- const lines = [result.message, taskStateSummary(result.state)];
122
+ function formatForModel(result: Pick<ApplyResultLike, "action" | "message" | "visibleTasks" | "selectedTask" | "state" | "dependencyState">): string {
123
+ const lines = [result.message, taskStateSummary(result.state, result.dependencyState)];
101
124
  const tasks = result.action === "get" && result.selectedTask ? [result.selectedTask] : result.visibleTasks;
102
125
  if (tasks.length === 0) {
103
126
  lines.push("(没有符合条件的任务)");
104
127
  } else {
105
128
  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"));
129
+ for (const task of display) lines.push(...taskLine(task, result.state, result.action === "get", result.dependencyState));
107
130
  if (display.length < tasks.length) lines.push(`... 还有 ${tasks.length - display.length} 个任务,请使用 list 的 query/status 分批查看`);
108
131
  }
109
- return lines.join("\n");
132
+ const text = lines.join("\n");
133
+ return text.length <= MAX_MODEL_OUTPUT_LENGTH
134
+ ? text
135
+ : `${text.slice(0, MAX_MODEL_OUTPUT_LENGTH - 80)}\n... 输出已截断;请使用 task_list 的 get 或 list + query 分批查看。`;
110
136
  }
111
137
 
112
138
  interface ApplyResultLike {
@@ -115,6 +141,7 @@ interface ApplyResultLike {
115
141
  visibleTasks: Task[];
116
142
  selectedTask?: Task;
117
143
  state: TaskState;
144
+ dependencyState: TaskState;
118
145
  }
119
146
 
120
147
  interface CompactWidgetTask {
@@ -157,7 +184,6 @@ function preferredWidgetTask(current: Task | undefined, candidate: Task): Task {
157
184
 
158
185
  function compactWidgetModel(state: TaskState): CompactWidgetModel {
159
186
  const summary = summarizeState(state);
160
- const statusById = new Map(state.tasks.map((task) => [task.id, task.status]));
161
187
  let active: Task | undefined;
162
188
  let blocked: Task | undefined;
163
189
  let ready: Task | undefined;
@@ -166,13 +192,7 @@ function compactWidgetModel(state: TaskState): CompactWidgetModel {
166
192
  for (const task of state.tasks) {
167
193
  if (task.status === "in_progress") active = preferredWidgetTask(active, task);
168
194
  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
- }
195
+ if (task.status === "pending" && isTaskReady(state, task)) ready = preferredWidgetTask(ready, task);
176
196
  if (
177
197
  task.status === "completed" &&
178
198
  (!recent || task.updatedAt > recent.updatedAt || (task.updatedAt === recent.updatedAt && task.id > recent.id))
@@ -266,26 +286,6 @@ class CompactTaskWidget {
266
286
  }
267
287
  }
268
288
 
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
289
  function statusColor(theme: Theme, status: TaskStatus, text: string): string {
290
290
  switch (status) {
291
291
  case "completed":
@@ -302,80 +302,190 @@ function statusColor(theme: Theme, status: TaskStatus, text: string): string {
302
302
  }
303
303
 
304
304
  class TaskListComponent {
305
- private readonly state: TaskState;
306
- private readonly theme: Theme;
307
- private readonly title: string;
308
305
  private cachedWidth?: number;
306
+ private cachedOffset?: number;
307
+ private cachedViewport?: number;
309
308
  private cachedLines?: string[];
310
- private readonly onClose: () => void;
309
+ private contentWidth?: number;
310
+ private contentLinesCache?: string[];
311
+ private scrollTop = 0;
311
312
 
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;
313
+ constructor(
314
+ private readonly state: TaskState,
315
+ private readonly theme: Theme,
316
+ private readonly title: string,
317
+ private readonly tui: TUI,
318
+ private readonly onClose: () => void,
319
+ private readonly dependencyState: TaskState = state,
320
+ ) {}
321
+
322
+ private viewportRows(): number {
323
+ const rows = this.tui.terminal?.rows;
324
+ if (typeof rows !== "number" || rows <= 0) return 24;
325
+ // Keep the detail view inside a stable modal viewport on both TUI modes.
326
+ return Math.max(6, Math.min(rows - 2, Math.floor(rows * 0.86)));
317
327
  }
318
328
 
319
- handleInput(data: string): void {
320
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) this.onClose();
329
+ private pageSize(): number {
330
+ // Reserve one line for the footer while remaining usable on tiny terminals.
331
+ return Math.max(1, this.viewportRows() - 1);
321
332
  }
322
333
 
323
- render(width: number): string[] {
324
- if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
334
+ private buildContent(width: number): string[] {
335
+ const safeWidth = Math.max(1, width);
336
+ if (this.contentWidth === safeWidth && this.contentLinesCache) return this.contentLinesCache;
337
+
325
338
  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("");
339
+ let truncated = false;
340
+ const add = (line: string): boolean => {
341
+ if (lines.length >= MAX_DETAIL_LINES - 1) {
342
+ truncated = true;
343
+ return false;
344
+ }
345
+ lines.push(truncateToWidth(line, safeWidth));
346
+ return true;
347
+ };
348
+ const display = (value: string, max = MAX_FIELD_LENGTH): string => compact(value, max);
349
+ const header =
350
+ this.theme.fg("borderMuted", "─".repeat(3)) +
351
+ this.theme.fg("accent", ` ${display(this.title, 120)} `) +
352
+ this.theme.fg("borderMuted", "─".repeat(Math.max(0, safeWidth - 12)));
353
+
354
+ add("");
355
+ add(header);
356
+ add(` ${taskStateSummary(this.state, this.dependencyState)}`);
357
+ add("");
332
358
 
333
359
  const tasks = filterTasks(this.state, { includeCancelled: true });
334
360
  if (tasks.length === 0) {
335
- lines.push(truncateToWidth(` ${this.theme.fg("dim", "当前没有任务")}`, width));
361
+ add(` ${this.theme.fg("dim", "当前没有任务")}`);
336
362
  } else {
337
363
  for (const task of tasks) {
338
364
  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));
365
+ if (!add(` ${statusColor(this.theme, task.status, mark)} ${this.theme.fg("accent", `#${task.id}`)} [${priorityLabel(task.priority)}] ${display(task.subject, MAX_SUBJECT_LENGTH)}`)) break;
366
+ if (task.status === "in_progress" && !add(` 动作:${display(task.activeForm, MAX_SUBJECT_LENGTH)}`)) break;
367
+ if (task.description && !add(` 说明:${display(task.description)}`)) break;
342
368
  if (task.acceptanceCriteria.length > 0) {
343
- lines.push(truncateToWidth(` 完成条件:${task.evidence.length}/${task.acceptanceCriteria.length} 项已有证据`, width));
369
+ if (!add(` 完成条件:${task.evidence.length}/${task.acceptanceCriteria.length} 项已有证据`)) break;
370
+ let stopped = false;
344
371
  for (const criterion of task.acceptanceCriteria) {
345
- lines.push(truncateToWidth(` - ${criterion}`, width));
372
+ if (!add(` - ${display(criterion)}`)) {
373
+ stopped = true;
374
+ break;
375
+ }
346
376
  }
377
+ if (stopped) break;
347
378
  }
348
379
  if (task.evidence.length > 0) {
349
- lines.push(truncateToWidth(" 证据:", width));
350
- for (const evidence of task.evidence) lines.push(truncateToWidth(` - ${evidence}`, width));
380
+ if (!add(" 证据:")) break;
381
+ let stopped = false;
382
+ for (const evidence of task.evidence) {
383
+ if (!add(` - ${display(evidence)}`)) {
384
+ stopped = true;
385
+ break;
386
+ }
387
+ }
388
+ if (stopped) break;
351
389
  }
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));
390
+ if (task.blockedBy.length > 0 && !add(` 依赖:${task.blockedBy.map((id) => `#${id}`).join(", ")}`)) break;
391
+ if (task.status === "blocked" && task.blockReason && !add(` 阻塞:${display(task.blockReason)}`)) break;
392
+ if (task.notes.length > 0 && !add(` 最近记录:${display(task.notes.at(-1)?.text ?? "")}`)) break;
393
+ if (task.status === "completed" && task.completionNote && !add(` 完成说明:${display(task.completionNote)}`)) break;
356
394
  }
357
395
  }
358
- lines.push("");
359
- lines.push(truncateToWidth(` ${this.theme.fg("dim", "按 Escape 关闭")}`, width));
360
- lines.push("");
361
- this.cachedWidth = width;
362
- this.cachedLines = lines;
396
+
397
+ if (truncated) {
398
+ add(" … 详情过长,已截断;请使用 /tasks #ID 或 task_list get 分批查看");
399
+ }
400
+ this.contentWidth = safeWidth;
401
+ this.contentLinesCache = lines;
363
402
  return lines;
364
403
  }
365
404
 
405
+ private maxScroll(width: number): number {
406
+ return Math.max(0, this.buildContent(width).length - this.pageSize());
407
+ }
408
+
409
+ handleInput(data: string): void {
410
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
411
+ this.onClose();
412
+ return;
413
+ }
414
+
415
+ const width = this.cachedWidth ?? this.tui.terminal?.columns ?? 80;
416
+ const page = Math.max(1, this.pageSize() - 1);
417
+ const maximum = this.maxScroll(width);
418
+ let next = this.scrollTop;
419
+ if (matchesKey(data, "up") || data === "k") next -= 1;
420
+ else if (matchesKey(data, "down") || data === "j") next += 1;
421
+ else if (matchesKey(data, "pageUp") || data === "u") next -= page;
422
+ else if (matchesKey(data, "pageDown") || data === "d" || data === " ") next += page;
423
+ else if (matchesKey(data, "home") || data === "g") next = 0;
424
+ else if (matchesKey(data, "end") || data === "G") next = maximum;
425
+ else return;
426
+
427
+ next = Math.max(0, Math.min(maximum, next));
428
+ if (next === this.scrollTop) return;
429
+ this.scrollTop = next;
430
+ this.invalidate();
431
+ this.tui.requestRender();
432
+ }
433
+
434
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
435
+ // Pi's regular TUI intentionally leaves the wheel to the terminal scrollback.
436
+ // Only consume normalized wheel events in fullscreen, where the host enables mouse reporting.
437
+ if (this.tui.mode !== "fullscreen" || event.type !== "wheel" || !event.wheelDelta) return undefined;
438
+
439
+ const width = Math.max(1, event.width || this.cachedWidth || this.tui.terminal?.columns || 80);
440
+ const maximum = this.maxScroll(width);
441
+ const next = Math.max(0, Math.min(maximum, this.scrollTop + event.wheelDelta));
442
+ if (next === this.scrollTop) return { handled: true, focus: true, render: false };
443
+
444
+ this.scrollTop = next;
445
+ this.invalidate();
446
+ return { handled: true, focus: true, render: true };
447
+ }
448
+
449
+ render(width: number): string[] {
450
+ const safeWidth = Math.max(1, width);
451
+ const content = this.buildContent(safeWidth);
452
+ const viewport = this.pageSize();
453
+ const maximum = Math.max(0, content.length - viewport);
454
+ this.scrollTop = Math.max(0, Math.min(maximum, this.scrollTop));
455
+
456
+ if (this.cachedWidth === safeWidth && this.cachedViewport === viewport && this.cachedOffset === this.scrollTop && this.cachedLines) return this.cachedLines;
457
+
458
+ const visible = content.slice(this.scrollTop, this.scrollTop + viewport);
459
+ while (visible.length < viewport) visible.push("");
460
+ const position = content.length === 0 ? "0/0" : `${this.scrollTop + 1}-${Math.min(content.length, this.scrollTop + viewport)}/${content.length}`;
461
+ const navigation = this.tui.mode === "fullscreen"
462
+ ? "滚轮/↑↓/jk 滚动 · u/d/Space 翻页 · Home/End/g/G 跳转 · Esc 关闭"
463
+ : "↑↓/jk 滚动 · u/d/Space 翻页 · Home/End/g/G 跳转 · Esc 关闭(fullscreen 支持滚轮)";
464
+ visible.push(truncateToWidth(` ${this.theme.fg("dim", `位置 ${position} · ${navigation}`)}`, safeWidth));
465
+
466
+ this.cachedWidth = safeWidth;
467
+ this.cachedOffset = this.scrollTop;
468
+ this.cachedViewport = viewport;
469
+ this.cachedLines = visible;
470
+ return visible;
471
+ }
472
+
366
473
  invalidate(): void {
367
474
  this.cachedWidth = undefined;
475
+ this.cachedOffset = undefined;
476
+ this.cachedViewport = undefined;
368
477
  this.cachedLines = undefined;
369
478
  }
370
479
  }
371
480
 
372
- function commandFilter(state: TaskState, args: string): { state: TaskState; title: string } {
481
+ function commandFilter(state: TaskState, args: string): { state: TaskState; title: string; dependencyState: TaskState } {
482
+ const dependencyState = cloneState(state);
373
483
  const filter = args.trim();
374
- if (!filter || filter.toLowerCase() === "all") return { state: cloneState(state), title: "Tasks" };
484
+ if (!filter || filter.toLowerCase() === "all") return { state: cloneState(state), title: "Tasks", dependencyState };
375
485
  const lower = filter.toLowerCase();
376
486
  if (/^#?\d+$/.test(lower)) {
377
487
  const id = Number(lower.replace(/^#/, ""));
378
- return { state: { ...cloneState(state), tasks: state.tasks.filter((task) => task.id === id) }, title: `Task #${id}` };
488
+ return { state: { ...cloneState(state), tasks: state.tasks.filter((task) => task.id === id) }, title: `Task #${id}`, dependencyState };
379
489
  }
380
490
  const statusMap: Record<string, TaskStatus> = {
381
491
  active: "in_progress",
@@ -387,9 +497,9 @@ function commandFilter(state: TaskState, args: string): { state: TaskState; titl
387
497
  cancelled: "cancelled",
388
498
  };
389
499
  if (statusMap[lower]) {
390
- return { state: { ...cloneState(state), tasks: filterTasks(state, { status: statusMap[lower], includeCancelled: true }) }, title: `Tasks: ${statusLabel(statusMap[lower])}` };
500
+ return { state: { ...cloneState(state), tasks: filterTasks(state, { status: statusMap[lower], includeCancelled: true }) }, title: `Tasks: ${statusLabel(statusMap[lower])}`, dependencyState };
391
501
  }
392
- return { state: { ...cloneState(state), tasks: filterTasks(state, { query: filter, includeCancelled: true }) }, title: `Tasks: ${filter}` };
502
+ return { state: { ...cloneState(state), tasks: filterTasks(state, { query: filter, includeCancelled: true }) }, title: `Tasks: ${filter}`, dependencyState };
393
503
  }
394
504
 
395
505
  export default function taskListExtension(pi: ExtensionAPI) {
@@ -397,6 +507,7 @@ export default function taskListExtension(pi: ExtensionAPI) {
397
507
  let compactWidget: CompactTaskWidget | undefined;
398
508
  let rpcWidgetFingerprint: string | undefined;
399
509
  let lastStatusText: string | undefined;
510
+ let lastCheckpointRevision = -1;
400
511
 
401
512
  const refreshUi = (ctx: ExtensionContext): void => {
402
513
  if (!ctx.hasUI) return;
@@ -413,7 +524,9 @@ export default function taskListExtension(pi: ExtensionAPI) {
413
524
  });
414
525
  }
415
526
  } else if (rpcWidgetFingerprint !== fingerprint) {
416
- ctx.ui.setWidget(WIDGET_KEY, compactWidgetLines(model, ctx.ui.theme, 96));
527
+ // RPC clients receive plain text; do not leak interactive ANSI styling into JSON events.
528
+ const lines = compactWidgetLines(model, ctx.ui.theme, RPC_WIDGET_WIDTH).map((line) => sanitizeTaskText(line));
529
+ ctx.ui.setWidget(WIDGET_KEY, lines);
417
530
  rpcWidgetFingerprint = fingerprint;
418
531
  }
419
532
 
@@ -435,15 +548,28 @@ export default function taskListExtension(pi: ExtensionAPI) {
435
548
  };
436
549
 
437
550
  const restoreAndRefresh = (ctx: ExtensionContext) => {
438
- state = restoreState(ctx);
551
+ state = restoreTaskListState(ctx.sessionManager.getBranch());
439
552
  refreshUi(ctx);
440
553
  };
441
554
 
442
555
  pi.on("session_start", async (_event, ctx) => restoreAndRefresh(ctx));
443
556
  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) });
557
+ // A session switch can be cancelled by another extension; keep the current state until shutdown is certain.
558
+ pi.on("session_shutdown", async (_event, ctx) => {
559
+ state = normalizeState(undefined);
560
+ clearUi(ctx);
561
+ });
562
+ pi.on("session_compact", async (_event, ctx) => {
563
+ if (typeof pi.appendEntry !== "function" || state.revision === lastCheckpointRevision) return;
564
+ try {
565
+ pi.appendEntry(TASK_LIST_STATE_ENTRY_TYPE, { version: 1, state: cloneState(state) });
566
+ lastCheckpointRevision = state.revision;
567
+ } catch (error) {
568
+ if (ctx.hasUI) {
569
+ const reason = error instanceof Error ? error.message : String(error);
570
+ ctx.ui.notify(`任务 checkpoint 未写入:${reason}`, "warning");
571
+ }
572
+ }
447
573
  });
448
574
 
449
575
  pi.registerTool({
@@ -464,24 +590,35 @@ export default function taskListExtension(pi: ExtensionAPI) {
464
590
 
465
591
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
466
592
  if (signal?.aborted) throw new Error("Task list operation cancelled");
467
- const result = applyTaskAction(state, params as TaskCommand);
593
+ const command = params as TaskCommand;
594
+ const result = applyTaskAction(state, command);
468
595
  state = result.state;
469
- if (result.mutated) {
470
- pi.appendEntry("task-list-state", { state: cloneState(state) });
471
- }
596
+ // Tool results retain a compact replayable mutation. The complete snapshot is
597
+ // checkpointed only after compaction, preventing a full state copy per turn.
598
+ const operation = result.mutated ? createTaskOperation(command, state.updatedAt) : undefined;
472
599
  refreshUi(ctx);
473
600
 
601
+ const copyTask = (task: Task): Task => ({
602
+ ...task,
603
+ acceptanceCriteria: [...task.acceptanceCriteria],
604
+ evidence: [...task.evidence],
605
+ blockedBy: [...task.blockedBy],
606
+ tags: [...task.tags],
607
+ notes: task.notes.map((note) => ({ ...note })),
608
+ });
474
609
  const details: TaskListDetails = {
610
+ schemaVersion: 2,
475
611
  action: result.action,
476
- state: cloneState(state),
612
+ operation,
613
+ summary: summarizeState(state),
477
614
  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,
615
+ visibleTasks: result.visibleTasks.map(copyTask),
616
+ selectedTask: result.selectedTask ? copyTask(result.selectedTask) : undefined,
480
617
  message: result.message,
481
618
  };
482
619
 
483
620
  return {
484
- content: [{ type: "text", text: formatForModel(result) }],
621
+ content: [{ type: "text", text: formatForModel({ ...result, dependencyState: state }) }],
485
622
  details,
486
623
  };
487
624
  },
@@ -499,13 +636,18 @@ export default function taskListExtension(pi: ExtensionAPI) {
499
636
  const fallback = result.content.find((part) => part.type === "text");
500
637
  if (!details) return new Text(theme.fg(context.isError ? "error" : "muted", fallback?.type === "text" ? fallback.text : ""), 0, 0);
501
638
 
502
- const lines = [theme.fg(context.isError ? "error" : "success", details.message), theme.fg("muted", taskStateSummary(details.state))];
639
+ const legacySummary = isPersistedTaskState(details.state) ? summarizeState(normalizeState(details.state)) : undefined;
640
+ const summary = details.summary ?? legacySummary;
641
+ const lines = [
642
+ theme.fg(context.isError ? "error" : "success", details.message),
643
+ theme.fg("muted", summary ? taskSummaryText(summary) : "任务状态摘要不可用"),
644
+ ];
503
645
  const tasks = details.action === "get" && details.selectedTask ? [details.selectedTask] : details.visibleTasks;
504
646
  const display = expanded ? tasks.slice(0, 20) : tasks.slice(0, 4);
505
647
  for (const task of display) {
506
648
  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)}`);
649
+ lines.push(`${statusColor(theme, task.status, mark)} ${theme.fg("accent", `#${task.id}`)} ${theme.fg(task.status === "completed" ? "dim" : "text", compact(task.subject, MAX_SUBJECT_LENGTH))} ${theme.fg("dim", `[${priorityLabel(task.priority)}]`)}`);
650
+ if (expanded && task.status === "blocked" && task.blockReason) lines.push(` ${theme.fg("error", compact(task.blockReason))}`);
509
651
  if (expanded && task.acceptanceCriteria.length > 0) lines.push(` ${theme.fg("muted", `完成条件证据:${task.evidence.length}/${task.acceptanceCriteria.length}`)}`);
510
652
  }
511
653
  if (display.length < tasks.length) lines.push(theme.fg("dim", `... 还有 ${tasks.length - display.length} 个任务`));
@@ -518,10 +660,28 @@ export default function taskListExtension(pi: ExtensionAPI) {
518
660
  handler: async (args, ctx) => {
519
661
  const selected = commandFilter(state, args);
520
662
  if (ctx.mode !== "tui") {
521
- ctx.ui.notify(formatForModel({ action: "list", message: selected.title, visibleTasks: selected.state.tasks, state: selected.state }), "info");
663
+ ctx.ui.notify(
664
+ formatForModel({ action: "list", message: selected.title, visibleTasks: selected.state.tasks, state: selected.state, dependencyState: selected.dependencyState }),
665
+ "info",
666
+ );
522
667
  return;
523
668
  }
524
- await ctx.ui.custom<void>((_tui, theme, _keybindings, done) => new TaskListComponent(selected.state, theme, selected.title, () => done()));
669
+ await ctx.ui.custom<void>(
670
+ (tui, theme, _keybindings, done) => new TaskListComponent(selected.state, theme, selected.title, tui, () => done(), selected.dependencyState),
671
+ {
672
+ // A modal gives fullscreen mode a bounded hit target for normalized wheel events.
673
+ // Regular mode still uses keyboard navigation because Pi delegates its wheel to scrollback.
674
+ overlay: true,
675
+ overlayOptions: () => {
676
+ return {
677
+ width: "92%",
678
+ maxHeight: "86%",
679
+ anchor: "center",
680
+ margin: 1,
681
+ };
682
+ },
683
+ },
684
+ );
525
685
  },
526
686
  });
527
687
  }