@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.
- package/README.md +6 -6
- package/extensions/task-context-bridge.ts +21 -40
- package/extensions/task-list.ts +247 -126
- package/lib/task-list-core.ts +284 -92
- package/lib/task-list-persistence.ts +116 -0
- package/package.json +1 -1
package/extensions/task-list.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
40
|
-
subject: Type.Optional(
|
|
41
|
-
description: Type.Optional(
|
|
42
|
-
activeForm: Type.Optional(
|
|
43
|
-
acceptanceCriteria: Type.Optional(
|
|
44
|
-
evidence: Type.Optional(
|
|
45
|
-
addEvidence: Type.Optional(
|
|
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(
|
|
49
|
-
addBlockedBy: Type.Optional(
|
|
50
|
-
removeBlockedBy: Type.Optional(
|
|
51
|
-
blockReason: Type.Optional(
|
|
52
|
-
tags: Type.Optional(
|
|
53
|
-
addTags: Type.Optional(
|
|
54
|
-
removeTags: Type.Optional(
|
|
55
|
-
owner: Type.Optional(
|
|
56
|
-
completionNote: Type.Optional(
|
|
57
|
-
note: Type.Optional(
|
|
58
|
-
query: Type.Optional(
|
|
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
|
|
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
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
309
|
+
private contentWidth?: number;
|
|
310
|
+
private contentLinesCache?: string[];
|
|
311
|
+
private scrollTop = 0;
|
|
311
312
|
|
|
312
|
-
constructor(
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
if (task.status === "in_progress"
|
|
341
|
-
if (task.description
|
|
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
|
-
|
|
363
|
+
if (!add(` 完成条件:${task.evidence.length}/${task.acceptanceCriteria.length} 项已有证据`)) break;
|
|
364
|
+
let stopped = false;
|
|
344
365
|
for (const criterion of task.acceptanceCriteria) {
|
|
345
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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
|
|
353
|
-
if (task.status === "blocked" && task.blockReason
|
|
354
|
-
if (task.notes.length > 0
|
|
355
|
-
if (task.status === "completed" && task.completionNote
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
this.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
445
|
-
pi.on("
|
|
446
|
-
|
|
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
|
|
569
|
+
const command = params as TaskCommand;
|
|
570
|
+
const result = applyTaskAction(state, command);
|
|
468
571
|
state = result.state;
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
588
|
+
operation,
|
|
589
|
+
summary: summarizeState(state),
|
|
477
590
|
changedIds: [...result.changedIds],
|
|
478
|
-
visibleTasks: result.visibleTasks.map(
|
|
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
|
|
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(
|
|
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>((
|
|
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
|
}
|