pi-long-task 0.3.5 → 0.3.7

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 +331 -118
  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.7",
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,6 +1,6 @@
1
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
+ import { truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
4
4
 
5
5
  import { runCoordinator, type CoordinatorProgressUpdate, type CoordinatorResult } from "./coordinator.ts";
6
6
  import { longTaskInputTransform } from "./input_router.ts";
@@ -76,6 +76,9 @@ function toolDetails(result: CoordinatorResult) {
76
76
  }
77
77
 
78
78
  const LONG_TASK_WIDGET_KEY = "pi-long-task-sidebar";
79
+ const SIDEBAR_WIDGET_RESERVED_INPUT_ROWS = 8;
80
+ const SIDEBAR_WIDGET_MIN_ROWS = 4;
81
+ const SIDEBAR_WIDGET_MAX_ROWS = 24;
79
82
 
80
83
  type UiContext = ExtensionContext;
81
84
 
@@ -86,10 +89,12 @@ export interface LongTaskSidebarController {
86
89
 
87
90
  class PiLongTaskSidebarComponent implements Component {
88
91
  private readonly theme: Theme;
92
+ private readonly maxRows: (() => number | undefined) | undefined;
89
93
  private update: CoordinatorProgressUpdate | undefined;
90
94
 
91
- constructor(theme: Theme) {
95
+ constructor(theme: Theme, maxRows?: () => number | undefined) {
92
96
  this.theme = theme;
97
+ this.maxRows = maxRows;
93
98
  }
94
99
 
95
100
  setUpdate(update: CoordinatorProgressUpdate): void {
@@ -98,7 +103,8 @@ class PiLongTaskSidebarComponent implements Component {
98
103
  }
99
104
 
100
105
  render(width: number): string[] {
101
- return renderSidebarOverlayLines(this.update, this.theme, width);
106
+ const lines = renderSidebarOverlayLines(this.update, this.theme, width);
107
+ return limitSidebarPanelLines(lines, this.theme, width, this.maxRows?.());
102
108
  }
103
109
 
104
110
  invalidate(): void {
@@ -112,48 +118,25 @@ export function createLongTaskSidebarController(ctx: UiContext | undefined): Lon
112
118
  }
113
119
 
114
120
  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;
121
+ let sidebarComponent: PiLongTaskSidebarComponent | undefined;
122
+ let sidebarTui: TUI | undefined;
119
123
  let closed = false;
120
124
 
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);
125
+ if (supportsTuiWidget(ctx)) {
126
+ ctx.ui.setWidget(
127
+ LONG_TASK_WIDGET_KEY,
128
+ (tui, theme) => {
129
+ sidebarTui = tui;
130
+ sidebarComponent = new PiLongTaskSidebarComponent(theme, () => sidebarWidgetLineLimit(tui));
129
131
  if (latestUpdate) {
130
- overlayComponent.setUpdate(latestUpdate);
131
- }
132
- if (closed) {
133
- done(undefined);
132
+ sidebarComponent.setUpdate(latestUpdate);
134
133
  }
135
- return overlayComponent;
136
- },
137
- {
138
- overlay: true,
139
- overlayOptions: {
140
- anchor: "right-center",
141
- width: "34%",
142
- minWidth: 32,
143
- maxHeight: "85%",
144
- margin: 1,
145
- nonCapturing: true,
146
- visible: (termWidth, termHeight) => termWidth >= 96 && termHeight >= 16,
147
- },
148
- onHandle: (handle) => {
149
- overlayHandle = handle;
150
- handle.unfocus();
151
- },
134
+ return sidebarComponent;
152
135
  },
136
+ { placement: "aboveEditor" },
153
137
  );
154
- void overlayPromise.catch(() => {
155
- // The widget fallback remains active if overlay registration is unavailable.
156
- });
138
+ } else {
139
+ ctx.ui.setWidget(LONG_TASK_WIDGET_KEY, ["Pi Long Task: preparing sidebar..."], { placement: "aboveEditor" });
157
140
  }
158
141
 
159
142
  return {
@@ -162,9 +145,11 @@ export function createLongTaskSidebarController(ctx: UiContext | undefined): Lon
162
145
  return;
163
146
  }
164
147
  latestUpdate = update;
165
- overlayComponent?.setUpdate(update);
166
- overlayTui?.requestRender();
167
- ctx.ui.setWidget(LONG_TASK_WIDGET_KEY, renderSidebarWidgetLines(update), { placement: "aboveEditor" });
148
+ sidebarComponent?.setUpdate(update);
149
+ sidebarTui?.requestRender();
150
+ if (!sidebarComponent) {
151
+ ctx.ui.setWidget(LONG_TASK_WIDGET_KEY, renderSidebarWidgetLines(update), { placement: "aboveEditor" });
152
+ }
168
153
  },
169
154
  close(): void {
170
155
  if (closed) {
@@ -172,45 +157,48 @@ export function createLongTaskSidebarController(ctx: UiContext | undefined): Lon
172
157
  }
173
158
  closed = true;
174
159
  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;
160
+ sidebarComponent = undefined;
161
+ sidebarTui = undefined;
184
162
  },
185
163
  };
186
164
  }
187
165
 
188
- function supportsTuiOverlay(ctx: UiContext): boolean {
166
+ function supportsTuiWidget(ctx: UiContext): boolean {
189
167
  const mode = (ctx as UiContext & { mode?: string }).mode;
190
168
  return mode === "tui" || mode === undefined;
191
169
  }
192
170
 
171
+ function sidebarWidgetLineLimit(tui: TUI): number {
172
+ const terminalRows = tui.terminal.rows;
173
+ const rows = Number.isFinite(terminalRows) ? Math.max(0, Math.floor(terminalRows)) : 24;
174
+ const availableRows = rows - SIDEBAR_WIDGET_RESERVED_INPUT_ROWS;
175
+ return Math.max(SIDEBAR_WIDGET_MIN_ROWS, Math.min(SIDEBAR_WIDGET_MAX_ROWS, availableRows));
176
+ }
177
+
193
178
  function renderSidebarWidgetLines(update: CoordinatorProgressUpdate): string[] {
194
179
  const progress = update.taskProgress;
195
180
  const summary = progress?.summary;
196
- const status = update.status ? String(update.status) : update.phase;
197
- const lines = [`Pi Long Task: ${status}`, update.message];
181
+ const statusDetails = sidebarUpdateStateDetails(update);
182
+ const lines = ["Pi Long Task", `${statusDetails.icon} ${statusDetails.label} · ${update.message}`];
198
183
  if (summary) {
199
184
  lines.push(
200
- `Tasks: ${summary.completedTasks}/${summary.totalTasks} done` +
201
- (summary.failedTasks ? `, ${summary.failedTasks} failed` : "") +
202
- (summary.blockedTasks ? `, ${summary.blockedTasks} blocked` : ""),
185
+ `Tasks: ${summary.completedTasks}/${summary.totalTasks} · ${summary.completedPercent}%` +
186
+ (summary.currentTasks ? ` · ${summary.currentTasks} active` : "") +
187
+ (summary.pendingTasks ? ` · ${summary.pendingTasks} queued` : "") +
188
+ (summary.failedTasks ? ` · ${summary.failedTasks} failed` : "") +
189
+ (summary.blockedTasks ? ` · ${summary.blockedTasks} blocked` : ""),
203
190
  );
204
191
  }
205
192
  if (progress && progress.tasks.length > 0) {
206
193
  const currentIndex = focusedTaskIndex(progress);
207
194
  const currentTask = currentIndex >= 0 ? progress.tasks[currentIndex] : undefined;
208
195
  if (currentTask) {
209
- lines.push(`Focus: TODO ${currentTask.taskId} — ${currentTask.title}`);
196
+ const details = taskStatusDetails(currentTask.status);
197
+ lines.push(`${details.label}: ${details.icon} TODO ${currentTask.taskId} — ${currentTask.title}`);
210
198
  }
211
199
  }
212
200
  if (update.workerCostTotal > 0) {
213
- lines.push(`Worker spend: ${formatCost(update.workerCostTotal)}`);
201
+ lines.push(`Spent: ${formatCost(update.workerCostTotal)}`);
214
202
  }
215
203
  return lines.map((line) => truncateToWidth(line, 96));
216
204
  }
@@ -220,45 +208,100 @@ function renderSidebarOverlayLines(
220
208
  theme: Theme,
221
209
  width: number,
222
210
  ): 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
- ];
211
+ const safeWidth = Math.max(28, width);
212
+ const contentWidth = Math.max(8, safeWidth - 4);
213
+ const rows = renderSidebarRows(update, theme, contentWidth);
214
+ return rows.map((row) => sidebarPanelRow(row, contentWidth, theme));
231
215
  }
232
216
 
233
- function renderSidebarRows(update: CoordinatorProgressUpdate | undefined, theme: Theme): string[] {
217
+ function limitSidebarPanelLines(lines: string[], theme: Theme, width: number, maxRows: number | undefined): string[] {
218
+ if (maxRows === undefined || !Number.isFinite(maxRows)) {
219
+ return lines;
220
+ }
221
+
222
+ const limit = Math.max(0, Math.floor(maxRows));
223
+ if (lines.length <= limit) {
224
+ return lines;
225
+ }
226
+ if (limit === 0) {
227
+ return [];
228
+ }
229
+
230
+ const contentWidth = Math.max(8, Math.max(28, width) - 4);
231
+ if (limit === 1) {
232
+ return [sidebarPanelRow(theme.fg("dim", "…"), contentWidth, theme)];
233
+ }
234
+
235
+ const visibleLines = lines.slice(0, limit - 1);
236
+ const omitted = lines.length - visibleLines.length;
237
+ visibleLines.push(sidebarPanelRow(theme.fg("dim", `… ${omitted} more`), contentWidth, theme));
238
+ return visibleLines;
239
+ }
240
+
241
+ function renderSidebarRows(update: CoordinatorProgressUpdate | undefined, theme: Theme, width: number): string[] {
234
242
  if (!update) {
235
- return [theme.fg("muted", "Preparing long-task sidebar...")];
243
+ return ["", sidebarHeading("Pi Long Task", theme), "", theme.fg("muted", "Preparing long-task sidebar...")];
236
244
  }
237
245
 
238
246
  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)}`));
247
+ const rows = [""];
248
+ for (const line of wrapPlainText(sidebarHeadline(update, progress), width, 2)) {
249
+ rows.push(sidebarHeading(line, theme));
250
+ }
251
+ rows.push(renderSidebarStateLine(update, theme));
252
+
253
+ const message = normalizeMessageForSidebar(update.message, update);
254
+ if (message) {
255
+ rows.push(...wrapPlainText(message, width, 2).map((line) => theme.fg("dim", line)));
242
256
  }
243
- rows.push("", theme.fg("muted", truncateToWidth(update.message, 72)));
244
257
 
245
258
  if (!progress || progress.tasks.length === 0) {
246
- rows.push("", theme.fg("muted", "Waiting for TODO plan..."));
259
+ rows.push("", sidebarHeading("Context", theme), theme.fg("muted", "Waiting for TODO plan"));
260
+ if (update.workerCostTotal > 0) {
261
+ rows.push(theme.fg("muted", `${formatCost(update.workerCostTotal)} spent`));
262
+ }
247
263
  return rows;
248
264
  }
249
265
 
250
266
  const summary = progress.summary;
251
- rows.push("", progressBarLine(summary.completedTasks, summary.totalTasks, summary.completedPercent, theme));
252
- rows.push(progressCountsLine(summary, theme));
267
+ rows.push(
268
+ "",
269
+ sidebarHeading("Context", theme),
270
+ theme.fg(
271
+ "muted",
272
+ `${summary.completedTasks.toLocaleString()}/${summary.totalTasks.toLocaleString()} tasks complete`,
273
+ ),
274
+ theme.fg("muted", `${summary.completedPercent}% complete`),
275
+ );
276
+ if (update.workerCostTotal > 0) {
277
+ rows.push(theme.fg("muted", `${formatCost(update.workerCostTotal)} spent`));
278
+ }
279
+
280
+ rows.push(
281
+ "",
282
+ sidebarHeading("Progress", theme),
283
+ progressBarLine(progress, theme),
284
+ progressCountsLine(summary, theme),
285
+ progressStateLegend(progress, theme),
286
+ );
253
287
 
254
288
  const currentIndex = focusedTaskIndex(progress);
255
- if (currentIndex >= 0) {
256
- rows.push(theme.fg("warning", `Focus: TODO ${progress.tasks[currentIndex]?.taskId ?? "?"}`));
289
+ const currentTask = currentIndex >= 0 ? progress.tasks[currentIndex] : undefined;
290
+ rows.push("", sidebarHeading("Current", theme));
291
+ if (currentTask) {
292
+ const details = taskStatusDetails(currentTask.status);
293
+ rows.push(
294
+ theme.fg(
295
+ details.color,
296
+ `${details.icon} TODO ${currentTask.taskId} ${theme.fg("dim", "·")} ${details.label}${currentTaskMeta(currentTask, theme)}`,
297
+ ),
298
+ );
299
+ rows.push(...wrapPlainText(currentTask.title, width, 2).map((line) => theme.fg("muted", line)));
257
300
  } else {
258
301
  rows.push(theme.fg("success", "No active task"));
259
302
  }
260
303
 
261
- rows.push("", theme.fg("muted", "Tasks"));
304
+ rows.push("", sidebarHeading("Task timeline", theme));
262
305
  const taskIndexes = centeredTaskIndexes(progress.tasks.length, currentIndex, 9);
263
306
  const first = taskIndexes[0] ?? 0;
264
307
  const last = taskIndexes[taskIndexes.length - 1] ?? -1;
@@ -278,7 +321,7 @@ function renderSidebarRows(update: CoordinatorProgressUpdate | undefined, theme:
278
321
 
279
322
  const subtasks = update.subtasks ?? [];
280
323
  if (subtasks.length > 0) {
281
- rows.push("", theme.fg("muted", "Current status"));
324
+ rows.push("", sidebarHeading("Current status", theme));
282
325
  for (const subtask of subtasks.slice(0, 6)) {
283
326
  rows.push(renderSubtaskRow(subtask, theme));
284
327
  }
@@ -316,61 +359,181 @@ function renderTaskRow(
316
359
  theme: Theme,
317
360
  ): string {
318
361
  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);
362
+ const attempts = task.attempts > 0 && task.status !== "completed" ? ` ${theme.fg("dim", "·")} ${task.attempts}x` : "";
363
+ const title = truncateToWidth(task.title, 72);
364
+ const focusMarker = focused ? theme.fg("accent", "›") : theme.fg("dim", " ");
365
+ const icon = theme.fg(details.color, details.icon);
366
+ const label = `TODO ${task.taskId}`;
367
+ const row = `${label} ${theme.fg("dim", "·")} ${details.label} ${theme.fg("dim", "·")} ${title}${attempts}`;
368
+ const styledRow = focused ? theme.fg(details.color, theme.bold(row)) : theme.fg(details.textColor, row);
369
+ return `${focusMarker} ${icon} ${styledRow}`;
323
370
  }
324
371
 
325
372
  function renderSubtaskRow(subtask: NonNullable<CoordinatorProgressUpdate["subtasks"]>[number], theme: Theme): string {
326
373
  const details = progressItemStatusDetails(subtask.status);
327
- return theme.fg(details.color, `${details.icon} ${subtask.text}`);
374
+ return `${theme.fg(details.color, details.icon)} ${theme.fg(details.textColor, `${details.label} · ${subtask.text}`)}`;
375
+ }
376
+
377
+ function renderSidebarStateLine(update: CoordinatorProgressUpdate, theme: Theme): string {
378
+ const details = sidebarUpdateStateDetails(update);
379
+ const suffix = [
380
+ update.attempt && update.attempt > 1 ? `attempt ${update.attempt}` : undefined,
381
+ update.workerCostTotal > 0 ? `${formatCost(update.workerCostTotal)} spent` : undefined,
382
+ ]
383
+ .filter(Boolean)
384
+ .join(` ${theme.fg("dim", "·")} `);
385
+ const meta = suffix ? ` ${theme.fg("dim", "·")} ${theme.fg("muted", suffix)}` : "";
386
+ return `${theme.fg(details.color, details.icon)} ${theme.fg(details.color, details.label)}${meta}`;
387
+ }
388
+
389
+ function currentTaskMeta(
390
+ task: NonNullable<CoordinatorProgressUpdate["taskProgress"]>["tasks"][number],
391
+ theme: Theme,
392
+ ): string {
393
+ const attempts = task.attempts > 0 && task.status !== "completed" ? [`attempt ${task.attempts}`] : [];
394
+ if (task.lastReportedStatus && task.status !== "current") {
395
+ attempts.push(task.lastReportedStatus);
396
+ }
397
+ return attempts.length > 0 ? ` ${theme.fg("dim", "·")} ${theme.fg("muted", attempts.join(" · "))}` : "";
398
+ }
399
+
400
+ function progressMeterLine(progress: NonNullable<CoordinatorProgressUpdate["taskProgress"]>, theme: Theme): string {
401
+ const total = progress.tasks.length;
402
+ if (total === 0) {
403
+ return theme.fg("dim", "────────");
404
+ }
405
+
406
+ const maxSegments = 12;
407
+ const step = Math.max(1, Math.ceil(total / maxSegments));
408
+ const segments: string[] = [];
409
+ for (let index = 0; index < total; index += step) {
410
+ const slice = progress.tasks.slice(index, Math.min(total, index + step));
411
+ segments.push(progressMeterSegment(slice, theme));
412
+ }
413
+ return segments.join("");
414
+ }
415
+
416
+ function progressMeterSegment(
417
+ tasks: Array<NonNullable<CoordinatorProgressUpdate["taskProgress"]>["tasks"][number]>,
418
+ theme: Theme,
419
+ ): string {
420
+ if (tasks.some((task) => task.status === "failed")) {
421
+ return theme.fg("error", "×");
422
+ }
423
+ if (tasks.some((task) => task.status === "blocked")) {
424
+ return theme.fg("warning", "!");
425
+ }
426
+ if (tasks.some((task) => task.status === "current")) {
427
+ return theme.fg("accent", "▢");
428
+ }
429
+ if (tasks.every((task) => task.status === "completed")) {
430
+ return theme.fg("success", "■");
431
+ }
432
+ if (tasks.some((task) => task.status === "completed")) {
433
+ return theme.fg("success", "▪");
434
+ }
435
+ return theme.fg("dim", "·");
436
+ }
437
+
438
+ function progressStateLegend(progress: NonNullable<CoordinatorProgressUpdate["taskProgress"]>, theme: Theme): string {
439
+ const statuses = new Set(progress.tasks.map((task) => task.status));
440
+ const items = [
441
+ statuses.has("completed") ? theme.fg("success", "■ done") : undefined,
442
+ statuses.has("current") ? theme.fg("accent", "▢ active") : undefined,
443
+ statuses.has("pending") ? theme.fg("dim", "· queued") : undefined,
444
+ statuses.has("failed") ? theme.fg("error", "× failed") : undefined,
445
+ statuses.has("blocked") ? theme.fg("warning", "! blocked") : undefined,
446
+ ].filter(Boolean);
447
+ return theme.fg("muted", items.join(" · "));
448
+ }
449
+
450
+ function sidebarUpdateStateDetails(update: CoordinatorProgressUpdate): {
451
+ icon: string;
452
+ label: string;
453
+ color: "accent" | "success" | "warning" | "error" | "muted";
454
+ } {
455
+ if (update.status) {
456
+ switch (update.status) {
457
+ case "done":
458
+ return { icon: "✓", label: "done", color: "success" };
459
+ case "failed":
460
+ return { icon: "×", label: "failed", color: "error" };
461
+ case "blocked":
462
+ return { icon: "!", label: "blocked", color: "warning" };
463
+ case "partial":
464
+ return { icon: "!", label: "partial", color: "warning" };
465
+ }
466
+ }
467
+
468
+ switch (update.phase) {
469
+ case "planning":
470
+ return { icon: "+", label: "Planning", color: "warning" };
471
+ case "planned":
472
+ return { icon: "✓", label: "Plan ready", color: "success" };
473
+ case "task_start":
474
+ return { icon: "▢", label: "Running task", color: "accent" };
475
+ case "worker_tool":
476
+ return { icon: "+", label: "Worker tool", color: "warning" };
477
+ case "task_done":
478
+ return { icon: "✓", label: "Task complete", color: "success" };
479
+ case "task_blocked":
480
+ return { icon: "!", label: "Task blocked", color: "warning" };
481
+ case "task_failed":
482
+ return { icon: "×", label: "Task failed", color: "error" };
483
+ case "complete":
484
+ return { icon: "✓", label: "Complete", color: "success" };
485
+ }
328
486
  }
329
487
 
330
488
  function taskStatusDetails(status: NonNullable<CoordinatorProgressUpdate["taskProgress"]>["tasks"][number]["status"]): {
331
489
  icon: string;
332
- color: "success" | "warning" | "error" | "dim" | "muted";
490
+ label: string;
491
+ color: "accent" | "success" | "warning" | "error" | "dim" | "muted";
492
+ textColor: "accent" | "text" | "success" | "warning" | "error" | "dim" | "muted";
333
493
  } {
334
494
  switch (status) {
335
495
  case "completed":
336
- return { icon: "✓", color: "success" };
496
+ return { icon: "✓", label: "done", color: "success", textColor: "muted" };
337
497
  case "current":
338
- return { icon: "", color: "warning" };
498
+ return { icon: "", label: "active", color: "accent", textColor: "text" };
339
499
  case "failed":
340
- return { icon: "", color: "error" };
500
+ return { icon: "×", label: "failed", color: "error", textColor: "error" };
341
501
  case "blocked":
342
- return { icon: "!", color: "warning" };
502
+ return { icon: "!", label: "blocked", color: "warning", textColor: "warning" };
343
503
  case "pending":
344
- return { icon: "○", color: "dim" };
504
+ return { icon: "○", label: "queued", color: "dim", textColor: "dim" };
345
505
  }
346
506
  }
347
507
 
348
508
  function progressItemStatusDetails(status: NonNullable<CoordinatorProgressUpdate["subtasks"]>[number]["status"]): {
349
509
  icon: string;
510
+ label: string;
350
511
  color: "success" | "warning" | "error" | "dim" | "muted";
512
+ textColor: "success" | "warning" | "error" | "dim" | "muted";
351
513
  } {
352
514
  switch (status) {
353
515
  case "done":
354
- return { icon: "✓", color: "success" };
516
+ return { icon: "✓", label: "done", color: "success", textColor: "muted" };
355
517
  case "in_progress":
356
- return { icon: "", color: "warning" };
518
+ return { icon: "+", label: "active", color: "warning", textColor: "warning" };
357
519
  case "failed":
358
- return { icon: "", color: "error" };
520
+ return { icon: "×", label: "failed", color: "error", textColor: "error" };
359
521
  case "blocked":
360
- return { icon: "!", color: "warning" };
522
+ return { icon: "!", label: "blocked", color: "warning", textColor: "warning" };
361
523
  case "empty":
362
- return { icon: "○", color: "dim" };
524
+ return { icon: "○", label: "queued", color: "dim", textColor: "dim" };
363
525
  }
364
526
  }
365
527
 
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(
528
+ function progressBarLine(progress: NonNullable<CoordinatorProgressUpdate["taskProgress"]>, theme: Theme): string {
529
+ const summary = progress.summary;
530
+ return `${theme.fg("muted", "Tasks")} ${theme.fg(
531
+ "success",
532
+ `${summary.completedTasks}/${summary.totalTasks}`,
533
+ )} ${theme.fg("dim", "·")} ${theme.fg("muted", `${summary.completedPercent}% complete`)} ${theme.fg(
371
534
  "dim",
372
- "-".repeat(empty),
373
- )}] ${completedTasks}/${totalTasks} ${percent}%`;
535
+ "·",
536
+ )} ${progressMeterLine(progress, theme)}`;
374
537
  }
375
538
 
376
539
  function progressCountsLine(
@@ -378,30 +541,80 @@ function progressCountsLine(
378
541
  theme: Theme,
379
542
  ): string {
380
543
  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,
544
+ theme.fg("success", `✓ ${summary.completedTasks} done`),
545
+ summary.currentTasks ? theme.fg("accent", `▢ ${summary.currentTasks} active`) : undefined,
546
+ summary.pendingTasks ? theme.fg("dim", `○ ${summary.pendingTasks} queued`) : undefined,
547
+ summary.failedTasks ? theme.fg("error", ${summary.failedTasks} failed`) : undefined,
548
+ summary.blockedTasks ? theme.fg("warning", `! ${summary.blockedTasks} blocked`) : undefined,
386
549
  ]
387
550
  .filter(Boolean)
388
551
  .join(" · ");
389
552
  }
390
553
 
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)}┐`);
554
+ function sidebarHeading(text: string, theme: Theme): string {
555
+ return theme.fg("toolTitle", theme.bold(text));
556
+ }
557
+
558
+ function sidebarHeadline(
559
+ update: CoordinatorProgressUpdate,
560
+ progress: CoordinatorProgressUpdate["taskProgress"],
561
+ ): string {
562
+ if (update.taskId && update.title) {
563
+ return `TODO ${update.taskId} — ${update.title}`;
564
+ }
565
+ const currentTask = progress?.currentTask ?? progress?.nextTask;
566
+ if (currentTask) {
567
+ return `TODO ${currentTask.taskId} — ${currentTask.title}`;
568
+ }
569
+ return "Pi Long Task";
570
+ }
571
+
572
+ function normalizeMessageForSidebar(updateMessage: string, update: CoordinatorProgressUpdate): string | undefined {
573
+ const message = updateMessage.trim();
574
+ if (!message) {
575
+ return undefined;
576
+ }
577
+ const title = update.taskId && update.title ? `TODO ${update.taskId} — ${update.title}` : undefined;
578
+ return title && message.includes(title) && message.length <= title.length + 16 ? undefined : message;
397
579
  }
398
580
 
399
- function sidebarRow(text: string, width: number, theme: Theme): string {
400
- return `${theme.fg("borderMuted", "│")}${truncateToWidth(text, width, "…", true)}${theme.fg("borderMuted", "│")}`;
581
+ function wrapPlainText(text: string, width: number, limit?: number): string[] {
582
+ const safeWidth = Math.max(8, width);
583
+ const words = text.trim().split(/\s+/).filter(Boolean);
584
+ if (words.length === 0) {
585
+ return [];
586
+ }
587
+
588
+ const lines: string[] = [];
589
+ let line = "";
590
+ for (const word of words) {
591
+ const next = line ? `${line} ${word}` : word;
592
+ if (next.length <= safeWidth) {
593
+ line = next;
594
+ continue;
595
+ }
596
+ if (line) {
597
+ lines.push(line);
598
+ }
599
+ line = word.length > safeWidth ? truncateToWidth(word, safeWidth) : word;
600
+ if (limit && lines.length >= limit) {
601
+ break;
602
+ }
603
+ }
604
+ if (line && (!limit || lines.length < limit)) {
605
+ lines.push(line);
606
+ }
607
+ if (limit && lines.length > limit) {
608
+ lines.length = limit;
609
+ }
610
+ if (limit && words.join(" ").length > lines.join(" ").length && lines.length > 0) {
611
+ lines[lines.length - 1] = truncateToWidth(`${lines[lines.length - 1]} …`, safeWidth);
612
+ }
613
+ return lines;
401
614
  }
402
615
 
403
- function clamp(value: number, min: number, max: number): number {
404
- return Math.min(max, Math.max(min, value));
616
+ function sidebarPanelRow(text: string, width: number, theme: Theme): string {
617
+ return `${theme.fg("borderMuted", "│")} ${truncateToWidth(text, width, "…", true)}`;
405
618
  }
406
619
 
407
620
  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 {