@ilikexiaoni/pi-task-list 0.4.0 → 0.4.1

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.
@@ -3,66 +3,86 @@ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-c
3
3
  import { matchesKey, Text, truncateToWidth, type TUI } 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,166 @@ 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;
317
- }
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
+ ) {}
318
321
 
319
- handleInput(data: string): void {
320
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) this.onClose();
322
+ private pageSize(): number {
323
+ const rows = this.tui.terminal?.rows;
324
+ // Reserve one line for the footer while remaining usable on tiny terminals.
325
+ return typeof rows === "number" && rows > 0 ? Math.max(1, rows - 2) : 22;
321
326
  }
322
327
 
323
- render(width: number): string[] {
324
- if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
328
+ private buildContent(width: number): string[] {
329
+ const safeWidth = Math.max(1, width);
330
+ if (this.contentWidth === safeWidth && this.contentLinesCache) return this.contentLinesCache;
331
+
325
332
  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("");
333
+ let truncated = false;
334
+ const add = (line: string): boolean => {
335
+ if (lines.length >= MAX_DETAIL_LINES - 1) {
336
+ truncated = true;
337
+ return false;
338
+ }
339
+ lines.push(truncateToWidth(line, safeWidth));
340
+ return true;
341
+ };
342
+ const display = (value: string, max = MAX_FIELD_LENGTH): string => compact(value, max);
343
+ const header =
344
+ this.theme.fg("borderMuted", "─".repeat(3)) +
345
+ this.theme.fg("accent", ` ${display(this.title, 120)} `) +
346
+ this.theme.fg("borderMuted", "─".repeat(Math.max(0, safeWidth - 12)));
347
+
348
+ add("");
349
+ add(header);
350
+ add(` ${taskStateSummary(this.state, this.dependencyState)}`);
351
+ add("");
332
352
 
333
353
  const tasks = filterTasks(this.state, { includeCancelled: true });
334
354
  if (tasks.length === 0) {
335
- lines.push(truncateToWidth(` ${this.theme.fg("dim", "当前没有任务")}`, width));
355
+ add(` ${this.theme.fg("dim", "当前没有任务")}`);
336
356
  } else {
337
357
  for (const task of tasks) {
338
358
  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));
359
+ if (!add(` ${statusColor(this.theme, task.status, mark)} ${this.theme.fg("accent", `#${task.id}`)} [${priorityLabel(task.priority)}] ${display(task.subject, MAX_SUBJECT_LENGTH)}`)) break;
360
+ if (task.status === "in_progress" && !add(` 动作:${display(task.activeForm, MAX_SUBJECT_LENGTH)}`)) break;
361
+ if (task.description && !add(` 说明:${display(task.description)}`)) break;
342
362
  if (task.acceptanceCriteria.length > 0) {
343
- lines.push(truncateToWidth(` 完成条件:${task.evidence.length}/${task.acceptanceCriteria.length} 项已有证据`, width));
363
+ if (!add(` 完成条件:${task.evidence.length}/${task.acceptanceCriteria.length} 项已有证据`)) break;
364
+ let stopped = false;
344
365
  for (const criterion of task.acceptanceCriteria) {
345
- lines.push(truncateToWidth(` - ${criterion}`, width));
366
+ if (!add(` - ${display(criterion)}`)) {
367
+ stopped = true;
368
+ break;
369
+ }
346
370
  }
371
+ if (stopped) break;
347
372
  }
348
373
  if (task.evidence.length > 0) {
349
- lines.push(truncateToWidth(" 证据:", width));
350
- for (const evidence of task.evidence) lines.push(truncateToWidth(` - ${evidence}`, width));
374
+ if (!add(" 证据:")) break;
375
+ let stopped = false;
376
+ for (const evidence of task.evidence) {
377
+ if (!add(` - ${display(evidence)}`)) {
378
+ stopped = true;
379
+ break;
380
+ }
381
+ }
382
+ if (stopped) break;
351
383
  }
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));
384
+ if (task.blockedBy.length > 0 && !add(` 依赖:${task.blockedBy.map((id) => `#${id}`).join(", ")}`)) break;
385
+ if (task.status === "blocked" && task.blockReason && !add(` 阻塞:${display(task.blockReason)}`)) break;
386
+ if (task.notes.length > 0 && !add(` 最近记录:${display(task.notes.at(-1)?.text ?? "")}`)) break;
387
+ if (task.status === "completed" && task.completionNote && !add(` 完成说明:${display(task.completionNote)}`)) break;
356
388
  }
357
389
  }
358
- lines.push("");
359
- lines.push(truncateToWidth(` ${this.theme.fg("dim", "按 Escape 关闭")}`, width));
360
- lines.push("");
361
- this.cachedWidth = width;
362
- this.cachedLines = lines;
390
+
391
+ if (truncated) {
392
+ add(" … 详情过长,已截断;请使用 /tasks #ID 或 task_list get 分批查看");
393
+ }
394
+ this.contentWidth = safeWidth;
395
+ this.contentLinesCache = lines;
363
396
  return lines;
364
397
  }
365
398
 
399
+ private maxScroll(width: number): number {
400
+ return Math.max(0, this.buildContent(width).length - this.pageSize());
401
+ }
402
+
403
+ handleInput(data: string): void {
404
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
405
+ this.onClose();
406
+ return;
407
+ }
408
+
409
+ const width = this.cachedWidth ?? this.tui.terminal?.columns ?? 80;
410
+ const page = Math.max(1, this.pageSize() - 1);
411
+ const maximum = this.maxScroll(width);
412
+ let next = this.scrollTop;
413
+ if (matchesKey(data, "up")) next -= 1;
414
+ else if (matchesKey(data, "down")) next += 1;
415
+ else if (matchesKey(data, "pageUp")) next -= page;
416
+ else if (matchesKey(data, "pageDown")) next += page;
417
+ else if (matchesKey(data, "home")) next = 0;
418
+ else if (matchesKey(data, "end")) next = maximum;
419
+ else return;
420
+
421
+ next = Math.max(0, Math.min(maximum, next));
422
+ if (next === this.scrollTop) return;
423
+ this.scrollTop = next;
424
+ this.invalidate();
425
+ this.tui.requestRender();
426
+ }
427
+
428
+ render(width: number): string[] {
429
+ const safeWidth = Math.max(1, width);
430
+ const content = this.buildContent(safeWidth);
431
+ const viewport = this.pageSize();
432
+ const maximum = Math.max(0, content.length - viewport);
433
+ this.scrollTop = Math.max(0, Math.min(maximum, this.scrollTop));
434
+
435
+ if (this.cachedWidth === safeWidth && this.cachedViewport === viewport && this.cachedOffset === this.scrollTop && this.cachedLines) return this.cachedLines;
436
+
437
+ const visible = content.slice(this.scrollTop, this.scrollTop + viewport);
438
+ while (visible.length < viewport) visible.push("");
439
+ const position = content.length === 0 ? "0/0" : `${this.scrollTop + 1}-${Math.min(content.length, this.scrollTop + viewport)}/${content.length}`;
440
+ visible.push(truncateToWidth(` ${this.theme.fg("dim", `位置 ${position} · ↑↓ 滚动 · PageUp/PageDown 翻页 · Home/End 跳转 · Esc 关闭`)}`, safeWidth));
441
+
442
+ this.cachedWidth = safeWidth;
443
+ this.cachedOffset = this.scrollTop;
444
+ this.cachedViewport = viewport;
445
+ this.cachedLines = visible;
446
+ return visible;
447
+ }
448
+
366
449
  invalidate(): void {
367
450
  this.cachedWidth = undefined;
451
+ this.cachedOffset = undefined;
452
+ this.cachedViewport = undefined;
368
453
  this.cachedLines = undefined;
369
454
  }
370
455
  }
371
456
 
372
- function commandFilter(state: TaskState, args: string): { state: TaskState; title: string } {
457
+ function commandFilter(state: TaskState, args: string): { state: TaskState; title: string; dependencyState: TaskState } {
458
+ const dependencyState = cloneState(state);
373
459
  const filter = args.trim();
374
- if (!filter || filter.toLowerCase() === "all") return { state: cloneState(state), title: "Tasks" };
460
+ if (!filter || filter.toLowerCase() === "all") return { state: cloneState(state), title: "Tasks", dependencyState };
375
461
  const lower = filter.toLowerCase();
376
462
  if (/^#?\d+$/.test(lower)) {
377
463
  const id = Number(lower.replace(/^#/, ""));
378
- return { state: { ...cloneState(state), tasks: state.tasks.filter((task) => task.id === id) }, title: `Task #${id}` };
464
+ return { state: { ...cloneState(state), tasks: state.tasks.filter((task) => task.id === id) }, title: `Task #${id}`, dependencyState };
379
465
  }
380
466
  const statusMap: Record<string, TaskStatus> = {
381
467
  active: "in_progress",
@@ -387,9 +473,9 @@ function commandFilter(state: TaskState, args: string): { state: TaskState; titl
387
473
  cancelled: "cancelled",
388
474
  };
389
475
  if (statusMap[lower]) {
390
- return { state: { ...cloneState(state), tasks: filterTasks(state, { status: statusMap[lower], includeCancelled: true }) }, title: `Tasks: ${statusLabel(statusMap[lower])}` };
476
+ return { state: { ...cloneState(state), tasks: filterTasks(state, { status: statusMap[lower], includeCancelled: true }) }, title: `Tasks: ${statusLabel(statusMap[lower])}`, dependencyState };
391
477
  }
392
- return { state: { ...cloneState(state), tasks: filterTasks(state, { query: filter, includeCancelled: true }) }, title: `Tasks: ${filter}` };
478
+ return { state: { ...cloneState(state), tasks: filterTasks(state, { query: filter, includeCancelled: true }) }, title: `Tasks: ${filter}`, dependencyState };
393
479
  }
394
480
 
395
481
  export default function taskListExtension(pi: ExtensionAPI) {
@@ -397,6 +483,7 @@ export default function taskListExtension(pi: ExtensionAPI) {
397
483
  let compactWidget: CompactTaskWidget | undefined;
398
484
  let rpcWidgetFingerprint: string | undefined;
399
485
  let lastStatusText: string | undefined;
486
+ let lastCheckpointRevision = -1;
400
487
 
401
488
  const refreshUi = (ctx: ExtensionContext): void => {
402
489
  if (!ctx.hasUI) return;
@@ -413,7 +500,9 @@ export default function taskListExtension(pi: ExtensionAPI) {
413
500
  });
414
501
  }
415
502
  } else if (rpcWidgetFingerprint !== fingerprint) {
416
- ctx.ui.setWidget(WIDGET_KEY, compactWidgetLines(model, ctx.ui.theme, 96));
503
+ // RPC clients receive plain text; do not leak interactive ANSI styling into JSON events.
504
+ const lines = compactWidgetLines(model, ctx.ui.theme, RPC_WIDGET_WIDTH).map((line) => sanitizeTaskText(line));
505
+ ctx.ui.setWidget(WIDGET_KEY, lines);
417
506
  rpcWidgetFingerprint = fingerprint;
418
507
  }
419
508
 
@@ -435,15 +524,28 @@ export default function taskListExtension(pi: ExtensionAPI) {
435
524
  };
436
525
 
437
526
  const restoreAndRefresh = (ctx: ExtensionContext) => {
438
- state = restoreState(ctx);
527
+ state = restoreTaskListState(ctx.sessionManager.getBranch());
439
528
  refreshUi(ctx);
440
529
  };
441
530
 
442
531
  pi.on("session_start", async (_event, ctx) => restoreAndRefresh(ctx));
443
532
  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) });
533
+ // A session switch can be cancelled by another extension; keep the current state until shutdown is certain.
534
+ pi.on("session_shutdown", async (_event, ctx) => {
535
+ state = normalizeState(undefined);
536
+ clearUi(ctx);
537
+ });
538
+ pi.on("session_compact", async (_event, ctx) => {
539
+ if (typeof pi.appendEntry !== "function" || state.revision === lastCheckpointRevision) return;
540
+ try {
541
+ pi.appendEntry(TASK_LIST_STATE_ENTRY_TYPE, { version: 1, state: cloneState(state) });
542
+ lastCheckpointRevision = state.revision;
543
+ } catch (error) {
544
+ if (ctx.hasUI) {
545
+ const reason = error instanceof Error ? error.message : String(error);
546
+ ctx.ui.notify(`任务 checkpoint 未写入:${reason}`, "warning");
547
+ }
548
+ }
447
549
  });
448
550
 
449
551
  pi.registerTool({
@@ -464,24 +566,35 @@ export default function taskListExtension(pi: ExtensionAPI) {
464
566
 
465
567
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
466
568
  if (signal?.aborted) throw new Error("Task list operation cancelled");
467
- const result = applyTaskAction(state, params as TaskCommand);
569
+ const command = params as TaskCommand;
570
+ const result = applyTaskAction(state, command);
468
571
  state = result.state;
469
- if (result.mutated) {
470
- pi.appendEntry("task-list-state", { state: cloneState(state) });
471
- }
572
+ // Tool results retain a compact replayable mutation. The complete snapshot is
573
+ // checkpointed only after compaction, preventing a full state copy per turn.
574
+ const operation = result.mutated ? createTaskOperation(command, state.updatedAt) : undefined;
472
575
  refreshUi(ctx);
473
576
 
577
+ const copyTask = (task: Task): Task => ({
578
+ ...task,
579
+ acceptanceCriteria: [...task.acceptanceCriteria],
580
+ evidence: [...task.evidence],
581
+ blockedBy: [...task.blockedBy],
582
+ tags: [...task.tags],
583
+ notes: task.notes.map((note) => ({ ...note })),
584
+ });
474
585
  const details: TaskListDetails = {
586
+ schemaVersion: 2,
475
587
  action: result.action,
476
- state: cloneState(state),
588
+ operation,
589
+ summary: summarizeState(state),
477
590
  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,
591
+ visibleTasks: result.visibleTasks.map(copyTask),
592
+ selectedTask: result.selectedTask ? copyTask(result.selectedTask) : undefined,
480
593
  message: result.message,
481
594
  };
482
595
 
483
596
  return {
484
- content: [{ type: "text", text: formatForModel(result) }],
597
+ content: [{ type: "text", text: formatForModel({ ...result, dependencyState: state }) }],
485
598
  details,
486
599
  };
487
600
  },
@@ -499,13 +612,18 @@ export default function taskListExtension(pi: ExtensionAPI) {
499
612
  const fallback = result.content.find((part) => part.type === "text");
500
613
  if (!details) return new Text(theme.fg(context.isError ? "error" : "muted", fallback?.type === "text" ? fallback.text : ""), 0, 0);
501
614
 
502
- const lines = [theme.fg(context.isError ? "error" : "success", details.message), theme.fg("muted", taskStateSummary(details.state))];
615
+ const legacySummary = isPersistedTaskState(details.state) ? summarizeState(normalizeState(details.state)) : undefined;
616
+ const summary = details.summary ?? legacySummary;
617
+ const lines = [
618
+ theme.fg(context.isError ? "error" : "success", details.message),
619
+ theme.fg("muted", summary ? taskSummaryText(summary) : "任务状态摘要不可用"),
620
+ ];
503
621
  const tasks = details.action === "get" && details.selectedTask ? [details.selectedTask] : details.visibleTasks;
504
622
  const display = expanded ? tasks.slice(0, 20) : tasks.slice(0, 4);
505
623
  for (const task of display) {
506
624
  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)}`);
625
+ 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)}]`)}`);
626
+ if (expanded && task.status === "blocked" && task.blockReason) lines.push(` ${theme.fg("error", compact(task.blockReason))}`);
509
627
  if (expanded && task.acceptanceCriteria.length > 0) lines.push(` ${theme.fg("muted", `完成条件证据:${task.evidence.length}/${task.acceptanceCriteria.length}`)}`);
510
628
  }
511
629
  if (display.length < tasks.length) lines.push(theme.fg("dim", `... 还有 ${tasks.length - display.length} 个任务`));
@@ -518,10 +636,13 @@ export default function taskListExtension(pi: ExtensionAPI) {
518
636
  handler: async (args, ctx) => {
519
637
  const selected = commandFilter(state, args);
520
638
  if (ctx.mode !== "tui") {
521
- ctx.ui.notify(formatForModel({ action: "list", message: selected.title, visibleTasks: selected.state.tasks, state: selected.state }), "info");
639
+ ctx.ui.notify(
640
+ formatForModel({ action: "list", message: selected.title, visibleTasks: selected.state.tasks, state: selected.state, dependencyState: selected.dependencyState }),
641
+ "info",
642
+ );
522
643
  return;
523
644
  }
524
- await ctx.ui.custom<void>((_tui, theme, _keybindings, done) => new TaskListComponent(selected.state, theme, selected.title, () => done()));
645
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => new TaskListComponent(selected.state, theme, selected.title, tui, () => done(), selected.dependencyState));
525
646
  },
526
647
  });
527
648
  }