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.
Files changed (4) hide show
  1. package/README.md +12 -12
  2. package/package.json +1 -1
  3. package/src/index.ts +568 -18
  4. 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, shows progress in a sidebar, and optionally commits completed work.
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's long-task sidebar.
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. Shows the current task and inferred subtask progress in a sidebar while it runs.
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
- A finished run gives you:
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 keeps the active worker transcript in the main content area and shows the run timeline in the sidebar:
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 real TUI uses responsive layout and colors; this README mockup stays narrow enough to avoid wrapping in package galleries.
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. **Show every task in the sidebar:** the sidebar lists the full run timeline, including completed, active, upcoming, failed, or blocked tasks and inferred subtask progress from each task's `**Status:**` checklist.
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
- - **Sidebar task timeline:** every TODO appears in the sidebar 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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-long-task",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "type": "module",
5
5
  "description": "Pi extension for breaking down and running long coding tasks safely.",
6
6
  "keywords": [
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
- const result = await runCoordinator({
124
- ...params,
125
- cwd: ctx?.cwd,
126
- abortSignal: signal,
127
- onProgress: publishProgress,
128
- });
129
- workerCostAccumulator.add(result.workerCostTotal);
130
-
131
- return {
132
- content: [
133
- {
134
- type: "text" as const,
135
- text: result.message,
136
- },
137
- ],
138
- details: toolDetails(result),
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, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
2
+ import { Text, type Component } from "@earendil-works/pi-tui";
3
3
 
4
- import type { TaskProgressModel, TaskProgressStatus, TaskProgressTask } from "./task_progress.ts";
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
- const taskProgress = taskProgressModel(details?.taskProgress);
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
- const main = renderLongTaskSummary(finalDetails, options.expanded, theme);
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("accent", "")} ${theme.fg("muted", prefix)} ${message}`;
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
- `${progressBubble(currentTask.status, theme)} ${theme.fg("muted", prefix)} ${theme.fg(progressTextColor(currentTask.status), taskLabel)}`,
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 progressBubble(status: ProgressItemStatus, theme: Theme): string {
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 "empty":
459
- return theme.fg("dim", "");
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 theme.fg("error", "");
320
+ return { icon: "×", label: "failed", color: "error", textColor: "error" };
462
321
  case "blocked":
463
- return theme.fg("warning", "!");
464
- default:
465
- return theme.fg(progressTextColor(status), "");
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 progressTextColor(status: ProgressItemStatus): "success" | "warning" | "dim" | "error" {
470
- if (status === "done") {
471
- return "success";
472
- }
473
- if (status === "failed") {
474
- return "error";
475
- }
476
- if (status === "in_progress" || status === "blocked") {
477
- return "warning";
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 {