@mblarsen/pi-task-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/assets/task-ui.png +0 -0
- package/core.ts +586 -0
- package/index.ts +726 -0
- package/package.json +41 -0
- package/skill/SKILL.md +157 -0
package/index.ts
ADDED
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text, truncateToWidth, visibleWidth, type OverlayHandle } from "@earendil-works/pi-tui";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import {
|
|
6
|
+
TASK_STATUSES,
|
|
7
|
+
appendTaskOutput,
|
|
8
|
+
clearTaskOutput,
|
|
9
|
+
cloneTaskUiState,
|
|
10
|
+
createInitialTaskUiState,
|
|
11
|
+
createTask,
|
|
12
|
+
createTasks,
|
|
13
|
+
getTaskDashboard,
|
|
14
|
+
getTaskDepth,
|
|
15
|
+
getTaskDisplayNumber,
|
|
16
|
+
normalizeStoredTaskUiState,
|
|
17
|
+
removeTask,
|
|
18
|
+
replaceExternalTasks,
|
|
19
|
+
setFocusedTask,
|
|
20
|
+
updateTask,
|
|
21
|
+
upsertExternalTask,
|
|
22
|
+
type CreateTaskInput,
|
|
23
|
+
type ExternalTaskInput,
|
|
24
|
+
type TaskDashboard,
|
|
25
|
+
type TaskRecord,
|
|
26
|
+
type TaskUiState,
|
|
27
|
+
} from "./core.ts";
|
|
28
|
+
|
|
29
|
+
const STATE_ENTRY_TYPE = "task-ui-state";
|
|
30
|
+
const OVERLAY_MIN_TERMINAL_WIDTH = 72;
|
|
31
|
+
const MAX_VISIBLE_WORK_TASKS = 7;
|
|
32
|
+
const MAX_VISIBLE_HISTORY_TASKS = 3;
|
|
33
|
+
const SPINNER_FRAMES = ["✳", "✽", "•"] as const;
|
|
34
|
+
const COMPLETED_ICON = "\x1b[38;2;34;197;94m✔\x1b[39m";
|
|
35
|
+
const LABEL_COLORS = ["accent", "mdLink", "syntaxType", "syntaxFunction", "syntaxString", "syntaxNumber", "syntaxKeyword", "syntaxVariable"] as const;
|
|
36
|
+
|
|
37
|
+
export const TASK_UI_EVENTS = {
|
|
38
|
+
snapshot: "task-ui:snapshot",
|
|
39
|
+
upsert: "task-ui:upsert",
|
|
40
|
+
remove: "task-ui:remove",
|
|
41
|
+
output: "task-ui:output",
|
|
42
|
+
focus: "task-ui:focus",
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
type TaskToolDetails = {
|
|
46
|
+
action: string;
|
|
47
|
+
task?: TaskRecord;
|
|
48
|
+
tasks?: TaskRecord[];
|
|
49
|
+
dashboard?: TaskDashboard;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type SnapshotEvent = { tasks: ExternalTaskInput[]; focusedTaskId?: string };
|
|
53
|
+
type RemoveEvent = { taskId: string };
|
|
54
|
+
type OutputEvent = { taskId: string; text: string };
|
|
55
|
+
type FocusEvent = { taskId?: string };
|
|
56
|
+
|
|
57
|
+
type AgentTaskInput = {
|
|
58
|
+
id?: string;
|
|
59
|
+
subject: string;
|
|
60
|
+
description?: string;
|
|
61
|
+
label?: string;
|
|
62
|
+
status?: (typeof TASK_STATUSES)[number];
|
|
63
|
+
progress?: number;
|
|
64
|
+
owner?: string;
|
|
65
|
+
parent_id?: string;
|
|
66
|
+
blocked_by?: string[];
|
|
67
|
+
executing?: boolean;
|
|
68
|
+
active_form?: string;
|
|
69
|
+
started_at?: string;
|
|
70
|
+
input_tokens?: number;
|
|
71
|
+
output_tokens?: number;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function toCreateTaskInput(input: AgentTaskInput): CreateTaskInput {
|
|
75
|
+
return {
|
|
76
|
+
id: input.id,
|
|
77
|
+
subject: input.subject,
|
|
78
|
+
description: input.description,
|
|
79
|
+
label: input.label,
|
|
80
|
+
status: input.status,
|
|
81
|
+
progress: input.progress,
|
|
82
|
+
owner: input.owner,
|
|
83
|
+
parentId: input.parent_id,
|
|
84
|
+
blockedBy: input.blocked_by,
|
|
85
|
+
executing: input.executing,
|
|
86
|
+
activeForm: input.active_form,
|
|
87
|
+
startedAt: input.started_at,
|
|
88
|
+
inputTokens: input.input_tokens,
|
|
89
|
+
outputTokens: input.output_tokens,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function createTaskSchema() {
|
|
94
|
+
return Type.Object({
|
|
95
|
+
id: Type.Optional(Type.String({ description: "Backend task ID to mirror; generated when omitted" })),
|
|
96
|
+
subject: Type.String({ description: "Short task title" }),
|
|
97
|
+
description: Type.Optional(Type.String()),
|
|
98
|
+
label: Type.Optional(Type.String({ description: "Short right-aligned label, without brackets" })),
|
|
99
|
+
status: Type.Optional(StringEnum(TASK_STATUSES)),
|
|
100
|
+
progress: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })),
|
|
101
|
+
owner: Type.Optional(Type.String()),
|
|
102
|
+
parent_id: Type.Optional(Type.String({ description: "Parent task ID; the parent remains independently executable" })),
|
|
103
|
+
blocked_by: Type.Optional(Type.Array(Type.String(), { description: "Task IDs that must complete first" })),
|
|
104
|
+
executing: Type.Optional(Type.Boolean({ description: "Show the animated execution state" })),
|
|
105
|
+
active_form: Type.Optional(Type.String({ description: "Present-progress text shown while executing" })),
|
|
106
|
+
started_at: Type.Optional(Type.String({ description: "ISO timestamp used for elapsed time" })),
|
|
107
|
+
input_tokens: Type.Optional(Type.Number({ minimum: 0 })),
|
|
108
|
+
output_tokens: Type.Optional(Type.Number({ minimum: 0 })),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function taskSummary(task: TaskRecord): string {
|
|
113
|
+
const execution = task.executing ? ", executing" : "";
|
|
114
|
+
const label = task.label ? ` [${task.label}]` : "";
|
|
115
|
+
return `#${task.number} [${task.status}${execution}] ${task.id} — ${task.subject}${label}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function taskDetails(task: TaskRecord): string {
|
|
119
|
+
const lines = [taskSummary(task)];
|
|
120
|
+
if (task.description) lines.push(task.description);
|
|
121
|
+
if (task.label) lines.push(`Label: ${task.label}`);
|
|
122
|
+
if (task.progress !== undefined) lines.push(`Progress: ${task.progress}%`);
|
|
123
|
+
if (task.owner) lines.push(`Owner: ${task.owner}`);
|
|
124
|
+
if (task.parentId) lines.push(`Parent: ${task.parentId}`);
|
|
125
|
+
if (task.blockedBy.length) lines.push(`Blocked by: ${task.blockedBy.join(", ")}`);
|
|
126
|
+
if (task.activeForm) lines.push(`Active form: ${task.activeForm}`);
|
|
127
|
+
if (task.startedAt) lines.push(`Started: ${task.startedAt}`);
|
|
128
|
+
if (task.inputTokens !== undefined || task.outputTokens !== undefined) {
|
|
129
|
+
lines.push(`Tokens: ↑ ${task.inputTokens ?? 0} ↓ ${task.outputTokens ?? 0}`);
|
|
130
|
+
}
|
|
131
|
+
return lines.join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function dashboardDetails(dashboard: TaskDashboard): string {
|
|
135
|
+
const active = dashboard.active.length ? dashboard.active.map(taskSummary).join("\n") : "none";
|
|
136
|
+
return [
|
|
137
|
+
`Active (${dashboard.active.length}):`,
|
|
138
|
+
active,
|
|
139
|
+
`Next: ${dashboard.next ? taskSummary(dashboard.next) : "none"}`,
|
|
140
|
+
`Focused: ${dashboard.focused ? taskSummary(dashboard.focused) : "none"}`,
|
|
141
|
+
].join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function fit(text: string, width: number, theme: Theme): string {
|
|
145
|
+
const safeWidth = Math.max(0, width);
|
|
146
|
+
const truncated = truncateToWidth(text, safeWidth, theme.fg("dim", "…"));
|
|
147
|
+
return truncated + " ".repeat(Math.max(0, safeWidth - visibleWidth(truncated)));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function framedRow(text: string, width: number, theme: Theme): string {
|
|
151
|
+
if (width < 2) return truncateToWidth(text, width, "");
|
|
152
|
+
return theme.fg("borderMuted", "│") + fit(` ${text}`, width - 2, theme) + theme.fg("borderMuted", "│");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function taskLabelColor(label: string): (typeof LABEL_COLORS)[number] {
|
|
156
|
+
let hash = 2_166_136_261;
|
|
157
|
+
for (const character of label.toLowerCase()) {
|
|
158
|
+
hash ^= character.codePointAt(0) ?? 0;
|
|
159
|
+
hash = Math.imul(hash, 16_777_619);
|
|
160
|
+
}
|
|
161
|
+
return LABEL_COLORS[(hash >>> 0) % LABEL_COLORS.length];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function framedTaskRow(text: string, label: string | undefined, width: number, theme: Theme, dimLabel = false): string {
|
|
165
|
+
if (!label || width < 10) return framedRow(text, width, theme);
|
|
166
|
+
const innerWidth = width - 2;
|
|
167
|
+
const maxBadgeWidth = Math.min(Math.floor(innerWidth * 0.4), innerWidth - 6);
|
|
168
|
+
if (maxBadgeWidth < 3) return framedRow(text, width, theme);
|
|
169
|
+
const labelText = truncateToWidth(label, maxBadgeWidth - 2, "…");
|
|
170
|
+
const badge = `[${labelText}]`;
|
|
171
|
+
const leftWidth = innerWidth - visibleWidth(badge) - 3;
|
|
172
|
+
if (leftWidth < 1) return framedRow(text, width, theme);
|
|
173
|
+
const labelColor = dimLabel ? "dim" : taskLabelColor(label);
|
|
174
|
+
return theme.fg("borderMuted", "│")
|
|
175
|
+
+ ` ${fit(text, leftWidth, theme)} ${theme.fg(labelColor, badge)} `
|
|
176
|
+
+ theme.fg("borderMuted", "│");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function divider(label: string, width: number, theme: Theme): string {
|
|
180
|
+
const innerWidth = Math.max(1, width - 4);
|
|
181
|
+
const labelText = ` ${label} `;
|
|
182
|
+
const left = Math.max(1, Math.floor((innerWidth - labelText.length) / 2));
|
|
183
|
+
const right = Math.max(1, innerWidth - labelText.length - left);
|
|
184
|
+
return framedRow(theme.fg("dim", `${"─".repeat(left)}${labelText}${"─".repeat(right)}`), width, theme);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function formatTokens(tokens: number): string {
|
|
188
|
+
if (tokens < 1_000) return String(tokens);
|
|
189
|
+
if (tokens < 1_000_000) return `${(tokens / 1_000).toFixed(tokens < 10_000 ? 1 : 0)}k`;
|
|
190
|
+
return `${(tokens / 1_000_000).toFixed(tokens < 10_000_000 ? 1 : 0)}m`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function formatElapsed(startedAt: string | undefined, now: number): string | undefined {
|
|
194
|
+
if (!startedAt) return undefined;
|
|
195
|
+
const started = Date.parse(startedAt);
|
|
196
|
+
if (!Number.isFinite(started)) return undefined;
|
|
197
|
+
const totalSeconds = Math.max(0, Math.floor((now - started) / 1_000));
|
|
198
|
+
const hours = Math.floor(totalSeconds / 3_600);
|
|
199
|
+
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
|
200
|
+
const seconds = totalSeconds % 60;
|
|
201
|
+
return hours > 0 ? `${hours}h ${minutes}m` : minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function orderTasksForDisplay(tasks: TaskRecord[]): TaskRecord[] {
|
|
205
|
+
const includedIds = new Set(tasks.map((task) => task.id));
|
|
206
|
+
const children = new Map<string, TaskRecord[]>();
|
|
207
|
+
const roots: TaskRecord[] = [];
|
|
208
|
+
|
|
209
|
+
for (const task of tasks) {
|
|
210
|
+
if (task.parentId && includedIds.has(task.parentId)) {
|
|
211
|
+
const siblings = children.get(task.parentId) ?? [];
|
|
212
|
+
siblings.push(task);
|
|
213
|
+
children.set(task.parentId, siblings);
|
|
214
|
+
} else {
|
|
215
|
+
roots.push(task);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const byNumber = (left: TaskRecord, right: TaskRecord) =>
|
|
220
|
+
(left.subtaskNumber ?? left.number) - (right.subtaskNumber ?? right.number) || left.number - right.number;
|
|
221
|
+
roots.sort((left, right) => left.number - right.number);
|
|
222
|
+
for (const siblings of children.values()) siblings.sort(byNumber);
|
|
223
|
+
|
|
224
|
+
const ordered: TaskRecord[] = [];
|
|
225
|
+
const visit = (task: TaskRecord) => {
|
|
226
|
+
ordered.push(task);
|
|
227
|
+
for (const child of children.get(task.id) ?? []) visit(child);
|
|
228
|
+
};
|
|
229
|
+
for (const root of roots) visit(root);
|
|
230
|
+
return ordered;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function blockerText(task: TaskRecord, tasks: TaskRecord[]): string | undefined {
|
|
234
|
+
if (!task.blockedBy.length) return undefined;
|
|
235
|
+
const taskById = new Map(tasks.map((item) => [item.id, item]));
|
|
236
|
+
const blockers = task.blockedBy.flatMap((id) => {
|
|
237
|
+
const blocker = taskById.get(id);
|
|
238
|
+
if (blocker?.status === "completed") return [];
|
|
239
|
+
return [blocker ? `#${getTaskDisplayNumber(blocker, tasks)}` : id];
|
|
240
|
+
});
|
|
241
|
+
return blockers.length ? `› blocked by ${blockers.join(", ")}` : undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function taskLine(
|
|
245
|
+
task: TaskRecord,
|
|
246
|
+
tasks: TaskRecord[],
|
|
247
|
+
focused: boolean,
|
|
248
|
+
spinnerFrame: string,
|
|
249
|
+
theme: Theme,
|
|
250
|
+
showHierarchy = true,
|
|
251
|
+
): string {
|
|
252
|
+
let glyph: string;
|
|
253
|
+
let label = task.subject;
|
|
254
|
+
if (task.executing) {
|
|
255
|
+
glyph = theme.fg("warning", spinnerFrame);
|
|
256
|
+
label = task.activeForm ?? task.subject;
|
|
257
|
+
} else {
|
|
258
|
+
switch (task.status) {
|
|
259
|
+
case "completed": glyph = "✔"; break;
|
|
260
|
+
case "in_progress": glyph = theme.fg("accent", "◼"); break;
|
|
261
|
+
case "pending": glyph = theme.fg("dim", "◻"); break;
|
|
262
|
+
case "failed": glyph = theme.fg("error", "✖"); break;
|
|
263
|
+
case "stopped": glyph = theme.fg("dim", "■"); break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const indent = showHierarchy ? " ".repeat(getTaskDepth(task, tasks)) : "";
|
|
268
|
+
const taskLabel = `#${getTaskDisplayNumber(task, tasks)} ${label}`;
|
|
269
|
+
if (task.status === "completed") {
|
|
270
|
+
return `${indent}${COMPLETED_ICON} ${theme.fg("dim", theme.strikethrough(taskLabel))}`;
|
|
271
|
+
}
|
|
272
|
+
const content = `${indent}${glyph} ${taskLabel}`;
|
|
273
|
+
if (task.status === "failed") return theme.fg("error", content);
|
|
274
|
+
if (task.status === "pending") return theme.fg("muted", content);
|
|
275
|
+
return focused ? theme.bold(content) : content;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function taskMetadata(task: TaskRecord, tasks: TaskRecord[], theme: Theme): string | undefined {
|
|
279
|
+
const indent = " ".repeat(getTaskDepth(task, tasks) + 1);
|
|
280
|
+
if (task.executing) {
|
|
281
|
+
const telemetry: string[] = [];
|
|
282
|
+
const elapsed = formatElapsed(task.startedAt, Date.now());
|
|
283
|
+
if (elapsed) telemetry.push(elapsed);
|
|
284
|
+
if (task.inputTokens !== undefined || task.outputTokens !== undefined) {
|
|
285
|
+
telemetry.push(`↑ ${formatTokens(task.inputTokens ?? 0)} ↓ ${formatTokens(task.outputTokens ?? 0)}`);
|
|
286
|
+
}
|
|
287
|
+
if (telemetry.length) return theme.fg("dim", `${indent}${telemetry.join(" · ")}`);
|
|
288
|
+
}
|
|
289
|
+
const blocked = blockerText(task, tasks);
|
|
290
|
+
return blocked ? theme.fg("dim", `${indent}${blocked}`) : undefined;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export class TaskBarComponent {
|
|
294
|
+
private readonly getState: () => TaskUiState;
|
|
295
|
+
private readonly getSpinnerFrame: () => string;
|
|
296
|
+
private readonly theme: Theme;
|
|
297
|
+
|
|
298
|
+
constructor(getState: () => TaskUiState, getSpinnerFrame: () => string, theme: Theme) {
|
|
299
|
+
this.getState = getState;
|
|
300
|
+
this.getSpinnerFrame = getSpinnerFrame;
|
|
301
|
+
this.theme = theme;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
render(width: number): string[] {
|
|
305
|
+
const state = this.getState();
|
|
306
|
+
const tasks = state.tasks;
|
|
307
|
+
if (!tasks.length) return [];
|
|
308
|
+
const work = orderTasksForDisplay(tasks.filter((task) => task.status === "in_progress" || task.status === "pending"));
|
|
309
|
+
const history = tasks
|
|
310
|
+
.filter((task) => ["completed", "failed", "stopped"].includes(task.status))
|
|
311
|
+
.sort((left, right) => (right.terminalAt ?? right.updatedAt).localeCompare(left.terminalAt ?? left.updatedAt) || right.number - left.number);
|
|
312
|
+
const topTitle = " Tasks ";
|
|
313
|
+
const topFill = Math.max(0, width - visibleWidth(topTitle) - 2);
|
|
314
|
+
const lines = [this.theme.fg("borderMuted", `╭${topTitle}${"─".repeat(topFill)}╮`)];
|
|
315
|
+
|
|
316
|
+
if (tasks.length > 0) {
|
|
317
|
+
if (work.length) {
|
|
318
|
+
for (const task of work.slice(0, MAX_VISIBLE_WORK_TASKS)) {
|
|
319
|
+
lines.push(framedTaskRow(taskLine(task, tasks, task.id === state.focusedTaskId, this.getSpinnerFrame(), this.theme), task.label, width, this.theme));
|
|
320
|
+
const metadata = taskMetadata(task, tasks, this.theme);
|
|
321
|
+
if (metadata) lines.push(framedRow(metadata, width, this.theme));
|
|
322
|
+
}
|
|
323
|
+
if (work.length > MAX_VISIBLE_WORK_TASKS) {
|
|
324
|
+
lines.push(framedRow(this.theme.fg("dim", `… and ${work.length - MAX_VISIBLE_WORK_TASKS} more`), width, this.theme));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (history.length) {
|
|
328
|
+
if (!work.length) lines.push(framedRow(this.theme.fg("muted", "All done!"), width, this.theme));
|
|
329
|
+
lines.push(divider("history", width, this.theme));
|
|
330
|
+
for (const task of history.slice(0, MAX_VISIBLE_HISTORY_TASKS)) {
|
|
331
|
+
lines.push(framedTaskRow(
|
|
332
|
+
taskLine(task, tasks, false, this.getSpinnerFrame(), this.theme, false),
|
|
333
|
+
task.label,
|
|
334
|
+
width,
|
|
335
|
+
this.theme,
|
|
336
|
+
task.status === "completed",
|
|
337
|
+
));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
lines.push(this.theme.fg("borderMuted", `╰${"─".repeat(Math.max(0, width - 2))}╯`));
|
|
343
|
+
return lines.map((line) => truncateToWidth(line, width, ""));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
invalidate(): void {}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function renderToolCall(name: string, detail: string | undefined, theme: Theme): Text {
|
|
350
|
+
const suffix = detail ? ` ${theme.fg("dim", detail)}` : "";
|
|
351
|
+
return new Text(theme.fg("toolTitle", theme.bold(name)) + suffix, 0, 0);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function renderToolResult(result: { content: Array<{ type: string; text?: string }>; details?: TaskToolDetails }, theme: Theme): Text {
|
|
355
|
+
const details = result.details;
|
|
356
|
+
if (details?.task) return new Text(theme.fg("success", "✓ ") + theme.fg("muted", taskSummary(details.task)), 0, 0);
|
|
357
|
+
if (details?.dashboard) {
|
|
358
|
+
return new Text(theme.fg("muted", `${details.dashboard.active.length} active · next ${details.dashboard.next ? `#${details.dashboard.next.number}` : "none"}`), 0, 0);
|
|
359
|
+
}
|
|
360
|
+
if (details?.tasks) return new Text(theme.fg("muted", `${details.tasks.length} projected task(s)`), 0, 0);
|
|
361
|
+
const first = result.content[0];
|
|
362
|
+
return new Text(first?.type === "text" ? first.text ?? "" : "", 0, 0);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export default function taskUiExtension(pi: ExtensionAPI): void {
|
|
366
|
+
let state = createInitialTaskUiState();
|
|
367
|
+
let currentCtx: ExtensionContext | undefined;
|
|
368
|
+
let overlayHandle: OverlayHandle | undefined;
|
|
369
|
+
let overlayVisible = true;
|
|
370
|
+
let requestRender: (() => void) | undefined;
|
|
371
|
+
let sessionActive = false;
|
|
372
|
+
let spinnerFrame = 0;
|
|
373
|
+
let animationTimer: ReturnType<typeof setInterval> | undefined;
|
|
374
|
+
|
|
375
|
+
const stopAnimation = () => {
|
|
376
|
+
if (animationTimer) clearInterval(animationTimer);
|
|
377
|
+
animationTimer = undefined;
|
|
378
|
+
spinnerFrame = 0;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const syncAnimation = () => {
|
|
382
|
+
const shouldAnimate = sessionActive && overlayVisible && state.tasks.some((task) => task.executing);
|
|
383
|
+
if (!shouldAnimate) {
|
|
384
|
+
stopAnimation();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (animationTimer) return;
|
|
388
|
+
animationTimer = setInterval(() => {
|
|
389
|
+
spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
390
|
+
requestRender?.();
|
|
391
|
+
}, 500);
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const publishState = () => {
|
|
395
|
+
if (sessionActive) pi.appendEntry(STATE_ENTRY_TYPE, cloneTaskUiState(state));
|
|
396
|
+
requestRender?.();
|
|
397
|
+
syncAnimation();
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const setState = (next: TaskUiState) => {
|
|
401
|
+
state = next;
|
|
402
|
+
publishState();
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const reportEventError = (error: unknown) => {
|
|
406
|
+
if (currentCtx?.hasUI) currentCtx.ui.notify(error instanceof Error ? error.message : "Invalid task-ui event", "warning");
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const showOverlay = (ctx: ExtensionContext) => {
|
|
410
|
+
if (ctx.mode !== "tui") return;
|
|
411
|
+
overlayVisible = true;
|
|
412
|
+
if (overlayHandle) {
|
|
413
|
+
overlayHandle.setHidden(false);
|
|
414
|
+
requestRender?.();
|
|
415
|
+
syncAnimation();
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
void ctx.ui.custom<void>((tui, theme) => {
|
|
419
|
+
requestRender = () => tui.requestRender();
|
|
420
|
+
return new TaskBarComponent(() => state, () => SPINNER_FRAMES[spinnerFrame], theme);
|
|
421
|
+
}, {
|
|
422
|
+
overlay: true,
|
|
423
|
+
overlayOptions: {
|
|
424
|
+
anchor: "top-right",
|
|
425
|
+
width: "38%",
|
|
426
|
+
minWidth: 48,
|
|
427
|
+
maxHeight: "76%",
|
|
428
|
+
margin: { top: 1, right: 1 },
|
|
429
|
+
nonCapturing: true,
|
|
430
|
+
visible: (termWidth) => termWidth >= OVERLAY_MIN_TERMINAL_WIDTH,
|
|
431
|
+
},
|
|
432
|
+
onHandle: (handle) => {
|
|
433
|
+
overlayHandle = handle;
|
|
434
|
+
syncAnimation();
|
|
435
|
+
},
|
|
436
|
+
}).finally(() => {
|
|
437
|
+
overlayHandle = undefined;
|
|
438
|
+
requestRender = undefined;
|
|
439
|
+
stopAnimation();
|
|
440
|
+
});
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const toggleOverlay = (ctx: ExtensionContext) => {
|
|
444
|
+
if (ctx.mode !== "tui") {
|
|
445
|
+
ctx.ui.notify("Task UI requires interactive mode", "warning");
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (!overlayHandle) {
|
|
449
|
+
showOverlay(ctx);
|
|
450
|
+
ctx.ui.notify("Task UI shown", "info");
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
overlayVisible = !overlayVisible;
|
|
454
|
+
overlayHandle.setHidden(!overlayVisible);
|
|
455
|
+
syncAnimation();
|
|
456
|
+
ctx.ui.notify(`Task UI ${overlayVisible ? "shown" : "hidden"}`, "info");
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const persistMutation = (next: TaskUiState): TaskUiState => {
|
|
460
|
+
setState(next);
|
|
461
|
+
return next;
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
pi.events.on(TASK_UI_EVENTS.snapshot, (payload) => {
|
|
465
|
+
try {
|
|
466
|
+
const event = payload as SnapshotEvent;
|
|
467
|
+
if (!Array.isArray(event.tasks)) throw new Error("task-ui:snapshot requires tasks[]");
|
|
468
|
+
setState(replaceExternalTasks(event.tasks, event.focusedTaskId));
|
|
469
|
+
} catch (error) { reportEventError(error); }
|
|
470
|
+
});
|
|
471
|
+
pi.events.on(TASK_UI_EVENTS.upsert, (payload) => {
|
|
472
|
+
try { setState(upsertExternalTask(state, payload as ExternalTaskInput).state); } catch (error) { reportEventError(error); }
|
|
473
|
+
});
|
|
474
|
+
pi.events.on(TASK_UI_EVENTS.remove, (payload) => {
|
|
475
|
+
try { setState(removeTask(state, (payload as RemoveEvent).taskId)); } catch (error) { reportEventError(error); }
|
|
476
|
+
});
|
|
477
|
+
pi.events.on(TASK_UI_EVENTS.output, (payload) => {
|
|
478
|
+
try {
|
|
479
|
+
const event = payload as OutputEvent;
|
|
480
|
+
setState(appendTaskOutput(state, event.taskId, event.text).state);
|
|
481
|
+
} catch (error) { reportEventError(error); }
|
|
482
|
+
});
|
|
483
|
+
pi.events.on(TASK_UI_EVENTS.focus, (payload) => {
|
|
484
|
+
try { setState(setFocusedTask(state, (payload as FocusEvent).taskId)); } catch (error) { reportEventError(error); }
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
pi.registerTool({
|
|
488
|
+
name: "task_ui_create",
|
|
489
|
+
label: "Task UI Create",
|
|
490
|
+
description: "Create or mirror one task in task-ui's presentation-only projection. This does not start backend work.",
|
|
491
|
+
promptSnippet: "Create or mirror one task in task-ui (UI only)",
|
|
492
|
+
executionMode: "sequential",
|
|
493
|
+
parameters: createTaskSchema(),
|
|
494
|
+
async execute(_id, params) {
|
|
495
|
+
const result = createTask(state, toCreateTaskInput(params));
|
|
496
|
+
persistMutation(result.state);
|
|
497
|
+
return { content: [{ type: "text", text: `Projected ${taskSummary(result.task)}. No backend work was started.` }], details: { action: "create", task: result.task } as TaskToolDetails };
|
|
498
|
+
},
|
|
499
|
+
renderCall: (args, theme) => renderToolCall("task_ui_create", args.subject, theme),
|
|
500
|
+
renderResult: (result, _options, theme) => renderToolResult(result as never, theme),
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
pi.registerTool({
|
|
504
|
+
name: "task_ui_batch_create",
|
|
505
|
+
label: "Task UI Batch Create",
|
|
506
|
+
description: "Atomically create or mirror several tasks in task-ui's presentation-only projection. This does not start backend work.",
|
|
507
|
+
promptSnippet: "Create several tasks in task-ui atomically (UI only)",
|
|
508
|
+
executionMode: "sequential",
|
|
509
|
+
parameters: Type.Object({ tasks: Type.Array(createTaskSchema(), { minItems: 1, maxItems: 100 }) }),
|
|
510
|
+
async execute(_id, params) {
|
|
511
|
+
const result = createTasks(state, params.tasks.map(toCreateTaskInput));
|
|
512
|
+
persistMutation(result.state);
|
|
513
|
+
return { content: [{ type: "text", text: `Projected ${result.tasks.length} tasks atomically. No backend work was started.` }], details: { action: "batch_create", tasks: result.tasks } as TaskToolDetails };
|
|
514
|
+
},
|
|
515
|
+
renderCall: (args, theme) => renderToolCall("task_ui_batch_create", `${args.tasks.length} tasks`, theme),
|
|
516
|
+
renderResult: (result, _options, theme) => renderToolResult(result as never, theme),
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
pi.registerTool({
|
|
520
|
+
name: "task_ui_list",
|
|
521
|
+
label: "Task UI List",
|
|
522
|
+
description: "List tasks currently shown by task-ui. This reads only the UI projection.",
|
|
523
|
+
promptSnippet: "List task-ui's current presentation projection",
|
|
524
|
+
executionMode: "sequential",
|
|
525
|
+
parameters: Type.Object({ status: Type.Optional(StringEnum(TASK_STATUSES)) }),
|
|
526
|
+
async execute(_id, params) {
|
|
527
|
+
const tasks = state.tasks.filter((task) => !params.status || task.status === params.status).map((task) => ({ ...task, blockedBy: [...task.blockedBy], output: [...task.output] }));
|
|
528
|
+
return { content: [{ type: "text", text: tasks.length ? tasks.map(taskSummary).join("\n") : "No projected tasks" }], details: { action: "list", tasks } as TaskToolDetails };
|
|
529
|
+
},
|
|
530
|
+
renderCall: (args, theme) => renderToolCall("task_ui_list", args.status, theme),
|
|
531
|
+
renderResult: (result, _options, theme) => renderToolResult(result as never, theme),
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
pi.registerTool({
|
|
535
|
+
name: "task_ui_get",
|
|
536
|
+
label: "Task UI Get",
|
|
537
|
+
description: "Get one task from the UI projection by task_id, or omit task_id to get all active tasks plus next and focused tasks.",
|
|
538
|
+
promptSnippet: "Read a task or the active/next task-ui dashboard state",
|
|
539
|
+
executionMode: "sequential",
|
|
540
|
+
parameters: Type.Object({ task_id: Type.Optional(Type.String()) }),
|
|
541
|
+
async execute(_id, params) {
|
|
542
|
+
if (params.task_id) {
|
|
543
|
+
const task = state.tasks.find((item) => item.id === params.task_id);
|
|
544
|
+
if (!task) throw new Error(`Task not found: ${params.task_id}`);
|
|
545
|
+
return { content: [{ type: "text", text: taskDetails(task) }], details: { action: "get", task } as TaskToolDetails };
|
|
546
|
+
}
|
|
547
|
+
const dashboard = getTaskDashboard(state);
|
|
548
|
+
return { content: [{ type: "text", text: dashboardDetails(dashboard) }], details: { action: "get_dashboard", dashboard } as TaskToolDetails };
|
|
549
|
+
},
|
|
550
|
+
renderCall: (args, theme) => renderToolCall("task_ui_get", args.task_id ?? "active + next", theme),
|
|
551
|
+
renderResult: (result, _options, theme) => renderToolResult(result as never, theme),
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
pi.registerTool({
|
|
555
|
+
name: "task_ui_update",
|
|
556
|
+
label: "Task UI Update",
|
|
557
|
+
description: "Update task state and execution telemetry in task-ui's presentation-only projection. Backend unchanged.",
|
|
558
|
+
promptSnippet: "Update a task and its telemetry in task-ui (UI only)",
|
|
559
|
+
executionMode: "sequential",
|
|
560
|
+
parameters: Type.Object({
|
|
561
|
+
task_id: Type.String(),
|
|
562
|
+
subject: Type.Optional(Type.String()),
|
|
563
|
+
description: Type.Optional(Type.String()),
|
|
564
|
+
label: Type.Optional(Type.String({ description: "Short right-aligned label, without brackets; empty string clears it" })),
|
|
565
|
+
status: Type.Optional(StringEnum(TASK_STATUSES)),
|
|
566
|
+
progress: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })),
|
|
567
|
+
owner: Type.Optional(Type.String()),
|
|
568
|
+
parent_id: Type.Optional(Type.Union([Type.String(), Type.Null()], { description: "Set a parent task ID, or null to make this a root task" })),
|
|
569
|
+
blocked_by: Type.Optional(Type.Array(Type.String())),
|
|
570
|
+
executing: Type.Optional(Type.Boolean()),
|
|
571
|
+
active_form: Type.Optional(Type.String()),
|
|
572
|
+
started_at: Type.Optional(Type.String()),
|
|
573
|
+
input_tokens: Type.Optional(Type.Number({ minimum: 0 })),
|
|
574
|
+
output_tokens: Type.Optional(Type.Number({ minimum: 0 })),
|
|
575
|
+
}),
|
|
576
|
+
async execute(_id, params) {
|
|
577
|
+
const result = updateTask(state, {
|
|
578
|
+
taskId: params.task_id,
|
|
579
|
+
subject: params.subject,
|
|
580
|
+
description: params.description,
|
|
581
|
+
label: params.label,
|
|
582
|
+
status: params.status,
|
|
583
|
+
progress: params.progress,
|
|
584
|
+
owner: params.owner,
|
|
585
|
+
parentId: params.parent_id,
|
|
586
|
+
blockedBy: params.blocked_by,
|
|
587
|
+
executing: params.executing,
|
|
588
|
+
activeForm: params.active_form,
|
|
589
|
+
startedAt: params.started_at,
|
|
590
|
+
inputTokens: params.input_tokens,
|
|
591
|
+
outputTokens: params.output_tokens,
|
|
592
|
+
});
|
|
593
|
+
persistMutation(result.state);
|
|
594
|
+
return { content: [{ type: "text", text: `Updated UI projection: ${taskSummary(result.task)}. Backend unchanged.` }], details: { action: "update", task: result.task } as TaskToolDetails };
|
|
595
|
+
},
|
|
596
|
+
renderCall: (args, theme) => renderToolCall("task_ui_update", args.task_id, theme),
|
|
597
|
+
renderResult: (result, _options, theme) => renderToolResult(result as never, theme),
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
pi.registerTool({
|
|
601
|
+
name: "task_ui_output",
|
|
602
|
+
label: "Task UI Output",
|
|
603
|
+
description: "Append, read, or clear output cached in task-ui. This never reads backend process output automatically.",
|
|
604
|
+
promptSnippet: "Manage displayed output in task-ui (UI only)",
|
|
605
|
+
executionMode: "sequential",
|
|
606
|
+
parameters: Type.Object({
|
|
607
|
+
task_id: Type.String(),
|
|
608
|
+
operation: StringEnum(["append", "read", "clear"] as const),
|
|
609
|
+
text: Type.Optional(Type.String({ description: "Required for append" })),
|
|
610
|
+
limit: Type.Optional(Type.Number({ minimum: 1, maximum: 20 })),
|
|
611
|
+
}),
|
|
612
|
+
async execute(_id, params) {
|
|
613
|
+
let task = state.tasks.find((item) => item.id === params.task_id);
|
|
614
|
+
if (!task) throw new Error(`Task not found: ${params.task_id}`);
|
|
615
|
+
if (params.operation === "append") {
|
|
616
|
+
if (!params.text) throw new Error("task_ui_output append requires text");
|
|
617
|
+
const result = appendTaskOutput(state, params.task_id, params.text);
|
|
618
|
+
task = result.task;
|
|
619
|
+
persistMutation(result.state);
|
|
620
|
+
} else if (params.operation === "clear") {
|
|
621
|
+
const result = clearTaskOutput(state, params.task_id);
|
|
622
|
+
task = result.task;
|
|
623
|
+
persistMutation(result.state);
|
|
624
|
+
}
|
|
625
|
+
const output = task.output.slice(-(params.limit ?? 10));
|
|
626
|
+
const text = output.length ? output.map((entry) => `${entry.timestamp} ${entry.text}`).join("\n") : "No projected output";
|
|
627
|
+
return { content: [{ type: "text", text }], details: { action: `output:${params.operation}`, task } as TaskToolDetails };
|
|
628
|
+
},
|
|
629
|
+
renderCall: (args, theme) => renderToolCall("task_ui_output", `${args.operation} ${args.task_id}`, theme),
|
|
630
|
+
renderResult: (result, _options, theme) => renderToolResult(result as never, theme),
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
pi.registerTool({
|
|
634
|
+
name: "task_ui_remove",
|
|
635
|
+
label: "Task UI Remove",
|
|
636
|
+
description: "Remove one task from task-ui's presentation-only projection. Child tasks become root tasks. Backend unchanged.",
|
|
637
|
+
promptSnippet: "Remove one task from task-ui (UI only)",
|
|
638
|
+
executionMode: "sequential",
|
|
639
|
+
parameters: Type.Object({ task_id: Type.String() }),
|
|
640
|
+
async execute(_id, params) {
|
|
641
|
+
persistMutation(removeTask(state, params.task_id));
|
|
642
|
+
return { content: [{ type: "text", text: `Removed ${params.task_id} from the UI projection. Backend unchanged.` }], details: { action: "remove" } as TaskToolDetails };
|
|
643
|
+
},
|
|
644
|
+
renderCall: (args, theme) => renderToolCall("task_ui_remove", args.task_id, theme),
|
|
645
|
+
renderResult: (result, _options, theme) => renderToolResult(result as never, theme),
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
pi.registerTool({
|
|
649
|
+
name: "task_ui_clear",
|
|
650
|
+
label: "Task UI Clear",
|
|
651
|
+
description: "Clear every task from task-ui's presentation-only projection. Backend unchanged.",
|
|
652
|
+
promptSnippet: "Clear the entire task-ui projection (UI only)",
|
|
653
|
+
executionMode: "sequential",
|
|
654
|
+
parameters: Type.Object({}),
|
|
655
|
+
async execute() {
|
|
656
|
+
const count = state.tasks.length;
|
|
657
|
+
persistMutation(createInitialTaskUiState());
|
|
658
|
+
return { content: [{ type: "text", text: `Cleared ${count} projected tasks. Backend unchanged.` }], details: { action: "clear", tasks: [] } as TaskToolDetails };
|
|
659
|
+
},
|
|
660
|
+
renderCall: (_args, theme) => renderToolCall("task_ui_clear", undefined, theme),
|
|
661
|
+
renderResult: (result, _options, theme) => renderToolResult(result as never, theme),
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
pi.registerTool({
|
|
665
|
+
name: "task_ui_stop",
|
|
666
|
+
label: "Task UI Stop",
|
|
667
|
+
description: "Mark a projected task stopped, retain it in history, and advance UI focus. This never stops backend work.",
|
|
668
|
+
promptSnippet: "Move a task to stopped history in task-ui (UI only)",
|
|
669
|
+
executionMode: "sequential",
|
|
670
|
+
parameters: Type.Object({ task_id: Type.String(), reason: Type.Optional(Type.String()) }),
|
|
671
|
+
async execute(_id, params) {
|
|
672
|
+
let result = updateTask(state, { taskId: params.task_id, status: "stopped", executing: false });
|
|
673
|
+
if (params.reason?.trim()) result = appendTaskOutput(result.state, params.task_id, `Stopped: ${params.reason.trim()}`);
|
|
674
|
+
persistMutation(result.state);
|
|
675
|
+
return { content: [{ type: "text", text: `Moved ${params.task_id} to stopped UI history and advanced focus. Backend unchanged.` }], details: { action: "stop", task: result.task } as TaskToolDetails };
|
|
676
|
+
},
|
|
677
|
+
renderCall: (args, theme) => renderToolCall("task_ui_stop", args.task_id, theme),
|
|
678
|
+
renderResult: (result, _options, theme) => renderToolResult(result as never, theme),
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
pi.registerShortcut("alt+u", {
|
|
682
|
+
description: "Toggle the non-capturing task sidebar",
|
|
683
|
+
handler: async (ctx) => toggleOverlay(ctx),
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
pi.registerCommand("task-ui", {
|
|
687
|
+
description: "Toggle the non-capturing task sidebar",
|
|
688
|
+
handler: async (_args, ctx) => toggleOverlay(ctx),
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
692
|
+
currentCtx = ctx;
|
|
693
|
+
sessionActive = true;
|
|
694
|
+
state = createInitialTaskUiState();
|
|
695
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
696
|
+
if (entry.type !== "custom" || entry.customType !== STATE_ENTRY_TYPE) continue;
|
|
697
|
+
const restored = normalizeStoredTaskUiState(entry.data);
|
|
698
|
+
if (restored) state = restored;
|
|
699
|
+
}
|
|
700
|
+
overlayHandle = undefined;
|
|
701
|
+
requestRender = undefined;
|
|
702
|
+
overlayVisible = true;
|
|
703
|
+
showOverlay(ctx);
|
|
704
|
+
syncAnimation();
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
708
|
+
state = createInitialTaskUiState();
|
|
709
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
710
|
+
if (entry.type !== "custom" || entry.customType !== STATE_ENTRY_TYPE) continue;
|
|
711
|
+
const restored = normalizeStoredTaskUiState(entry.data);
|
|
712
|
+
if (restored) state = restored;
|
|
713
|
+
}
|
|
714
|
+
requestRender?.();
|
|
715
|
+
syncAnimation();
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
pi.on("session_shutdown", async () => {
|
|
719
|
+
sessionActive = false;
|
|
720
|
+
currentCtx = undefined;
|
|
721
|
+
stopAnimation();
|
|
722
|
+
overlayHandle?.hide();
|
|
723
|
+
overlayHandle = undefined;
|
|
724
|
+
requestRender = undefined;
|
|
725
|
+
});
|
|
726
|
+
}
|