@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.
- package/README.md +26 -7
- package/extensions/task-context-bridge.ts +21 -40
- package/extensions/task-list.ts +286 -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
|
@@ -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
|
-
|
|
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,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
|
|
309
|
+
private contentWidth?: number;
|
|
310
|
+
private contentLinesCache?: string[];
|
|
311
|
+
private scrollTop = 0;
|
|
311
312
|
|
|
312
|
-
constructor(
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
if (task.status === "in_progress"
|
|
341
|
-
if (task.description
|
|
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
|
-
|
|
369
|
+
if (!add(` 完成条件:${task.evidence.length}/${task.acceptanceCriteria.length} 项已有证据`)) break;
|
|
370
|
+
let stopped = false;
|
|
344
371
|
for (const criterion of task.acceptanceCriteria) {
|
|
345
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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
|
|
353
|
-
if (task.status === "blocked" && task.blockReason
|
|
354
|
-
if (task.notes.length > 0
|
|
355
|
-
if (task.status === "completed" && task.completionNote
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
this.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
445
|
-
pi.on("
|
|
446
|
-
|
|
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
|
|
593
|
+
const command = params as TaskCommand;
|
|
594
|
+
const result = applyTaskAction(state, command);
|
|
468
595
|
state = result.state;
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
612
|
+
operation,
|
|
613
|
+
summary: summarizeState(state),
|
|
477
614
|
changedIds: [...result.changedIds],
|
|
478
|
-
visibleTasks: result.visibleTasks.map(
|
|
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
|
|
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(
|
|
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>(
|
|
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
|
}
|