pi-long-task 0.3.5 → 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 (3) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +273 -69
  3. package/src/render.ts +121 -23
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-long-task",
3
- "version": "0.3.5",
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
@@ -139,9 +139,9 @@ export function createLongTaskSidebarController(ctx: UiContext | undefined): Lon
139
139
  overlayOptions: {
140
140
  anchor: "right-center",
141
141
  width: "34%",
142
- minWidth: 32,
143
- maxHeight: "85%",
144
- margin: 1,
142
+ minWidth: 36,
143
+ maxHeight: "100%",
144
+ margin: 0,
145
145
  nonCapturing: true,
146
146
  visible: (termWidth, termHeight) => termWidth >= 96 && termHeight >= 16,
147
147
  },
@@ -193,24 +193,27 @@ function supportsTuiOverlay(ctx: UiContext): boolean {
193
193
  function renderSidebarWidgetLines(update: CoordinatorProgressUpdate): string[] {
194
194
  const progress = update.taskProgress;
195
195
  const summary = progress?.summary;
196
- const status = update.status ? String(update.status) : update.phase;
197
- const lines = [`Pi Long Task: ${status}`, update.message];
196
+ const statusDetails = sidebarUpdateStateDetails(update);
197
+ const lines = ["Pi Long Task", `${statusDetails.icon} ${statusDetails.label} · ${update.message}`];
198
198
  if (summary) {
199
199
  lines.push(
200
- `Tasks: ${summary.completedTasks}/${summary.totalTasks} done` +
201
- (summary.failedTasks ? `, ${summary.failedTasks} failed` : "") +
202
- (summary.blockedTasks ? `, ${summary.blockedTasks} blocked` : ""),
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` : ""),
203
205
  );
204
206
  }
205
207
  if (progress && progress.tasks.length > 0) {
206
208
  const currentIndex = focusedTaskIndex(progress);
207
209
  const currentTask = currentIndex >= 0 ? progress.tasks[currentIndex] : undefined;
208
210
  if (currentTask) {
209
- lines.push(`Focus: TODO ${currentTask.taskId} — ${currentTask.title}`);
211
+ const details = taskStatusDetails(currentTask.status);
212
+ lines.push(`${details.label}: ${details.icon} TODO ${currentTask.taskId} — ${currentTask.title}`);
210
213
  }
211
214
  }
212
215
  if (update.workerCostTotal > 0) {
213
- lines.push(`Worker spend: ${formatCost(update.workerCostTotal)}`);
216
+ lines.push(`Spent: ${formatCost(update.workerCostTotal)}`);
214
217
  }
215
218
  return lines.map((line) => truncateToWidth(line, 96));
216
219
  }
@@ -220,45 +223,76 @@ function renderSidebarOverlayLines(
220
223
  theme: Theme,
221
224
  width: number,
222
225
  ): string[] {
223
- const safeWidth = Math.max(12, width);
224
- const innerWidth = Math.max(0, safeWidth - 2);
225
- const rows = renderSidebarRows(update, theme);
226
- return [
227
- sidebarBorder("Pi Long Task", safeWidth, theme),
228
- ...rows.map((row) => sidebarRow(row, innerWidth, theme)),
229
- theme.fg("borderMuted", `└${"─".repeat(innerWidth)}┘`),
230
- ];
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));
231
230
  }
232
231
 
233
- function renderSidebarRows(update: CoordinatorProgressUpdate | undefined, theme: Theme): string[] {
232
+ function renderSidebarRows(update: CoordinatorProgressUpdate | undefined, theme: Theme, width: number): string[] {
234
233
  if (!update) {
235
- return [theme.fg("muted", "Preparing long-task sidebar...")];
234
+ return ["", sidebarHeading("Pi Long Task", theme), "", theme.fg("muted", "Preparing long-task sidebar...")];
236
235
  }
237
236
 
238
237
  const progress = update.taskProgress;
239
- const rows = [theme.fg("toolTitle", theme.bold("Task timeline")), theme.fg("dim", update.phase)];
240
- if (update.workerCostTotal > 0) {
241
- rows.push(theme.fg("muted", `Worker spend: ${formatCost(update.workerCostTotal)}`));
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)));
242
247
  }
243
- rows.push("", theme.fg("muted", truncateToWidth(update.message, 72)));
244
248
 
245
249
  if (!progress || progress.tasks.length === 0) {
246
- rows.push("", theme.fg("muted", "Waiting for TODO plan..."));
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
+ }
247
254
  return rows;
248
255
  }
249
256
 
250
257
  const summary = progress.summary;
251
- rows.push("", progressBarLine(summary.completedTasks, summary.totalTasks, summary.completedPercent, theme));
252
- rows.push(progressCountsLine(summary, theme));
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
+ );
253
278
 
254
279
  const currentIndex = focusedTaskIndex(progress);
255
- if (currentIndex >= 0) {
256
- rows.push(theme.fg("warning", `Focus: TODO ${progress.tasks[currentIndex]?.taskId ?? "?"}`));
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)));
257
291
  } else {
258
292
  rows.push(theme.fg("success", "No active task"));
259
293
  }
260
294
 
261
- rows.push("", theme.fg("muted", "Tasks"));
295
+ rows.push("", sidebarHeading("Task timeline", theme));
262
296
  const taskIndexes = centeredTaskIndexes(progress.tasks.length, currentIndex, 9);
263
297
  const first = taskIndexes[0] ?? 0;
264
298
  const last = taskIndexes[taskIndexes.length - 1] ?? -1;
@@ -278,7 +312,7 @@ function renderSidebarRows(update: CoordinatorProgressUpdate | undefined, theme:
278
312
 
279
313
  const subtasks = update.subtasks ?? [];
280
314
  if (subtasks.length > 0) {
281
- rows.push("", theme.fg("muted", "Current status"));
315
+ rows.push("", sidebarHeading("Current status", theme));
282
316
  for (const subtask of subtasks.slice(0, 6)) {
283
317
  rows.push(renderSubtaskRow(subtask, theme));
284
318
  }
@@ -316,61 +350,181 @@ function renderTaskRow(
316
350
  theme: Theme,
317
351
  ): string {
318
352
  const details = taskStatusDetails(task.status);
319
- const attempts = task.attempts > 0 && task.status !== "completed" ? ` · ${task.attempts}x` : "";
320
- const row = `${details.icon} TODO ${task.taskId} — ${task.title}${attempts}`;
321
- const styled = focused ? theme.bold(row) : row;
322
- return theme.fg(details.color, styled);
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}`;
323
361
  }
324
362
 
325
363
  function renderSubtaskRow(subtask: NonNullable<CoordinatorProgressUpdate["subtasks"]>[number], theme: Theme): string {
326
364
  const details = progressItemStatusDetails(subtask.status);
327
- return theme.fg(details.color, `${details.icon} ${subtask.text}`);
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
+ }
328
477
  }
329
478
 
330
479
  function taskStatusDetails(status: NonNullable<CoordinatorProgressUpdate["taskProgress"]>["tasks"][number]["status"]): {
331
480
  icon: string;
332
- color: "success" | "warning" | "error" | "dim" | "muted";
481
+ label: string;
482
+ color: "accent" | "success" | "warning" | "error" | "dim" | "muted";
483
+ textColor: "accent" | "text" | "success" | "warning" | "error" | "dim" | "muted";
333
484
  } {
334
485
  switch (status) {
335
486
  case "completed":
336
- return { icon: "✓", color: "success" };
487
+ return { icon: "✓", label: "done", color: "success", textColor: "muted" };
337
488
  case "current":
338
- return { icon: "", color: "warning" };
489
+ return { icon: "", label: "active", color: "accent", textColor: "text" };
339
490
  case "failed":
340
- return { icon: "", color: "error" };
491
+ return { icon: "×", label: "failed", color: "error", textColor: "error" };
341
492
  case "blocked":
342
- return { icon: "!", color: "warning" };
493
+ return { icon: "!", label: "blocked", color: "warning", textColor: "warning" };
343
494
  case "pending":
344
- return { icon: "○", color: "dim" };
495
+ return { icon: "○", label: "queued", color: "dim", textColor: "dim" };
345
496
  }
346
497
  }
347
498
 
348
499
  function progressItemStatusDetails(status: NonNullable<CoordinatorProgressUpdate["subtasks"]>[number]["status"]): {
349
500
  icon: string;
501
+ label: string;
350
502
  color: "success" | "warning" | "error" | "dim" | "muted";
503
+ textColor: "success" | "warning" | "error" | "dim" | "muted";
351
504
  } {
352
505
  switch (status) {
353
506
  case "done":
354
- return { icon: "✓", color: "success" };
507
+ return { icon: "✓", label: "done", color: "success", textColor: "muted" };
355
508
  case "in_progress":
356
- return { icon: "", color: "warning" };
509
+ return { icon: "+", label: "active", color: "warning", textColor: "warning" };
357
510
  case "failed":
358
- return { icon: "", color: "error" };
511
+ return { icon: "×", label: "failed", color: "error", textColor: "error" };
359
512
  case "blocked":
360
- return { icon: "!", color: "warning" };
513
+ return { icon: "!", label: "blocked", color: "warning", textColor: "warning" };
361
514
  case "empty":
362
- return { icon: "○", color: "dim" };
515
+ return { icon: "○", label: "queued", color: "dim", textColor: "dim" };
363
516
  }
364
517
  }
365
518
 
366
- function progressBarLine(completedTasks: number, totalTasks: number, percent: number, theme: Theme): string {
367
- const width = 12;
368
- const filled = clamp(totalTasks === 0 ? width : Math.round((completedTasks / totalTasks) * width), 0, width);
369
- const empty = Math.max(0, width - filled);
370
- return `${theme.fg("muted", "Progress")} [${theme.fg("success", "#".repeat(filled))}${theme.fg(
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(
371
525
  "dim",
372
- "-".repeat(empty),
373
- )}] ${completedTasks}/${totalTasks} ${percent}%`;
526
+ "·",
527
+ )} ${progressMeterLine(progress, theme)}`;
374
528
  }
375
529
 
376
530
  function progressCountsLine(
@@ -378,30 +532,80 @@ function progressCountsLine(
378
532
  theme: Theme,
379
533
  ): string {
380
534
  return [
381
- theme.fg("success", `✓ ${summary.completedTasks}`),
382
- summary.currentTasks ? theme.fg("warning", `▶ ${summary.currentTasks}`) : undefined,
383
- summary.pendingTasks ? theme.fg("dim", `○ ${summary.pendingTasks}`) : undefined,
384
- summary.failedTasks ? theme.fg("error", `✗ ${summary.failedTasks}`) : undefined,
385
- summary.blockedTasks ? theme.fg("warning", `! ${summary.blockedTasks}`) : undefined,
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,
386
540
  ]
387
541
  .filter(Boolean)
388
542
  .join(" · ");
389
543
  }
390
544
 
391
- function sidebarBorder(title: string, width: number, theme: Theme): string {
392
- const innerWidth = Math.max(0, width - 2);
393
- const label = ` ${title} `;
394
- const safeLabel = truncateToWidth(label, innerWidth, "");
395
- const remainder = Math.max(0, innerWidth - safeLabel.length);
396
- return theme.fg("borderMuted", `┌${safeLabel}${"─".repeat(remainder)}┐`);
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;
397
570
  }
398
571
 
399
- function sidebarRow(text: string, width: number, theme: Theme): string {
400
- return `${theme.fg("borderMuted", "│")}${truncateToWidth(text, width, "…", true)}${theme.fg("borderMuted", "│")}`;
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;
401
605
  }
402
606
 
403
- function clamp(value: number, min: number, max: number): number {
404
- return Math.min(max, Math.max(min, value));
607
+ function sidebarPanelRow(text: string, width: number, theme: Theme): string {
608
+ return `${theme.fg("borderMuted", "│")} ${truncateToWidth(text, width, "…", true)}`;
405
609
  }
406
610
 
407
611
  function formatCost(value: number): string {
package/src/render.ts CHANGED
@@ -111,22 +111,36 @@ function renderLongTaskProgress(details: Record<string, unknown> | undefined, fa
111
111
  const toolName = stringValue(details?.toolName);
112
112
  const prefix = phase === "worker_tool" && toolName ? `worker ${toolName}` : phase || "progress";
113
113
  const currentTask = progressTaskDetails(details?.currentTask);
114
+ const progress = taskProgressModel(details?.taskProgress);
115
+
114
116
  if (!currentTask) {
115
- return `${theme.fg("accent", "")} ${theme.fg("muted", prefix)} ${message}`;
117
+ return `${theme.fg("warning", "+")} ${theme.fg("warning", `${progressPhaseLabel(phase)}:`)} ${message}`;
116
118
  }
117
119
 
118
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}`)}` : "";
119
127
  const lines = [
120
- `${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}`,
121
132
  ];
133
+
134
+ if (progress) {
135
+ lines.push(renderTaskProgressStrip(progress, theme));
136
+ }
137
+
122
138
  if (message && !message.includes(taskLabel)) {
123
- lines.push(` ${theme.fg("dim", message)}`);
139
+ lines.push(` ${theme.fg("dim", "⚙")} ${theme.fg("dim", `${prefix} · ${message}`)}`);
124
140
  }
125
141
 
126
142
  for (const subtask of progressSubtaskDetails(details?.subtasks)) {
127
- lines.push(
128
- ` ${progressBubble(subtask.status, theme)} ${theme.fg(progressTextColor(subtask.status), subtask.text)}`,
129
- );
143
+ lines.push(renderProgressSubtaskLine(subtask, theme));
130
144
  }
131
145
 
132
146
  return lines.join("\n");
@@ -215,36 +229,120 @@ function progressSubtaskDetails(value: unknown): ProgressSubtaskRenderDetails[]
215
229
  });
216
230
  }
217
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
+
218
302
  function progressItemStatus(value: unknown): ProgressItemStatus | undefined {
219
303
  return value === "empty" || value === "in_progress" || value === "done" || value === "failed" || value === "blocked"
220
304
  ? value
221
305
  : undefined;
222
306
  }
223
307
 
224
- 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
+ } {
225
314
  switch (status) {
226
- case "empty":
227
- 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" };
228
319
  case "failed":
229
- return theme.fg("error", "");
320
+ return { icon: "×", label: "failed", color: "error", textColor: "error" };
230
321
  case "blocked":
231
- return theme.fg("warning", "!");
232
- default:
233
- 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" };
234
325
  }
235
326
  }
236
327
 
237
- function progressTextColor(status: ProgressItemStatus): "success" | "warning" | "dim" | "error" {
238
- if (status === "done") {
239
- return "success";
240
- }
241
- if (status === "failed") {
242
- return "error";
243
- }
244
- if (status === "in_progress" || status === "blocked") {
245
- 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" };
246
345
  }
247
- return "dim";
248
346
  }
249
347
 
250
348
  function longTaskDetails(details: Record<string, unknown> | undefined): CoordinatorToolRenderDetails | undefined {