pi-long-task 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,22 +96,25 @@ 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
- Main content: active worker activity | Pi Long Task sidebar
103
- Worker TODO 2 — Add parser tests | Progress: 2/5 tasks complete (40%)
104
- $ npm test -- parser | Worker spend: $0.18
105
- parser handles nested arrays | Timeline
106
- parser rejects invalid escapes | ✓ TODO 1 Rewrite intro done
107
- Editing src/parser.test.ts... | TODO 2 Add parser tests active
108
- Re-running focused tests after fix... | ◌ add edge-case fixtures
109
- Worker reports commands, file edits, and results here. |fix failing assertions
110
- Current TODO output stays in the main thread. | ○ TODO 3 Update docs next
111
- Sidebar tracks task status, timeline, progress, spend. | ○ TODO 4 Validate install later
112
- ```
113
-
114
- The real TUI uses responsive layout and colors; this README mockup keeps the split to one separator so it renders cleanly in package galleries and narrow views.
102
+ ┌─ Main content: active worker activity ─────────┬─ Pi Long Task sidebar ─────────┐
103
+ Worker TODO 2 — Add parser tests Progress: 2/5 tasks complete
104
+ │ │ Worker spend: $0.18
105
+ $ npm test -- parser │ │
106
+ parser handles nested arrays │ Timeline │
107
+ parser rejects invalid escapes │ ✓ TODO 1 Rewrite intro done │
108
+ │ │ TODO 2 Add tests active │
109
+ Editing src/parser.test.ts... │add edge fixtures │
110
+ Re-running tests after fix... │ ◌ fix assertions │
111
+ │ │ ○ TODO 3 Update docs next │
112
+ │ Worker reports commands, edits, results here. │ ○ TODO 4 Validate later │
113
+ │ Current TODO output stays in main thread. │ Tracks status/timeline/spend │
114
+ └────────────────────────────────────────────────┴────────────────────────────────┘
115
+ ```
116
+
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.
115
118
 
116
119
  ## How it works
117
120
 
@@ -119,15 +122,15 @@ Pi Long Task coordinates a long request from planning through task completion:
119
122
 
120
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.
121
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.
122
- 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.
123
- 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.
124
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>/`.
125
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.
126
129
 
127
130
  ## Feature reference
128
131
 
129
- - **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.
130
- - **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.
131
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.
132
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>/`.
133
136
  - **Commit-safe behavior:** when commits are enabled, Pi Long Task commits only eligible completed-task changes and skips generated run files.
@@ -177,7 +180,7 @@ No other public options are required.
177
180
 
178
181
  ## Progress display
179
182
 
180
- 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.
181
184
 
182
185
  Status markers are:
183
186
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-long-task",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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,345 @@ 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: 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
+ },
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 status = update.status ? String(update.status) : update.phase;
197
+ const lines = [`Pi Long Task: ${status}`, update.message];
198
+ if (summary) {
199
+ lines.push(
200
+ `Tasks: ${summary.completedTasks}/${summary.totalTasks} done` +
201
+ (summary.failedTasks ? `, ${summary.failedTasks} failed` : "") +
202
+ (summary.blockedTasks ? `, ${summary.blockedTasks} blocked` : ""),
203
+ );
204
+ }
205
+ if (progress && progress.tasks.length > 0) {
206
+ const currentIndex = focusedTaskIndex(progress);
207
+ const currentTask = currentIndex >= 0 ? progress.tasks[currentIndex] : undefined;
208
+ if (currentTask) {
209
+ lines.push(`Focus: TODO ${currentTask.taskId} — ${currentTask.title}`);
210
+ }
211
+ }
212
+ if (update.workerCostTotal > 0) {
213
+ lines.push(`Worker spend: ${formatCost(update.workerCostTotal)}`);
214
+ }
215
+ return lines.map((line) => truncateToWidth(line, 96));
216
+ }
217
+
218
+ function renderSidebarOverlayLines(
219
+ update: CoordinatorProgressUpdate | undefined,
220
+ theme: Theme,
221
+ width: number,
222
+ ): 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
+ ];
231
+ }
232
+
233
+ function renderSidebarRows(update: CoordinatorProgressUpdate | undefined, theme: Theme): string[] {
234
+ if (!update) {
235
+ return [theme.fg("muted", "Preparing long-task sidebar...")];
236
+ }
237
+
238
+ 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)}`));
242
+ }
243
+ rows.push("", theme.fg("muted", truncateToWidth(update.message, 72)));
244
+
245
+ if (!progress || progress.tasks.length === 0) {
246
+ rows.push("", theme.fg("muted", "Waiting for TODO plan..."));
247
+ return rows;
248
+ }
249
+
250
+ const summary = progress.summary;
251
+ rows.push("", progressBarLine(summary.completedTasks, summary.totalTasks, summary.completedPercent, theme));
252
+ rows.push(progressCountsLine(summary, theme));
253
+
254
+ const currentIndex = focusedTaskIndex(progress);
255
+ if (currentIndex >= 0) {
256
+ rows.push(theme.fg("warning", `Focus: TODO ${progress.tasks[currentIndex]?.taskId ?? "?"}`));
257
+ } else {
258
+ rows.push(theme.fg("success", "No active task"));
259
+ }
260
+
261
+ rows.push("", theme.fg("muted", "Tasks"));
262
+ const taskIndexes = centeredTaskIndexes(progress.tasks.length, currentIndex, 9);
263
+ const first = taskIndexes[0] ?? 0;
264
+ const last = taskIndexes[taskIndexes.length - 1] ?? -1;
265
+ if (first > 0) {
266
+ rows.push(theme.fg("dim", `… ${first} earlier task${first === 1 ? "" : "s"}`));
267
+ }
268
+ for (const index of taskIndexes) {
269
+ const task = progress.tasks[index];
270
+ if (task) {
271
+ rows.push(renderTaskRow(task, index === currentIndex, theme));
272
+ }
273
+ }
274
+ const remaining = progress.tasks.length - last - 1;
275
+ if (remaining > 0) {
276
+ rows.push(theme.fg("dim", `… ${remaining} later task${remaining === 1 ? "" : "s"}`));
277
+ }
278
+
279
+ const subtasks = update.subtasks ?? [];
280
+ if (subtasks.length > 0) {
281
+ rows.push("", theme.fg("muted", "Current status"));
282
+ for (const subtask of subtasks.slice(0, 6)) {
283
+ rows.push(renderSubtaskRow(subtask, theme));
284
+ }
285
+ if (subtasks.length > 6) {
286
+ rows.push(theme.fg("dim", `… ${subtasks.length - 6} more`));
287
+ }
288
+ }
289
+
290
+ return rows;
291
+ }
292
+
293
+ function centeredTaskIndexes(total: number, currentIndex: number, limit: number): number[] {
294
+ if (total <= 0) {
295
+ return [];
296
+ }
297
+ const clampedLimit = Math.max(1, Math.min(total, limit));
298
+ const focus = currentIndex >= 0 ? currentIndex : 0;
299
+ const start = Math.max(0, Math.min(total - clampedLimit, focus - Math.floor(clampedLimit / 2)));
300
+ return Array.from({ length: clampedLimit }, (_value, index) => start + index);
301
+ }
302
+
303
+ function focusedTaskIndex(progress: NonNullable<CoordinatorProgressUpdate["taskProgress"]>): number {
304
+ if (typeof progress.currentIndex === "number" && progress.currentIndex >= 0) {
305
+ return progress.currentIndex;
306
+ }
307
+ if (typeof progress.nextIndex === "number" && progress.nextIndex >= 0) {
308
+ return progress.nextIndex;
309
+ }
310
+ return progress.tasks.findIndex((task) => task.status === "current" || task.position === "current");
311
+ }
312
+
313
+ function renderTaskRow(
314
+ task: NonNullable<CoordinatorProgressUpdate["taskProgress"]>["tasks"][number],
315
+ focused: boolean,
316
+ theme: Theme,
317
+ ): string {
318
+ 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);
323
+ }
324
+
325
+ function renderSubtaskRow(subtask: NonNullable<CoordinatorProgressUpdate["subtasks"]>[number], theme: Theme): string {
326
+ const details = progressItemStatusDetails(subtask.status);
327
+ return theme.fg(details.color, `${details.icon} ${subtask.text}`);
328
+ }
329
+
330
+ function taskStatusDetails(status: NonNullable<CoordinatorProgressUpdate["taskProgress"]>["tasks"][number]["status"]): {
331
+ icon: string;
332
+ color: "success" | "warning" | "error" | "dim" | "muted";
333
+ } {
334
+ switch (status) {
335
+ case "completed":
336
+ return { icon: "✓", color: "success" };
337
+ case "current":
338
+ return { icon: "▶", color: "warning" };
339
+ case "failed":
340
+ return { icon: "✗", color: "error" };
341
+ case "blocked":
342
+ return { icon: "!", color: "warning" };
343
+ case "pending":
344
+ return { icon: "○", color: "dim" };
345
+ }
346
+ }
347
+
348
+ function progressItemStatusDetails(status: NonNullable<CoordinatorProgressUpdate["subtasks"]>[number]["status"]): {
349
+ icon: string;
350
+ color: "success" | "warning" | "error" | "dim" | "muted";
351
+ } {
352
+ switch (status) {
353
+ case "done":
354
+ return { icon: "✓", color: "success" };
355
+ case "in_progress":
356
+ return { icon: "▶", color: "warning" };
357
+ case "failed":
358
+ return { icon: "✗", color: "error" };
359
+ case "blocked":
360
+ return { icon: "!", color: "warning" };
361
+ case "empty":
362
+ return { icon: "○", color: "dim" };
363
+ }
364
+ }
365
+
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(
371
+ "dim",
372
+ "-".repeat(empty),
373
+ )}] ${completedTasks}/${totalTasks} ${percent}%`;
374
+ }
375
+
376
+ function progressCountsLine(
377
+ summary: NonNullable<NonNullable<CoordinatorProgressUpdate["taskProgress"]>["summary"]>,
378
+ theme: Theme,
379
+ ): string {
380
+ 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,
386
+ ]
387
+ .filter(Boolean)
388
+ .join(" · ");
389
+ }
390
+
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)}┐`);
397
+ }
398
+
399
+ function sidebarRow(text: string, width: number, theme: Theme): string {
400
+ return `${theme.fg("borderMuted", "│")}${truncateToWidth(text, width, "…", true)}${theme.fg("borderMuted", "│")}`;
401
+ }
402
+
403
+ function clamp(value: number, min: number, max: number): number {
404
+ return Math.min(max, Math.max(min, value));
405
+ }
406
+
407
+ function formatCost(value: number): string {
408
+ if (value === 0) {
409
+ return "$0";
410
+ }
411
+ if (value < 0.01) {
412
+ return `$${value.toFixed(4)}`;
413
+ }
414
+ return `$${value.toFixed(2)}`;
415
+ }
416
+
77
417
  export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
78
418
  const workerCostAccumulator = createWorkerCostAccumulator();
79
419
 
@@ -108,7 +448,9 @@ export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
108
448
  renderCall: renderLongTaskToolCall,
109
449
  renderResult: renderLongTaskToolResult,
110
450
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
451
+ const sidebar = createLongTaskSidebarController(ctx);
111
452
  const publishProgress = (update: CoordinatorProgressUpdate) => {
453
+ sidebar?.update(update);
112
454
  onUpdate?.({
113
455
  content: [
114
456
  {
@@ -120,23 +462,27 @@ export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
120
462
  });
121
463
  };
122
464
 
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
- };
465
+ try {
466
+ const result = await runCoordinator({
467
+ ...params,
468
+ cwd: ctx?.cwd,
469
+ abortSignal: signal,
470
+ onProgress: publishProgress,
471
+ });
472
+ workerCostAccumulator.add(result.workerCostTotal);
473
+
474
+ return {
475
+ content: [
476
+ {
477
+ type: "text" as const,
478
+ text: result.message,
479
+ },
480
+ ],
481
+ details: toolDetails(result),
482
+ };
483
+ } finally {
484
+ sidebar?.close();
485
+ }
140
486
  },
141
487
  });
142
488
  }
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 {
@@ -253,162 +181,6 @@ function renderLongTaskSummary(details: CoordinatorToolRenderDetails, expanded:
253
181
  return lines.join("\n");
254
182
  }
255
183
 
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
184
  function taskProgressModel(value: unknown): TaskProgressModel | undefined {
413
185
  const record = recordOrUndefined(value);
414
186
  if (!record || !Array.isArray(record.tasks)) {
@@ -417,10 +189,6 @@ function taskProgressModel(value: unknown): TaskProgressModel | undefined {
417
189
  return value as TaskProgressModel;
418
190
  }
419
191
 
420
- function clamp(value: number, min: number, max: number): number {
421
- return Math.min(max, Math.max(min, value));
422
- }
423
-
424
192
  function progressTaskDetails(value: unknown): ProgressTaskRenderDetails | undefined {
425
193
  const record = recordOrUndefined(value);
426
194
  const taskId = stringValue(record?.taskId);