pi-long-task 0.3.4 → 0.3.6
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 +12 -12
- package/package.json +1 -1
- package/src/index.ts +568 -18
- package/src/render.ts +125 -259
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# Pi Long Task
|
|
2
2
|
|
|
3
|
-
Pi Long Task is a Pi extension that breaks large coding requests into tracked TODOs, executes them in isolated worker sessions,
|
|
3
|
+
Pi Long Task is a Pi extension that breaks large coding requests into tracked TODOs, executes them in isolated worker sessions, registers a real Pi TUI progress sidebar while a run is active, and optionally commits completed work.
|
|
4
4
|
|
|
5
5
|
Use it when a coding request is bigger than one focused interaction. Pi Long Task creates or cleans up the TODO plan, hands each TODO to a fresh worker session, tracks every attempt, and keeps the run artifacts so you can inspect what happened later.
|
|
6
6
|
|
|
7
7
|
## Why use it
|
|
8
8
|
|
|
9
9
|
- **Take on bigger tasks:** split broad product, refactor, testing, or cleanup requests into smaller TODOs that Pi can complete one at a time.
|
|
10
|
-
- **Track progress visibly:** see the active TODO, inferred `**Status:**` subtasks, completed/failed/blocked counts, and remaining work in Pi
|
|
10
|
+
- **Track progress visibly:** in Pi TUI, see the active TODO, inferred `**Status:**` subtasks, completed/failed/blocked counts, and remaining work in the Pi Long Task sidebar while the run is active.
|
|
11
11
|
- **Recover with retries:** tasks that do not report completion can be retried with context from previous attempts instead of losing the thread.
|
|
12
12
|
- **Commit safely when asked:** enable commits for completed task work, while generated run files and pre-existing dirty files are kept out of those commits.
|
|
13
13
|
- **Keep task artifacts:** every run writes a generated `TODO.md`, generated `TASK_RESULT.md`, attempt summaries, and final status under `tmp/pi-long-task/<run-id>/`.
|
|
@@ -20,18 +20,18 @@ When you ask Pi to run a long task, Pi Long Task:
|
|
|
20
20
|
1. Recognizes natural-language requests like "run a long task with commits" and routes them to `pi_long_task`.
|
|
21
21
|
2. Creates or cleans up a TODO plan from your request.
|
|
22
22
|
3. Works through each unfinished TODO task in order using isolated worker sessions.
|
|
23
|
-
4.
|
|
23
|
+
4. Registers a Pi TUI sidebar/widget when UI support is available and updates it with the current task, inferred subtask progress, and full task timeline while the run is active.
|
|
24
24
|
5. Retries unfinished tasks up to the configured attempt limit.
|
|
25
25
|
6. Records progress, task artifacts, and final results under `tmp/pi-long-task/<run-id>/`.
|
|
26
26
|
7. Returns a summary with completed, failed, blocked, and remaining task counts, plus worker spend when available.
|
|
27
27
|
8. Optionally commits completed work after each task.
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
During and after a run you get:
|
|
30
30
|
|
|
31
31
|
- a concise status summary in Pi
|
|
32
32
|
- a generated `TODO.md`
|
|
33
33
|
- a generated `TASK_RESULT.md`
|
|
34
|
-
- live sidebar progress for the active task and its `**Status:**` checkbox subtasks
|
|
34
|
+
- live TUI sidebar progress during the run for the active task and its `**Status:**` checkbox subtasks
|
|
35
35
|
- task attempt history and any remaining or blocked tasks clearly listed
|
|
36
36
|
- worker spend when cost data is available
|
|
37
37
|
- commit hashes when commits were enabled and created
|
|
@@ -96,7 +96,7 @@ Run a long task without commits to audit the README examples and leave the final
|
|
|
96
96
|
|
|
97
97
|
## What it looks like
|
|
98
98
|
|
|
99
|
-
Pi
|
|
99
|
+
In Pi TUI, Pi Long Task keeps worker activity in the main tool result flow and registers a real right-side TUI sidebar for the run timeline:
|
|
100
100
|
|
|
101
101
|
```text
|
|
102
102
|
┌─ Main content: active worker activity ─────────┬─ Pi Long Task sidebar ─────────┐
|
|
@@ -114,7 +114,7 @@ Pi keeps the active worker transcript in the main content area and shows the run
|
|
|
114
114
|
└────────────────────────────────────────────────┴────────────────────────────────┘
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
-
The
|
|
117
|
+
The actual sidebar is a Pi TUI overlay anchored on the right when the terminal is large enough, with a Pi widget fallback for UI contexts where the overlay is unavailable. It is cleared when the run finishes; this README mockup stays narrow enough to avoid wrapping in package galleries.
|
|
118
118
|
|
|
119
119
|
## How it works
|
|
120
120
|
|
|
@@ -122,15 +122,15 @@ Pi Long Task coordinates a long request from planning through task completion:
|
|
|
122
122
|
|
|
123
123
|
1. **Plan the work:** it creates a TODO plan from your request, or normalizes pasted TODO markdown so each item can be tracked consistently.
|
|
124
124
|
2. **Run isolated workers:** each TODO is assigned to its own fresh worker session with the relevant task text, global instructions, attempt history, and commit setting.
|
|
125
|
-
3. **Stream progress back:** the active worker's activity streams into the main Pi thread, so you can follow commands, edits, verification, and the final `TASK_RESULT` as they happen.
|
|
126
|
-
4. **
|
|
125
|
+
3. **Stream progress back:** the active worker's activity streams into the main Pi thread as partial tool results, so you can follow commands, edits, verification, and the final `TASK_RESULT` as they happen.
|
|
126
|
+
4. **Update the Pi TUI sidebar:** when Pi provides UI support, the extension uses Pi's TUI UI APIs to maintain a real sidebar/widget that lists the full run timeline, including completed, active, upcoming, failed, or blocked tasks and inferred subtask progress from each task's `**Status:**` checklist.
|
|
127
127
|
5. **Write run artifacts:** the coordinator writes the generated/normalized `TODO.md`, `TASK_RESULT.md`, attempt summaries, and final run details to `tmp/pi-long-task/<run-id>/`.
|
|
128
128
|
6. **Commit only when enabled:** if `commit` is `true`, Pi Long Task may create a commit after each completed task using only eligible task changes. If commits are disabled, no commits are created; even when enabled, commits can be skipped when there are no eligible changes or the task outcome is not commit-worthy.
|
|
129
129
|
|
|
130
130
|
## Feature reference
|
|
131
131
|
|
|
132
|
-
- **
|
|
133
|
-
- **Main-thread worker activity:** the active worker streams commands, edits, verification, and its per-task `TASK_RESULT` back into the main Pi conversation.
|
|
132
|
+
- **Real Pi TUI sidebar:** in TUI sessions, every TODO appears in a registered sidebar/widget with past, current, and future statuses so you can distinguish completed, active, upcoming, failed, blocked, and remaining work at a glance.
|
|
133
|
+
- **Main-thread worker activity:** the active worker still streams commands, edits, verification, and its per-task `TASK_RESULT` back into the main Pi conversation; the sidebar does not replace tool-result rendering.
|
|
134
134
|
- **Cost visibility:** worker spend is included in Pi Long Task progress and is added to the main Pi `$ spent` total when cost data is available.
|
|
135
135
|
- **Result and TODO artifacts:** each run keeps the generated or normalized `TODO.md`, aggregate `TASK_RESULT.md`, per-attempt summaries, and final run details under `tmp/pi-long-task/<run-id>/`.
|
|
136
136
|
- **Commit-safe behavior:** when commits are enabled, Pi Long Task commits only eligible completed-task changes and skips generated run files.
|
|
@@ -180,7 +180,7 @@ No other public options are required.
|
|
|
180
180
|
|
|
181
181
|
## Progress display
|
|
182
182
|
|
|
183
|
-
While a task is running, Pi Long Task shows the active TODO and subtasks parsed from that task's `**Status:**` checkbox list.
|
|
183
|
+
While a task is running, Pi Long Task shows the active TODO and subtasks parsed from that task's `**Status:**` checkbox list. In Pi TUI this appears in the live sidebar/widget; in headless or non-UI contexts the same progress is still published through partial tool results.
|
|
184
184
|
|
|
185
185
|
Status markers are:
|
|
186
186
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
3
|
+
import { truncateToWidth, type Component, type OverlayHandle, type TUI } from "@earendil-works/pi-tui";
|
|
3
4
|
|
|
4
5
|
import { runCoordinator, type CoordinatorProgressUpdate, type CoordinatorResult } from "./coordinator.ts";
|
|
5
6
|
import { longTaskInputTransform } from "./input_router.ts";
|
|
@@ -74,6 +75,549 @@ function toolDetails(result: CoordinatorResult) {
|
|
|
74
75
|
};
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
const LONG_TASK_WIDGET_KEY = "pi-long-task-sidebar";
|
|
79
|
+
|
|
80
|
+
type UiContext = ExtensionContext;
|
|
81
|
+
|
|
82
|
+
export interface LongTaskSidebarController {
|
|
83
|
+
update(update: CoordinatorProgressUpdate): void;
|
|
84
|
+
close(): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
class PiLongTaskSidebarComponent implements Component {
|
|
88
|
+
private readonly theme: Theme;
|
|
89
|
+
private update: CoordinatorProgressUpdate | undefined;
|
|
90
|
+
|
|
91
|
+
constructor(theme: Theme) {
|
|
92
|
+
this.theme = theme;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setUpdate(update: CoordinatorProgressUpdate): void {
|
|
96
|
+
this.update = update;
|
|
97
|
+
this.invalidate();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
render(width: number): string[] {
|
|
101
|
+
return renderSidebarOverlayLines(this.update, this.theme, width);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
invalidate(): void {
|
|
105
|
+
// Rendering is derived from the latest progress update and current theme.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function createLongTaskSidebarController(ctx: UiContext | undefined): LongTaskSidebarController | undefined {
|
|
110
|
+
if (!ctx?.hasUI) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let latestUpdate: CoordinatorProgressUpdate | undefined;
|
|
115
|
+
let overlayComponent: PiLongTaskSidebarComponent | undefined;
|
|
116
|
+
let overlayTui: TUI | undefined;
|
|
117
|
+
let overlayDone: ((result: undefined) => void) | undefined;
|
|
118
|
+
let overlayHandle: OverlayHandle | undefined;
|
|
119
|
+
let closed = false;
|
|
120
|
+
|
|
121
|
+
ctx.ui.setWidget(LONG_TASK_WIDGET_KEY, ["Pi Long Task: preparing sidebar..."], { placement: "aboveEditor" });
|
|
122
|
+
|
|
123
|
+
if (supportsTuiOverlay(ctx)) {
|
|
124
|
+
const overlayPromise = ctx.ui.custom<undefined>(
|
|
125
|
+
(tui, theme, _keybindings, done) => {
|
|
126
|
+
overlayTui = tui;
|
|
127
|
+
overlayDone = done;
|
|
128
|
+
overlayComponent = new PiLongTaskSidebarComponent(theme);
|
|
129
|
+
if (latestUpdate) {
|
|
130
|
+
overlayComponent.setUpdate(latestUpdate);
|
|
131
|
+
}
|
|
132
|
+
if (closed) {
|
|
133
|
+
done(undefined);
|
|
134
|
+
}
|
|
135
|
+
return overlayComponent;
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
overlay: true,
|
|
139
|
+
overlayOptions: {
|
|
140
|
+
anchor: "right-center",
|
|
141
|
+
width: "34%",
|
|
142
|
+
minWidth: 36,
|
|
143
|
+
maxHeight: "100%",
|
|
144
|
+
margin: 0,
|
|
145
|
+
nonCapturing: true,
|
|
146
|
+
visible: (termWidth, termHeight) => termWidth >= 96 && termHeight >= 16,
|
|
147
|
+
},
|
|
148
|
+
onHandle: (handle) => {
|
|
149
|
+
overlayHandle = handle;
|
|
150
|
+
handle.unfocus();
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
void overlayPromise.catch(() => {
|
|
155
|
+
// The widget fallback remains active if overlay registration is unavailable.
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
update(update: CoordinatorProgressUpdate): void {
|
|
161
|
+
if (closed) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
latestUpdate = update;
|
|
165
|
+
overlayComponent?.setUpdate(update);
|
|
166
|
+
overlayTui?.requestRender();
|
|
167
|
+
ctx.ui.setWidget(LONG_TASK_WIDGET_KEY, renderSidebarWidgetLines(update), { placement: "aboveEditor" });
|
|
168
|
+
},
|
|
169
|
+
close(): void {
|
|
170
|
+
if (closed) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
closed = true;
|
|
174
|
+
ctx.ui.setWidget(LONG_TASK_WIDGET_KEY, undefined);
|
|
175
|
+
if (overlayDone) {
|
|
176
|
+
overlayDone(undefined);
|
|
177
|
+
} else {
|
|
178
|
+
overlayHandle?.hide();
|
|
179
|
+
}
|
|
180
|
+
overlayComponent = undefined;
|
|
181
|
+
overlayTui = undefined;
|
|
182
|
+
overlayDone = undefined;
|
|
183
|
+
overlayHandle = undefined;
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function supportsTuiOverlay(ctx: UiContext): boolean {
|
|
189
|
+
const mode = (ctx as UiContext & { mode?: string }).mode;
|
|
190
|
+
return mode === "tui" || mode === undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function renderSidebarWidgetLines(update: CoordinatorProgressUpdate): string[] {
|
|
194
|
+
const progress = update.taskProgress;
|
|
195
|
+
const summary = progress?.summary;
|
|
196
|
+
const statusDetails = sidebarUpdateStateDetails(update);
|
|
197
|
+
const lines = ["Pi Long Task", `${statusDetails.icon} ${statusDetails.label} · ${update.message}`];
|
|
198
|
+
if (summary) {
|
|
199
|
+
lines.push(
|
|
200
|
+
`Tasks: ${summary.completedTasks}/${summary.totalTasks} · ${summary.completedPercent}%` +
|
|
201
|
+
(summary.currentTasks ? ` · ${summary.currentTasks} active` : "") +
|
|
202
|
+
(summary.pendingTasks ? ` · ${summary.pendingTasks} queued` : "") +
|
|
203
|
+
(summary.failedTasks ? ` · ${summary.failedTasks} failed` : "") +
|
|
204
|
+
(summary.blockedTasks ? ` · ${summary.blockedTasks} blocked` : ""),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (progress && progress.tasks.length > 0) {
|
|
208
|
+
const currentIndex = focusedTaskIndex(progress);
|
|
209
|
+
const currentTask = currentIndex >= 0 ? progress.tasks[currentIndex] : undefined;
|
|
210
|
+
if (currentTask) {
|
|
211
|
+
const details = taskStatusDetails(currentTask.status);
|
|
212
|
+
lines.push(`${details.label}: ${details.icon} TODO ${currentTask.taskId} — ${currentTask.title}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (update.workerCostTotal > 0) {
|
|
216
|
+
lines.push(`Spent: ${formatCost(update.workerCostTotal)}`);
|
|
217
|
+
}
|
|
218
|
+
return lines.map((line) => truncateToWidth(line, 96));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function renderSidebarOverlayLines(
|
|
222
|
+
update: CoordinatorProgressUpdate | undefined,
|
|
223
|
+
theme: Theme,
|
|
224
|
+
width: number,
|
|
225
|
+
): string[] {
|
|
226
|
+
const safeWidth = Math.max(28, width);
|
|
227
|
+
const contentWidth = Math.max(8, safeWidth - 4);
|
|
228
|
+
const rows = renderSidebarRows(update, theme, contentWidth);
|
|
229
|
+
return rows.map((row) => sidebarPanelRow(row, contentWidth, theme));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function renderSidebarRows(update: CoordinatorProgressUpdate | undefined, theme: Theme, width: number): string[] {
|
|
233
|
+
if (!update) {
|
|
234
|
+
return ["", sidebarHeading("Pi Long Task", theme), "", theme.fg("muted", "Preparing long-task sidebar...")];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const progress = update.taskProgress;
|
|
238
|
+
const rows = [""];
|
|
239
|
+
for (const line of wrapPlainText(sidebarHeadline(update, progress), width, 2)) {
|
|
240
|
+
rows.push(sidebarHeading(line, theme));
|
|
241
|
+
}
|
|
242
|
+
rows.push(renderSidebarStateLine(update, theme));
|
|
243
|
+
|
|
244
|
+
const message = normalizeMessageForSidebar(update.message, update);
|
|
245
|
+
if (message) {
|
|
246
|
+
rows.push(...wrapPlainText(message, width, 2).map((line) => theme.fg("dim", line)));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!progress || progress.tasks.length === 0) {
|
|
250
|
+
rows.push("", sidebarHeading("Context", theme), theme.fg("muted", "Waiting for TODO plan"));
|
|
251
|
+
if (update.workerCostTotal > 0) {
|
|
252
|
+
rows.push(theme.fg("muted", `${formatCost(update.workerCostTotal)} spent`));
|
|
253
|
+
}
|
|
254
|
+
return rows;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const summary = progress.summary;
|
|
258
|
+
rows.push(
|
|
259
|
+
"",
|
|
260
|
+
sidebarHeading("Context", theme),
|
|
261
|
+
theme.fg(
|
|
262
|
+
"muted",
|
|
263
|
+
`${summary.completedTasks.toLocaleString()}/${summary.totalTasks.toLocaleString()} tasks complete`,
|
|
264
|
+
),
|
|
265
|
+
theme.fg("muted", `${summary.completedPercent}% complete`),
|
|
266
|
+
);
|
|
267
|
+
if (update.workerCostTotal > 0) {
|
|
268
|
+
rows.push(theme.fg("muted", `${formatCost(update.workerCostTotal)} spent`));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
rows.push(
|
|
272
|
+
"",
|
|
273
|
+
sidebarHeading("Progress", theme),
|
|
274
|
+
progressBarLine(progress, theme),
|
|
275
|
+
progressCountsLine(summary, theme),
|
|
276
|
+
progressStateLegend(progress, theme),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const currentIndex = focusedTaskIndex(progress);
|
|
280
|
+
const currentTask = currentIndex >= 0 ? progress.tasks[currentIndex] : undefined;
|
|
281
|
+
rows.push("", sidebarHeading("Current", theme));
|
|
282
|
+
if (currentTask) {
|
|
283
|
+
const details = taskStatusDetails(currentTask.status);
|
|
284
|
+
rows.push(
|
|
285
|
+
theme.fg(
|
|
286
|
+
details.color,
|
|
287
|
+
`${details.icon} TODO ${currentTask.taskId} ${theme.fg("dim", "·")} ${details.label}${currentTaskMeta(currentTask, theme)}`,
|
|
288
|
+
),
|
|
289
|
+
);
|
|
290
|
+
rows.push(...wrapPlainText(currentTask.title, width, 2).map((line) => theme.fg("muted", line)));
|
|
291
|
+
} else {
|
|
292
|
+
rows.push(theme.fg("success", "No active task"));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
rows.push("", sidebarHeading("Task timeline", theme));
|
|
296
|
+
const taskIndexes = centeredTaskIndexes(progress.tasks.length, currentIndex, 9);
|
|
297
|
+
const first = taskIndexes[0] ?? 0;
|
|
298
|
+
const last = taskIndexes[taskIndexes.length - 1] ?? -1;
|
|
299
|
+
if (first > 0) {
|
|
300
|
+
rows.push(theme.fg("dim", `… ${first} earlier task${first === 1 ? "" : "s"}`));
|
|
301
|
+
}
|
|
302
|
+
for (const index of taskIndexes) {
|
|
303
|
+
const task = progress.tasks[index];
|
|
304
|
+
if (task) {
|
|
305
|
+
rows.push(renderTaskRow(task, index === currentIndex, theme));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const remaining = progress.tasks.length - last - 1;
|
|
309
|
+
if (remaining > 0) {
|
|
310
|
+
rows.push(theme.fg("dim", `… ${remaining} later task${remaining === 1 ? "" : "s"}`));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const subtasks = update.subtasks ?? [];
|
|
314
|
+
if (subtasks.length > 0) {
|
|
315
|
+
rows.push("", sidebarHeading("Current status", theme));
|
|
316
|
+
for (const subtask of subtasks.slice(0, 6)) {
|
|
317
|
+
rows.push(renderSubtaskRow(subtask, theme));
|
|
318
|
+
}
|
|
319
|
+
if (subtasks.length > 6) {
|
|
320
|
+
rows.push(theme.fg("dim", `… ${subtasks.length - 6} more`));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return rows;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function centeredTaskIndexes(total: number, currentIndex: number, limit: number): number[] {
|
|
328
|
+
if (total <= 0) {
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
const clampedLimit = Math.max(1, Math.min(total, limit));
|
|
332
|
+
const focus = currentIndex >= 0 ? currentIndex : 0;
|
|
333
|
+
const start = Math.max(0, Math.min(total - clampedLimit, focus - Math.floor(clampedLimit / 2)));
|
|
334
|
+
return Array.from({ length: clampedLimit }, (_value, index) => start + index);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function focusedTaskIndex(progress: NonNullable<CoordinatorProgressUpdate["taskProgress"]>): number {
|
|
338
|
+
if (typeof progress.currentIndex === "number" && progress.currentIndex >= 0) {
|
|
339
|
+
return progress.currentIndex;
|
|
340
|
+
}
|
|
341
|
+
if (typeof progress.nextIndex === "number" && progress.nextIndex >= 0) {
|
|
342
|
+
return progress.nextIndex;
|
|
343
|
+
}
|
|
344
|
+
return progress.tasks.findIndex((task) => task.status === "current" || task.position === "current");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function renderTaskRow(
|
|
348
|
+
task: NonNullable<CoordinatorProgressUpdate["taskProgress"]>["tasks"][number],
|
|
349
|
+
focused: boolean,
|
|
350
|
+
theme: Theme,
|
|
351
|
+
): string {
|
|
352
|
+
const details = taskStatusDetails(task.status);
|
|
353
|
+
const attempts = task.attempts > 0 && task.status !== "completed" ? ` ${theme.fg("dim", "·")} ${task.attempts}x` : "";
|
|
354
|
+
const title = truncateToWidth(task.title, 72);
|
|
355
|
+
const focusMarker = focused ? theme.fg("accent", "›") : theme.fg("dim", " ");
|
|
356
|
+
const icon = theme.fg(details.color, details.icon);
|
|
357
|
+
const label = `TODO ${task.taskId}`;
|
|
358
|
+
const row = `${label} ${theme.fg("dim", "·")} ${details.label} ${theme.fg("dim", "·")} ${title}${attempts}`;
|
|
359
|
+
const styledRow = focused ? theme.fg(details.color, theme.bold(row)) : theme.fg(details.textColor, row);
|
|
360
|
+
return `${focusMarker} ${icon} ${styledRow}`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function renderSubtaskRow(subtask: NonNullable<CoordinatorProgressUpdate["subtasks"]>[number], theme: Theme): string {
|
|
364
|
+
const details = progressItemStatusDetails(subtask.status);
|
|
365
|
+
return `${theme.fg(details.color, details.icon)} ${theme.fg(details.textColor, `${details.label} · ${subtask.text}`)}`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function renderSidebarStateLine(update: CoordinatorProgressUpdate, theme: Theme): string {
|
|
369
|
+
const details = sidebarUpdateStateDetails(update);
|
|
370
|
+
const suffix = [
|
|
371
|
+
update.attempt && update.attempt > 1 ? `attempt ${update.attempt}` : undefined,
|
|
372
|
+
update.workerCostTotal > 0 ? `${formatCost(update.workerCostTotal)} spent` : undefined,
|
|
373
|
+
]
|
|
374
|
+
.filter(Boolean)
|
|
375
|
+
.join(` ${theme.fg("dim", "·")} `);
|
|
376
|
+
const meta = suffix ? ` ${theme.fg("dim", "·")} ${theme.fg("muted", suffix)}` : "";
|
|
377
|
+
return `${theme.fg(details.color, details.icon)} ${theme.fg(details.color, details.label)}${meta}`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function currentTaskMeta(
|
|
381
|
+
task: NonNullable<CoordinatorProgressUpdate["taskProgress"]>["tasks"][number],
|
|
382
|
+
theme: Theme,
|
|
383
|
+
): string {
|
|
384
|
+
const attempts = task.attempts > 0 && task.status !== "completed" ? [`attempt ${task.attempts}`] : [];
|
|
385
|
+
if (task.lastReportedStatus && task.status !== "current") {
|
|
386
|
+
attempts.push(task.lastReportedStatus);
|
|
387
|
+
}
|
|
388
|
+
return attempts.length > 0 ? ` ${theme.fg("dim", "·")} ${theme.fg("muted", attempts.join(" · "))}` : "";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function progressMeterLine(progress: NonNullable<CoordinatorProgressUpdate["taskProgress"]>, theme: Theme): string {
|
|
392
|
+
const total = progress.tasks.length;
|
|
393
|
+
if (total === 0) {
|
|
394
|
+
return theme.fg("dim", "────────");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const maxSegments = 12;
|
|
398
|
+
const step = Math.max(1, Math.ceil(total / maxSegments));
|
|
399
|
+
const segments: string[] = [];
|
|
400
|
+
for (let index = 0; index < total; index += step) {
|
|
401
|
+
const slice = progress.tasks.slice(index, Math.min(total, index + step));
|
|
402
|
+
segments.push(progressMeterSegment(slice, theme));
|
|
403
|
+
}
|
|
404
|
+
return segments.join("");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function progressMeterSegment(
|
|
408
|
+
tasks: Array<NonNullable<CoordinatorProgressUpdate["taskProgress"]>["tasks"][number]>,
|
|
409
|
+
theme: Theme,
|
|
410
|
+
): string {
|
|
411
|
+
if (tasks.some((task) => task.status === "failed")) {
|
|
412
|
+
return theme.fg("error", "×");
|
|
413
|
+
}
|
|
414
|
+
if (tasks.some((task) => task.status === "blocked")) {
|
|
415
|
+
return theme.fg("warning", "!");
|
|
416
|
+
}
|
|
417
|
+
if (tasks.some((task) => task.status === "current")) {
|
|
418
|
+
return theme.fg("accent", "▢");
|
|
419
|
+
}
|
|
420
|
+
if (tasks.every((task) => task.status === "completed")) {
|
|
421
|
+
return theme.fg("success", "■");
|
|
422
|
+
}
|
|
423
|
+
if (tasks.some((task) => task.status === "completed")) {
|
|
424
|
+
return theme.fg("success", "▪");
|
|
425
|
+
}
|
|
426
|
+
return theme.fg("dim", "·");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function progressStateLegend(progress: NonNullable<CoordinatorProgressUpdate["taskProgress"]>, theme: Theme): string {
|
|
430
|
+
const statuses = new Set(progress.tasks.map((task) => task.status));
|
|
431
|
+
const items = [
|
|
432
|
+
statuses.has("completed") ? theme.fg("success", "■ done") : undefined,
|
|
433
|
+
statuses.has("current") ? theme.fg("accent", "▢ active") : undefined,
|
|
434
|
+
statuses.has("pending") ? theme.fg("dim", "· queued") : undefined,
|
|
435
|
+
statuses.has("failed") ? theme.fg("error", "× failed") : undefined,
|
|
436
|
+
statuses.has("blocked") ? theme.fg("warning", "! blocked") : undefined,
|
|
437
|
+
].filter(Boolean);
|
|
438
|
+
return theme.fg("muted", items.join(" · "));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function sidebarUpdateStateDetails(update: CoordinatorProgressUpdate): {
|
|
442
|
+
icon: string;
|
|
443
|
+
label: string;
|
|
444
|
+
color: "accent" | "success" | "warning" | "error" | "muted";
|
|
445
|
+
} {
|
|
446
|
+
if (update.status) {
|
|
447
|
+
switch (update.status) {
|
|
448
|
+
case "done":
|
|
449
|
+
return { icon: "✓", label: "done", color: "success" };
|
|
450
|
+
case "failed":
|
|
451
|
+
return { icon: "×", label: "failed", color: "error" };
|
|
452
|
+
case "blocked":
|
|
453
|
+
return { icon: "!", label: "blocked", color: "warning" };
|
|
454
|
+
case "partial":
|
|
455
|
+
return { icon: "!", label: "partial", color: "warning" };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
switch (update.phase) {
|
|
460
|
+
case "planning":
|
|
461
|
+
return { icon: "+", label: "Planning", color: "warning" };
|
|
462
|
+
case "planned":
|
|
463
|
+
return { icon: "✓", label: "Plan ready", color: "success" };
|
|
464
|
+
case "task_start":
|
|
465
|
+
return { icon: "▢", label: "Running task", color: "accent" };
|
|
466
|
+
case "worker_tool":
|
|
467
|
+
return { icon: "+", label: "Worker tool", color: "warning" };
|
|
468
|
+
case "task_done":
|
|
469
|
+
return { icon: "✓", label: "Task complete", color: "success" };
|
|
470
|
+
case "task_blocked":
|
|
471
|
+
return { icon: "!", label: "Task blocked", color: "warning" };
|
|
472
|
+
case "task_failed":
|
|
473
|
+
return { icon: "×", label: "Task failed", color: "error" };
|
|
474
|
+
case "complete":
|
|
475
|
+
return { icon: "✓", label: "Complete", color: "success" };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function taskStatusDetails(status: NonNullable<CoordinatorProgressUpdate["taskProgress"]>["tasks"][number]["status"]): {
|
|
480
|
+
icon: string;
|
|
481
|
+
label: string;
|
|
482
|
+
color: "accent" | "success" | "warning" | "error" | "dim" | "muted";
|
|
483
|
+
textColor: "accent" | "text" | "success" | "warning" | "error" | "dim" | "muted";
|
|
484
|
+
} {
|
|
485
|
+
switch (status) {
|
|
486
|
+
case "completed":
|
|
487
|
+
return { icon: "✓", label: "done", color: "success", textColor: "muted" };
|
|
488
|
+
case "current":
|
|
489
|
+
return { icon: "▢", label: "active", color: "accent", textColor: "text" };
|
|
490
|
+
case "failed":
|
|
491
|
+
return { icon: "×", label: "failed", color: "error", textColor: "error" };
|
|
492
|
+
case "blocked":
|
|
493
|
+
return { icon: "!", label: "blocked", color: "warning", textColor: "warning" };
|
|
494
|
+
case "pending":
|
|
495
|
+
return { icon: "○", label: "queued", color: "dim", textColor: "dim" };
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function progressItemStatusDetails(status: NonNullable<CoordinatorProgressUpdate["subtasks"]>[number]["status"]): {
|
|
500
|
+
icon: string;
|
|
501
|
+
label: string;
|
|
502
|
+
color: "success" | "warning" | "error" | "dim" | "muted";
|
|
503
|
+
textColor: "success" | "warning" | "error" | "dim" | "muted";
|
|
504
|
+
} {
|
|
505
|
+
switch (status) {
|
|
506
|
+
case "done":
|
|
507
|
+
return { icon: "✓", label: "done", color: "success", textColor: "muted" };
|
|
508
|
+
case "in_progress":
|
|
509
|
+
return { icon: "+", label: "active", color: "warning", textColor: "warning" };
|
|
510
|
+
case "failed":
|
|
511
|
+
return { icon: "×", label: "failed", color: "error", textColor: "error" };
|
|
512
|
+
case "blocked":
|
|
513
|
+
return { icon: "!", label: "blocked", color: "warning", textColor: "warning" };
|
|
514
|
+
case "empty":
|
|
515
|
+
return { icon: "○", label: "queued", color: "dim", textColor: "dim" };
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function progressBarLine(progress: NonNullable<CoordinatorProgressUpdate["taskProgress"]>, theme: Theme): string {
|
|
520
|
+
const summary = progress.summary;
|
|
521
|
+
return `${theme.fg("muted", "Tasks")} ${theme.fg(
|
|
522
|
+
"success",
|
|
523
|
+
`${summary.completedTasks}/${summary.totalTasks}`,
|
|
524
|
+
)} ${theme.fg("dim", "·")} ${theme.fg("muted", `${summary.completedPercent}% complete`)} ${theme.fg(
|
|
525
|
+
"dim",
|
|
526
|
+
"·",
|
|
527
|
+
)} ${progressMeterLine(progress, theme)}`;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function progressCountsLine(
|
|
531
|
+
summary: NonNullable<NonNullable<CoordinatorProgressUpdate["taskProgress"]>["summary"]>,
|
|
532
|
+
theme: Theme,
|
|
533
|
+
): string {
|
|
534
|
+
return [
|
|
535
|
+
theme.fg("success", `✓ ${summary.completedTasks} done`),
|
|
536
|
+
summary.currentTasks ? theme.fg("accent", `▢ ${summary.currentTasks} active`) : undefined,
|
|
537
|
+
summary.pendingTasks ? theme.fg("dim", `○ ${summary.pendingTasks} queued`) : undefined,
|
|
538
|
+
summary.failedTasks ? theme.fg("error", `× ${summary.failedTasks} failed`) : undefined,
|
|
539
|
+
summary.blockedTasks ? theme.fg("warning", `! ${summary.blockedTasks} blocked`) : undefined,
|
|
540
|
+
]
|
|
541
|
+
.filter(Boolean)
|
|
542
|
+
.join(" · ");
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function sidebarHeading(text: string, theme: Theme): string {
|
|
546
|
+
return theme.fg("toolTitle", theme.bold(text));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function sidebarHeadline(
|
|
550
|
+
update: CoordinatorProgressUpdate,
|
|
551
|
+
progress: CoordinatorProgressUpdate["taskProgress"],
|
|
552
|
+
): string {
|
|
553
|
+
if (update.taskId && update.title) {
|
|
554
|
+
return `TODO ${update.taskId} — ${update.title}`;
|
|
555
|
+
}
|
|
556
|
+
const currentTask = progress?.currentTask ?? progress?.nextTask;
|
|
557
|
+
if (currentTask) {
|
|
558
|
+
return `TODO ${currentTask.taskId} — ${currentTask.title}`;
|
|
559
|
+
}
|
|
560
|
+
return "Pi Long Task";
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function normalizeMessageForSidebar(updateMessage: string, update: CoordinatorProgressUpdate): string | undefined {
|
|
564
|
+
const message = updateMessage.trim();
|
|
565
|
+
if (!message) {
|
|
566
|
+
return undefined;
|
|
567
|
+
}
|
|
568
|
+
const title = update.taskId && update.title ? `TODO ${update.taskId} — ${update.title}` : undefined;
|
|
569
|
+
return title && message.includes(title) && message.length <= title.length + 16 ? undefined : message;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function wrapPlainText(text: string, width: number, limit?: number): string[] {
|
|
573
|
+
const safeWidth = Math.max(8, width);
|
|
574
|
+
const words = text.trim().split(/\s+/).filter(Boolean);
|
|
575
|
+
if (words.length === 0) {
|
|
576
|
+
return [];
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const lines: string[] = [];
|
|
580
|
+
let line = "";
|
|
581
|
+
for (const word of words) {
|
|
582
|
+
const next = line ? `${line} ${word}` : word;
|
|
583
|
+
if (next.length <= safeWidth) {
|
|
584
|
+
line = next;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (line) {
|
|
588
|
+
lines.push(line);
|
|
589
|
+
}
|
|
590
|
+
line = word.length > safeWidth ? truncateToWidth(word, safeWidth) : word;
|
|
591
|
+
if (limit && lines.length >= limit) {
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (line && (!limit || lines.length < limit)) {
|
|
596
|
+
lines.push(line);
|
|
597
|
+
}
|
|
598
|
+
if (limit && lines.length > limit) {
|
|
599
|
+
lines.length = limit;
|
|
600
|
+
}
|
|
601
|
+
if (limit && words.join(" ").length > lines.join(" ").length && lines.length > 0) {
|
|
602
|
+
lines[lines.length - 1] = truncateToWidth(`${lines[lines.length - 1]} …`, safeWidth);
|
|
603
|
+
}
|
|
604
|
+
return lines;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function sidebarPanelRow(text: string, width: number, theme: Theme): string {
|
|
608
|
+
return `${theme.fg("borderMuted", "│")} ${truncateToWidth(text, width, "…", true)}`;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function formatCost(value: number): string {
|
|
612
|
+
if (value === 0) {
|
|
613
|
+
return "$0";
|
|
614
|
+
}
|
|
615
|
+
if (value < 0.01) {
|
|
616
|
+
return `$${value.toFixed(4)}`;
|
|
617
|
+
}
|
|
618
|
+
return `$${value.toFixed(2)}`;
|
|
619
|
+
}
|
|
620
|
+
|
|
77
621
|
export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
|
|
78
622
|
const workerCostAccumulator = createWorkerCostAccumulator();
|
|
79
623
|
|
|
@@ -108,7 +652,9 @@ export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
|
|
|
108
652
|
renderCall: renderLongTaskToolCall,
|
|
109
653
|
renderResult: renderLongTaskToolResult,
|
|
110
654
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
655
|
+
const sidebar = createLongTaskSidebarController(ctx);
|
|
111
656
|
const publishProgress = (update: CoordinatorProgressUpdate) => {
|
|
657
|
+
sidebar?.update(update);
|
|
112
658
|
onUpdate?.({
|
|
113
659
|
content: [
|
|
114
660
|
{
|
|
@@ -120,23 +666,27 @@ export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
|
|
|
120
666
|
});
|
|
121
667
|
};
|
|
122
668
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
669
|
+
try {
|
|
670
|
+
const result = await runCoordinator({
|
|
671
|
+
...params,
|
|
672
|
+
cwd: ctx?.cwd,
|
|
673
|
+
abortSignal: signal,
|
|
674
|
+
onProgress: publishProgress,
|
|
675
|
+
});
|
|
676
|
+
workerCostAccumulator.add(result.workerCostTotal);
|
|
677
|
+
|
|
678
|
+
return {
|
|
679
|
+
content: [
|
|
680
|
+
{
|
|
681
|
+
type: "text" as const,
|
|
682
|
+
text: result.message,
|
|
683
|
+
},
|
|
684
|
+
],
|
|
685
|
+
details: toolDetails(result),
|
|
686
|
+
};
|
|
687
|
+
} finally {
|
|
688
|
+
sidebar?.close();
|
|
689
|
+
}
|
|
140
690
|
},
|
|
141
691
|
});
|
|
142
692
|
}
|
package/src/render.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AgentToolResult, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Text,
|
|
2
|
+
import { Text, type Component } from "@earendil-works/pi-tui";
|
|
3
3
|
|
|
4
|
-
import type { TaskProgressModel
|
|
4
|
+
import type { TaskProgressModel } from "./task_progress.ts";
|
|
5
5
|
import type { CoordinatorCommitSummary, CoordinatorRemainingTask, CoordinatorStatus } from "./types.ts";
|
|
6
6
|
|
|
7
7
|
export interface CoordinatorResultForRendering {
|
|
@@ -38,72 +38,6 @@ interface ProgressSubtaskRenderDetails {
|
|
|
38
38
|
status: ProgressItemStatus;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
const SIDEBAR_MIN_SIDE_BY_SIDE_WIDTH = 84;
|
|
42
|
-
const SIDEBAR_MIN_WIDTH = 26;
|
|
43
|
-
const SIDEBAR_MAX_WIDTH = 40;
|
|
44
|
-
const SIDEBAR_GAP = 2;
|
|
45
|
-
|
|
46
|
-
class LongTaskSidebarShell implements Component {
|
|
47
|
-
private readonly mainText: string;
|
|
48
|
-
private readonly taskProgress: TaskProgressModel;
|
|
49
|
-
private readonly workerCostTotal: number | undefined;
|
|
50
|
-
private readonly theme: Theme;
|
|
51
|
-
|
|
52
|
-
constructor(mainText: string, taskProgress: TaskProgressModel, theme: Theme, workerCostTotal?: number) {
|
|
53
|
-
this.mainText = mainText;
|
|
54
|
-
this.taskProgress = taskProgress;
|
|
55
|
-
this.workerCostTotal = workerCostTotal;
|
|
56
|
-
this.theme = theme;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
render(width: number): string[] {
|
|
60
|
-
if (width < SIDEBAR_MIN_SIDE_BY_SIDE_WIDTH) {
|
|
61
|
-
return this.renderStacked(width);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const sidebarWidth = clamp(Math.floor(width * 0.32), SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH);
|
|
65
|
-
const mainWidth = width - sidebarWidth - SIDEBAR_GAP;
|
|
66
|
-
if (mainWidth < 40) {
|
|
67
|
-
return this.renderStacked(width);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const mainLines = renderWrappedLines(this.mainText, mainWidth);
|
|
71
|
-
const sidebarLines = this.renderSidebar(sidebarWidth);
|
|
72
|
-
const height = Math.max(mainLines.length, sidebarLines.length);
|
|
73
|
-
const lines: string[] = [];
|
|
74
|
-
for (let idx = 0; idx < height; idx += 1) {
|
|
75
|
-
const main = padLine(mainLines[idx] ?? "", mainWidth);
|
|
76
|
-
const sidebar = sidebarLines[idx] ?? "";
|
|
77
|
-
lines.push(truncateToWidth(`${main}${" ".repeat(SIDEBAR_GAP)}${sidebar}`, width));
|
|
78
|
-
}
|
|
79
|
-
return lines;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
invalidate(): void {
|
|
83
|
-
// Rendering is computed from current state on each pass.
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
private renderStacked(width: number): string[] {
|
|
87
|
-
const mainLines = renderWrappedLines(this.mainText, width);
|
|
88
|
-
const sidebarLines = this.renderSidebar(width);
|
|
89
|
-
return [...mainLines, ...sidebarLines].map((line) => truncateToWidth(line, width));
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
private renderSidebar(width: number): string[] {
|
|
93
|
-
if (width < 8) {
|
|
94
|
-
return [];
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const innerWidth = Math.max(0, width - 2);
|
|
98
|
-
const rows = sidebarRows(this.taskProgress, this.theme, this.workerCostTotal);
|
|
99
|
-
return [
|
|
100
|
-
sidebarBorder("Long Task", width, this.theme),
|
|
101
|
-
...rows.map((row) => sidebarRow(row, innerWidth, this.theme)),
|
|
102
|
-
this.theme.fg("borderMuted", `└${"─".repeat(innerWidth)}┘`),
|
|
103
|
-
];
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
41
|
export function formatCoordinatorResultMessage(result: CoordinatorResultForRendering): string {
|
|
108
42
|
const resultPath = result.resultPath ?? result.taskResultPath ?? "unknown";
|
|
109
43
|
const remaining = result.remainingTasks ?? [];
|
|
@@ -160,10 +94,7 @@ export function renderLongTaskToolResult(
|
|
|
160
94
|
): Component {
|
|
161
95
|
const details = recordOrUndefined(result.details);
|
|
162
96
|
if (options.isPartial) {
|
|
163
|
-
|
|
164
|
-
const workerCostTotal = numberValue(details?.workerCostTotal);
|
|
165
|
-
const main = renderLongTaskProgress(details, contentText(result), theme);
|
|
166
|
-
return taskProgress ? new LongTaskSidebarShell(main, taskProgress, theme, workerCostTotal) : new Text(main, 0, 0);
|
|
97
|
+
return new Text(renderLongTaskProgress(details, contentText(result), theme), 0, 0);
|
|
167
98
|
}
|
|
168
99
|
|
|
169
100
|
const finalDetails = longTaskDetails(details);
|
|
@@ -171,10 +102,7 @@ export function renderLongTaskToolResult(
|
|
|
171
102
|
return new Text(contentText(result), 0, 0);
|
|
172
103
|
}
|
|
173
104
|
|
|
174
|
-
|
|
175
|
-
return finalDetails.taskProgress
|
|
176
|
-
? new LongTaskSidebarShell(main, finalDetails.taskProgress, theme, finalDetails.workerCostTotal)
|
|
177
|
-
: new Text(main, 0, 0);
|
|
105
|
+
return new Text(renderLongTaskSummary(finalDetails, options.expanded, theme), 0, 0);
|
|
178
106
|
}
|
|
179
107
|
|
|
180
108
|
function renderLongTaskProgress(details: Record<string, unknown> | undefined, fallback: string, theme: Theme): string {
|
|
@@ -183,22 +111,36 @@ function renderLongTaskProgress(details: Record<string, unknown> | undefined, fa
|
|
|
183
111
|
const toolName = stringValue(details?.toolName);
|
|
184
112
|
const prefix = phase === "worker_tool" && toolName ? `worker ${toolName}` : phase || "progress";
|
|
185
113
|
const currentTask = progressTaskDetails(details?.currentTask);
|
|
114
|
+
const progress = taskProgressModel(details?.taskProgress);
|
|
115
|
+
|
|
186
116
|
if (!currentTask) {
|
|
187
|
-
return `${theme.fg("
|
|
117
|
+
return `${theme.fg("warning", "+")} ${theme.fg("warning", `${progressPhaseLabel(phase)}:`)} ${message}`;
|
|
188
118
|
}
|
|
189
119
|
|
|
190
120
|
const taskLabel = `TODO ${currentTask.taskId} — ${currentTask.title}`;
|
|
121
|
+
const status = progressItemStatusDetails(currentTask.status);
|
|
122
|
+
const activitySuffix =
|
|
123
|
+
phase === "worker_tool" && toolName ? ` ${theme.fg("dim", "·")} ${theme.fg("muted", `worker ${toolName}`)}` : "";
|
|
124
|
+
const attempt = numberValue(details?.attempt);
|
|
125
|
+
const attemptSuffix =
|
|
126
|
+
attempt && attempt > 1 ? ` ${theme.fg("dim", "·")} ${theme.fg("muted", `attempt ${attempt}`)}` : "";
|
|
191
127
|
const lines = [
|
|
192
|
-
`${
|
|
128
|
+
`${theme.fg("warning", "+")} ${theme.fg("warning", `${progressPhaseLabel(phase)}:`)} ${theme.fg(
|
|
129
|
+
status.textColor,
|
|
130
|
+
taskLabel,
|
|
131
|
+
)}${activitySuffix}${attemptSuffix}`,
|
|
193
132
|
];
|
|
133
|
+
|
|
134
|
+
if (progress) {
|
|
135
|
+
lines.push(renderTaskProgressStrip(progress, theme));
|
|
136
|
+
}
|
|
137
|
+
|
|
194
138
|
if (message && !message.includes(taskLabel)) {
|
|
195
|
-
lines.push(` ${theme.fg("dim", message)}`);
|
|
139
|
+
lines.push(` ${theme.fg("dim", "⚙")} ${theme.fg("dim", `${prefix} · ${message}`)}`);
|
|
196
140
|
}
|
|
197
141
|
|
|
198
142
|
for (const subtask of progressSubtaskDetails(details?.subtasks)) {
|
|
199
|
-
lines.push(
|
|
200
|
-
` ${progressBubble(subtask.status, theme)} ${theme.fg(progressTextColor(subtask.status), subtask.text)}`,
|
|
201
|
-
);
|
|
143
|
+
lines.push(renderProgressSubtaskLine(subtask, theme));
|
|
202
144
|
}
|
|
203
145
|
|
|
204
146
|
return lines.join("\n");
|
|
@@ -253,162 +195,6 @@ function renderLongTaskSummary(details: CoordinatorToolRenderDetails, expanded:
|
|
|
253
195
|
return lines.join("\n");
|
|
254
196
|
}
|
|
255
197
|
|
|
256
|
-
function renderWrappedLines(text: string, width: number): string[] {
|
|
257
|
-
return new Text(text, 0, 0).render(Math.max(1, width)).map((line) => truncateToWidth(line, Math.max(1, width)));
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function padLine(line: string, width: number): string {
|
|
261
|
-
return truncateToWidth(line, width, "…", true);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function sidebarRows(taskProgress: TaskProgressModel, theme: Theme, workerCostTotal?: number): string[] {
|
|
265
|
-
const summary = normalizedTaskProgressSummary(taskProgress);
|
|
266
|
-
const rows = [theme.fg("toolTitle", theme.bold("Task sidebar")), theme.fg("dim", "Centered timeline")];
|
|
267
|
-
if (workerCostTotal) {
|
|
268
|
-
rows.push(theme.fg("muted", `Worker spend: ${formatCost(workerCostTotal)}`));
|
|
269
|
-
}
|
|
270
|
-
if (summary.totalTasks === 0) {
|
|
271
|
-
rows.push("", theme.fg("muted", "Waiting for TODO plan..."));
|
|
272
|
-
return rows;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
rows.push("", progressBarLine(summary.completedTasks, summary.totalTasks, summary.completedPercent, theme));
|
|
276
|
-
rows.push(progressCountsLine(summary, theme));
|
|
277
|
-
|
|
278
|
-
const currentIndex = focusedTaskIndex(taskProgress);
|
|
279
|
-
if (currentIndex >= 0) {
|
|
280
|
-
rows.push(theme.fg("warning", `Focus: TODO ${taskProgress.tasks[currentIndex]?.taskId ?? "?"}`));
|
|
281
|
-
} else {
|
|
282
|
-
rows.push(theme.fg("success", "No active task"));
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
rows.push("", theme.fg("muted", "Timeline"));
|
|
286
|
-
for (const [index, task] of taskProgress.tasks.entries()) {
|
|
287
|
-
if (currentIndex >= 0 && index === currentIndex && index > 0) {
|
|
288
|
-
rows.push(theme.fg("dim", "──── current ────"));
|
|
289
|
-
}
|
|
290
|
-
rows.push(renderSidebarTaskRow(task, theme));
|
|
291
|
-
if (currentIndex >= 0 && index === currentIndex && index < taskProgress.tasks.length - 1) {
|
|
292
|
-
rows.push(theme.fg("dim", "──── future ─────"));
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
return rows;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
interface NormalizedTaskProgressSummary {
|
|
300
|
-
totalTasks: number;
|
|
301
|
-
completedTasks: number;
|
|
302
|
-
failedTasks: number;
|
|
303
|
-
blockedTasks: number;
|
|
304
|
-
pendingTasks: number;
|
|
305
|
-
currentTasks: number;
|
|
306
|
-
completedPercent: number;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function normalizedTaskProgressSummary(taskProgress: TaskProgressModel): NormalizedTaskProgressSummary {
|
|
310
|
-
const totalTasks = taskProgress.summary?.totalTasks ?? taskProgress.tasks.length;
|
|
311
|
-
const completedTasks = taskProgress.summary?.completedTasks ?? countTasksByStatus(taskProgress.tasks, "completed");
|
|
312
|
-
const failedTasks = taskProgress.summary?.failedTasks ?? countTasksByStatus(taskProgress.tasks, "failed");
|
|
313
|
-
const blockedTasks = taskProgress.summary?.blockedTasks ?? countTasksByStatus(taskProgress.tasks, "blocked");
|
|
314
|
-
const pendingTasks = taskProgress.summary?.pendingTasks ?? countTasksByStatus(taskProgress.tasks, "pending");
|
|
315
|
-
const currentTasks = taskProgress.summary?.currentTasks ?? countTasksByStatus(taskProgress.tasks, "current");
|
|
316
|
-
const completedPercent =
|
|
317
|
-
taskProgress.summary?.completedPercent ??
|
|
318
|
-
(totalTasks === 0 ? 100 : Math.round((completedTasks / totalTasks) * 100));
|
|
319
|
-
|
|
320
|
-
return {
|
|
321
|
-
totalTasks,
|
|
322
|
-
completedTasks,
|
|
323
|
-
failedTasks,
|
|
324
|
-
blockedTasks,
|
|
325
|
-
pendingTasks,
|
|
326
|
-
currentTasks,
|
|
327
|
-
completedPercent,
|
|
328
|
-
};
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function countTasksByStatus(tasks: readonly TaskProgressTask[], status: TaskProgressStatus): number {
|
|
332
|
-
return tasks.filter((task) => task.status === status).length;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function progressBarLine(completedTasks: number, totalTasks: number, percent: number, theme: Theme): string {
|
|
336
|
-
const width = 10;
|
|
337
|
-
const filled = clamp(totalTasks === 0 ? width : Math.round((completedTasks / totalTasks) * width), 0, width);
|
|
338
|
-
const empty = Math.max(0, width - filled);
|
|
339
|
-
return `${theme.fg("muted", "Progress")} [${theme.fg("success", "#".repeat(filled))}${theme.fg(
|
|
340
|
-
"dim",
|
|
341
|
-
"-".repeat(empty),
|
|
342
|
-
)}] ${completedTasks}/${totalTasks} ${percent}%`;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function progressCountsLine(summary: NormalizedTaskProgressSummary, theme: Theme): string {
|
|
346
|
-
const parts = [
|
|
347
|
-
theme.fg("success", `✓ ${summary.completedTasks}`),
|
|
348
|
-
summary.currentTasks ? theme.fg("warning", `▶ ${summary.currentTasks}`) : undefined,
|
|
349
|
-
summary.pendingTasks ? theme.fg("dim", `○ ${summary.pendingTasks}`) : undefined,
|
|
350
|
-
summary.failedTasks ? theme.fg("error", `✗ ${summary.failedTasks}`) : undefined,
|
|
351
|
-
summary.blockedTasks ? theme.fg("warning", `! ${summary.blockedTasks}`) : undefined,
|
|
352
|
-
].filter(Boolean);
|
|
353
|
-
return parts.join(" · ");
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
function focusedTaskIndex(taskProgress: TaskProgressModel): number {
|
|
357
|
-
if (typeof taskProgress.currentIndex === "number" && taskProgress.currentIndex >= 0) {
|
|
358
|
-
return taskProgress.currentIndex;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const currentIndex = taskProgress.tasks.findIndex((task) => task.status === "current" || task.position === "current");
|
|
362
|
-
if (currentIndex >= 0) {
|
|
363
|
-
return currentIndex;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
if (typeof taskProgress.nextIndex === "number" && taskProgress.nextIndex >= 0) {
|
|
367
|
-
return taskProgress.nextIndex;
|
|
368
|
-
}
|
|
369
|
-
return taskProgress.tasks.findIndex((task) => task.status === "pending");
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
function renderSidebarTaskRow(task: TaskProgressTask, theme: Theme): string {
|
|
373
|
-
const { icon, color, label } = sidebarTaskStatusDetails(task.status);
|
|
374
|
-
const attempts =
|
|
375
|
-
task.attempts > 0 && task.status !== "completed"
|
|
376
|
-
? ` · ${task.attempts} attempt${task.attempts === 1 ? "" : "s"}`
|
|
377
|
-
: "";
|
|
378
|
-
const text = `${icon} [${label}] TODO ${task.taskId} — ${task.title}${attempts}`;
|
|
379
|
-
return task.status === "current" ? theme.fg(color, theme.bold(text)) : theme.fg(color, text);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function sidebarTaskStatusDetails(status: TaskProgressStatus): {
|
|
383
|
-
icon: string;
|
|
384
|
-
color: "success" | "warning" | "error" | "dim" | "muted";
|
|
385
|
-
label: string;
|
|
386
|
-
} {
|
|
387
|
-
switch (status) {
|
|
388
|
-
case "completed":
|
|
389
|
-
return { icon: "✓", color: "success", label: "completed" };
|
|
390
|
-
case "current":
|
|
391
|
-
return { icon: "▶", color: "warning", label: "current" };
|
|
392
|
-
case "failed":
|
|
393
|
-
return { icon: "✗", color: "error", label: "failed" };
|
|
394
|
-
case "blocked":
|
|
395
|
-
return { icon: "!", color: "warning", label: "blocked" };
|
|
396
|
-
case "pending":
|
|
397
|
-
return { icon: "○", color: "dim", label: "pending" };
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function sidebarBorder(title: string, width: number, theme: Theme): string {
|
|
402
|
-
const innerWidth = Math.max(0, width - 2);
|
|
403
|
-
const titleText = truncateToWidth(` ${title} `, innerWidth, "");
|
|
404
|
-
const remaining = Math.max(0, innerWidth - visibleWidth(titleText));
|
|
405
|
-
return theme.fg("borderMuted", `┌${titleText}${"─".repeat(remaining)}┐`);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
function sidebarRow(row: string, innerWidth: number, theme: Theme): string {
|
|
409
|
-
return `${theme.fg("borderMuted", "│")}${truncateToWidth(row, innerWidth, "…", true)}${theme.fg("borderMuted", "│")}`;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
198
|
function taskProgressModel(value: unknown): TaskProgressModel | undefined {
|
|
413
199
|
const record = recordOrUndefined(value);
|
|
414
200
|
if (!record || !Array.isArray(record.tasks)) {
|
|
@@ -417,10 +203,6 @@ function taskProgressModel(value: unknown): TaskProgressModel | undefined {
|
|
|
417
203
|
return value as TaskProgressModel;
|
|
418
204
|
}
|
|
419
205
|
|
|
420
|
-
function clamp(value: number, min: number, max: number): number {
|
|
421
|
-
return Math.min(max, Math.max(min, value));
|
|
422
|
-
}
|
|
423
|
-
|
|
424
206
|
function progressTaskDetails(value: unknown): ProgressTaskRenderDetails | undefined {
|
|
425
207
|
const record = recordOrUndefined(value);
|
|
426
208
|
const taskId = stringValue(record?.taskId);
|
|
@@ -447,36 +229,120 @@ function progressSubtaskDetails(value: unknown): ProgressSubtaskRenderDetails[]
|
|
|
447
229
|
});
|
|
448
230
|
}
|
|
449
231
|
|
|
232
|
+
function progressPhaseLabel(phase: string): string {
|
|
233
|
+
switch (phase) {
|
|
234
|
+
case "planning":
|
|
235
|
+
case "planned":
|
|
236
|
+
return "Thought";
|
|
237
|
+
case "task_start":
|
|
238
|
+
case "worker_tool":
|
|
239
|
+
return "Build";
|
|
240
|
+
case "task_done":
|
|
241
|
+
return "Done";
|
|
242
|
+
case "task_failed":
|
|
243
|
+
return "Failed";
|
|
244
|
+
case "task_blocked":
|
|
245
|
+
return "Blocked";
|
|
246
|
+
case "complete":
|
|
247
|
+
return "Complete";
|
|
248
|
+
default:
|
|
249
|
+
return "Progress";
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function renderTaskProgressStrip(progress: TaskProgressModel, theme: Theme): string {
|
|
254
|
+
const summary = progress.summary;
|
|
255
|
+
const track = taskProgressTrack(progress, theme);
|
|
256
|
+
const counts = [
|
|
257
|
+
theme.fg("muted", `${summary.completedTasks}/${summary.totalTasks}`),
|
|
258
|
+
theme.fg("muted", `${summary.completedPercent}%`),
|
|
259
|
+
summary.failedTasks ? theme.fg("error", `${summary.failedTasks} failed`) : undefined,
|
|
260
|
+
summary.blockedTasks ? theme.fg("warning", `${summary.blockedTasks} blocked`) : undefined,
|
|
261
|
+
summary.currentTasks ? theme.fg("warning", `${summary.currentTasks} active`) : undefined,
|
|
262
|
+
summary.pendingTasks ? theme.fg("dim", `${summary.pendingTasks} queued`) : undefined,
|
|
263
|
+
]
|
|
264
|
+
.filter(Boolean)
|
|
265
|
+
.join(` ${theme.fg("dim", "·")} `);
|
|
266
|
+
return ` ${track} ${counts}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function taskProgressTrack(progress: TaskProgressModel, theme: Theme): string {
|
|
270
|
+
const taskIndexes = focusedTaskIndexes(progress);
|
|
271
|
+
const first = taskIndexes[0] ?? 0;
|
|
272
|
+
const last = taskIndexes[taskIndexes.length - 1] ?? -1;
|
|
273
|
+
const prefix = first > 0 ? theme.fg("dim", "… ") : "";
|
|
274
|
+
const suffix = last >= 0 && last < progress.tasks.length - 1 ? theme.fg("dim", " …") : "";
|
|
275
|
+
return `${prefix}${taskIndexes.map((index) => taskProgressTaskGlyph(progress.tasks[index], theme)).join(" ")}${suffix}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function focusedTaskIndexes(progress: TaskProgressModel): number[] {
|
|
279
|
+
const total = progress.tasks.length;
|
|
280
|
+
if (total <= 0) {
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
const limit = Math.min(total, 8);
|
|
284
|
+
const focus = Math.max(0, Math.min(total - 1, progress.currentIndex ?? progress.nextIndex ?? 0));
|
|
285
|
+
const start = Math.max(0, Math.min(total - limit, focus - Math.floor(limit / 2)));
|
|
286
|
+
return Array.from({ length: limit }, (_value, index) => start + index);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function taskProgressTaskGlyph(task: TaskProgressModel["tasks"][number] | undefined, theme: Theme): string {
|
|
290
|
+
if (!task) {
|
|
291
|
+
return "";
|
|
292
|
+
}
|
|
293
|
+
const details = taskStatusDetails(task.status);
|
|
294
|
+
return theme.fg(details.color, details.icon);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function renderProgressSubtaskLine(subtask: ProgressSubtaskRenderDetails, theme: Theme): string {
|
|
298
|
+
const details = progressItemStatusDetails(subtask.status);
|
|
299
|
+
return ` ${theme.fg(details.color, details.icon)} ${theme.fg(details.textColor, `${details.label} · ${subtask.text}`)}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
450
302
|
function progressItemStatus(value: unknown): ProgressItemStatus | undefined {
|
|
451
303
|
return value === "empty" || value === "in_progress" || value === "done" || value === "failed" || value === "blocked"
|
|
452
304
|
? value
|
|
453
305
|
: undefined;
|
|
454
306
|
}
|
|
455
307
|
|
|
456
|
-
function
|
|
308
|
+
function taskStatusDetails(status: TaskProgressModel["tasks"][number]["status"]): {
|
|
309
|
+
icon: string;
|
|
310
|
+
label: string;
|
|
311
|
+
color: "accent" | "success" | "warning" | "dim" | "error";
|
|
312
|
+
textColor: "accent" | "text" | "success" | "warning" | "dim" | "error";
|
|
313
|
+
} {
|
|
457
314
|
switch (status) {
|
|
458
|
-
case "
|
|
459
|
-
return
|
|
315
|
+
case "completed":
|
|
316
|
+
return { icon: "✓", label: "done", color: "success", textColor: "dim" };
|
|
317
|
+
case "current":
|
|
318
|
+
return { icon: "▢", label: "active", color: "accent", textColor: "text" };
|
|
460
319
|
case "failed":
|
|
461
|
-
return
|
|
320
|
+
return { icon: "×", label: "failed", color: "error", textColor: "error" };
|
|
462
321
|
case "blocked":
|
|
463
|
-
return
|
|
464
|
-
|
|
465
|
-
return
|
|
322
|
+
return { icon: "!", label: "blocked", color: "warning", textColor: "warning" };
|
|
323
|
+
case "pending":
|
|
324
|
+
return { icon: "○", label: "queued", color: "dim", textColor: "dim" };
|
|
466
325
|
}
|
|
467
326
|
}
|
|
468
327
|
|
|
469
|
-
function
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
328
|
+
function progressItemStatusDetails(status: ProgressItemStatus): {
|
|
329
|
+
icon: string;
|
|
330
|
+
label: string;
|
|
331
|
+
color: "accent" | "success" | "warning" | "dim" | "error";
|
|
332
|
+
textColor: "accent" | "success" | "warning" | "dim" | "error";
|
|
333
|
+
} {
|
|
334
|
+
switch (status) {
|
|
335
|
+
case "done":
|
|
336
|
+
return { icon: "✓", label: "done", color: "success", textColor: "dim" };
|
|
337
|
+
case "in_progress":
|
|
338
|
+
return { icon: "+", label: "active", color: "warning", textColor: "warning" };
|
|
339
|
+
case "failed":
|
|
340
|
+
return { icon: "×", label: "failed", color: "error", textColor: "error" };
|
|
341
|
+
case "blocked":
|
|
342
|
+
return { icon: "!", label: "blocked", color: "warning", textColor: "warning" };
|
|
343
|
+
case "empty":
|
|
344
|
+
return { icon: "○", label: "queued", color: "dim", textColor: "dim" };
|
|
478
345
|
}
|
|
479
|
-
return "dim";
|
|
480
346
|
}
|
|
481
347
|
|
|
482
348
|
function longTaskDetails(details: Record<string, unknown> | undefined): CoordinatorToolRenderDetails | undefined {
|