pi-long-task 0.1.2 → 0.2.0

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
@@ -6,19 +6,22 @@ It is useful when you want Pi to handle a multi-step change without losing track
6
6
 
7
7
  ## What you get
8
8
 
9
- When you ask Pi to use `pi_long_task`, it will:
9
+ When you ask Pi to run a long task, it will:
10
10
 
11
- 1. Create or clean up a TODO plan from your request.
12
- 2. Work through each unfinished TODO task in order.
13
- 3. Record progress and final results under `tmp/pi-long-task/<run-id>/`.
14
- 4. Return a summary with completed, failed, blocked, and remaining task counts.
15
- 5. Optionally commit completed work after each task.
11
+ 1. Recognize natural-language requests like "run a long task with commits" and route them to `pi_long_task`.
12
+ 2. Create or clean up a TODO plan from your request.
13
+ 3. Work through each unfinished TODO task in order.
14
+ 4. Show the current task and inferred subtask progress while it runs.
15
+ 5. Record progress and final results under `tmp/pi-long-task/<run-id>/`.
16
+ 6. Return a summary with completed, failed, blocked, and remaining task counts.
17
+ 7. Optionally commit completed work after each task.
16
18
 
17
19
  A finished run gives you:
18
20
 
19
21
  - a concise status summary in Pi
20
22
  - a generated `TODO.md`
21
23
  - a generated `TASK_RESULT.md`
24
+ - live progress for the active task and its `**Status:**` checkbox subtasks
22
25
  - commit hashes when commits were enabled and created
23
26
  - any remaining or blocked tasks clearly listed
24
27
 
@@ -42,10 +45,34 @@ Or install the local checkout so Pi can load it normally:
42
45
  pi install /path/to/pi-long-task
43
46
  ```
44
47
 
45
- After installing, start `pi` in your target project and ask it to use the `pi_long_task` tool.
48
+ After installing, start `pi` in your target project and ask it to run a long task.
49
+
50
+ To update an existing npm install:
51
+
52
+ ```bash
53
+ pi update npm:pi-long-task
54
+ ```
55
+
56
+ Or update all installed Pi extension packages:
57
+
58
+ ```bash
59
+ pi update --extensions
60
+ ```
46
61
 
47
62
  ## Usage
48
63
 
64
+ Use natural language; you do not need to mention `pi_long_task` explicitly:
65
+
66
+ ```text
67
+ Run a long task without commits to add tests for the parser and fix any failures.
68
+ ```
69
+
70
+ ```text
71
+ Run a long task with commits to implement the TODOs in @TODO.md.
72
+ ```
73
+
74
+ You can also call the tool explicitly.
75
+
49
76
  Run without commits:
50
77
 
51
78
  ```text
@@ -78,8 +105,24 @@ The tool has two inputs:
78
105
  - `inputText` is the request or TODO markdown to work on.
79
106
  - `commit` controls whether Pi Long Task may create git commits.
80
107
 
108
+ For natural-language requests, Pi Long Task routes phrases like "run a long task with commits" to the tool with commits enabled. If you ask for a long task without mentioning commits, commits stay disabled.
109
+
110
+ Natural-language routing intentionally avoids informational questions, such as "How do I run a long task with commits?", and explicit tool calls are left unchanged.
111
+
81
112
  No other public options are required.
82
113
 
114
+ ## Progress display
115
+
116
+ While a task is running, Pi Long Task shows the active TODO and subtasks parsed from that task's `**Status:**` checkbox list.
117
+
118
+ Status markers are:
119
+
120
+ - `○` not started
121
+ - green `●` done
122
+ - orange `●` inferred as in progress
123
+
124
+ Because workers currently report structured results at the end of a task, in-progress subtask state is inferred: the first unchecked status item is shown as in progress while the task runs.
125
+
83
126
  ## Commits and files
84
127
 
85
128
  When `commit` is `false`, Pi Long Task never creates commits.
@@ -92,6 +135,8 @@ When `commit` is `true`, it may commit eligible task changes after a task report
92
135
 
93
136
  This lets you keep existing local work separate from Pi Long Task changes.
94
137
 
138
+ Commit messages are generated from the task title and adjusted to resemble recent commit-message style in the repository. Pi Long Task does not prefix commits with generated labels like `Complete TODO 1 — ...`.
139
+
95
140
  ## Validate the install
96
141
 
97
142
  Run the local checks:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-long-task",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Pi extension for breaking down and running long coding tasks safely.",
6
6
  "keywords": [
@@ -11,13 +11,14 @@ import type {
11
11
  import { commitAfterSession, gitDirtyPaths, shouldCommitOutcome, type CommitAfterSessionResult } from "./git.ts";
12
12
  import { formatCoordinatorResultMessage } from "./render.ts";
13
13
  import { extractResultSummary } from "./result_writer.ts";
14
+ import { buildTaskProgressModel, type TaskProgressModel, type TaskProgressStatus } from "./task_progress.ts";
14
15
  import {
15
16
  buildTodoCreationPrompt,
16
17
  extractTodoMarkdown,
17
18
  todoMarkdownFromString,
18
19
  validateTodoMarkdown,
19
20
  } from "./todo_generator.ts";
20
- import { incompleteTasks, markTaskDone, parseTasks, todoGlobalInstructions, type Task } from "./todo_parser.ts";
21
+ import { markTaskDone, parseTasks, todoGlobalInstructions, type Task } from "./todo_parser.ts";
21
22
  import {
22
23
  createIsolatedWorkerSession,
23
24
  lastAssistantTextFromMessages,
@@ -49,7 +50,7 @@ export type CoordinatorProgressPhase =
49
50
  | "task_failed"
50
51
  | "complete";
51
52
 
52
- export type CoordinatorProgressItemStatus = "empty" | "in_progress" | "done";
53
+ export type CoordinatorProgressItemStatus = "empty" | "in_progress" | "done" | "failed" | "blocked";
53
54
 
54
55
  export interface CoordinatorProgressTask {
55
56
  taskId: string;
@@ -81,6 +82,7 @@ export interface CoordinatorProgressUpdate {
81
82
  totalTasks?: number;
82
83
  currentTask?: CoordinatorProgressTask;
83
84
  subtasks?: CoordinatorProgressSubtask[];
85
+ taskProgress?: TaskProgressModel;
84
86
  }
85
87
 
86
88
  export type CoordinatorProgressHandler = (update: CoordinatorProgressUpdate) => void;
@@ -142,6 +144,7 @@ export interface CoordinatorResult {
142
144
  outcomes: SessionOutcome[];
143
145
  commits: CoordinatorCommitSummary[];
144
146
  attempts: TaskAttemptSummary[];
147
+ taskProgress: TaskProgressModel;
145
148
  commit: boolean;
146
149
  error?: string;
147
150
  }
@@ -184,13 +187,15 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
184
187
  emitProgress(runtime, `Created TODO plan with ${initialTasks.length} task(s).`, {
185
188
  phase: "planned",
186
189
  totalTasks: initialTasks.length,
190
+ taskProgress: buildTaskProgressModel({ tasks: initialTasks }),
187
191
  });
188
192
 
189
193
  const previousAttempts = new Map<string, string[]>();
190
194
  let failure: string | undefined;
191
195
 
192
196
  while (!runtime.abortSignal?.aborted) {
193
- const nextTask = incompleteTasks(todoMarkdown)[0];
197
+ const tasksBeforeAttempt = parseTasks(todoMarkdown);
198
+ const nextTask = tasksBeforeAttempt.find((task) => !task.done);
194
199
  if (!nextTask) {
195
200
  break;
196
201
  }
@@ -205,6 +210,11 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
205
210
  title: nextTask.title,
206
211
  attempt,
207
212
  ...currentTaskProgress(nextTask, "in_progress"),
213
+ taskProgress: buildTaskProgressModel({
214
+ tasks: tasksBeforeAttempt,
215
+ attempts,
216
+ currentTaskId: nextTask.taskId,
217
+ }),
208
218
  },
209
219
  );
210
220
  const preExistingDirtyPaths = options.commit
@@ -224,7 +234,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
224
234
  abortSignal: runtime.abortSignal,
225
235
  sessionFactory: runtime.workerSessionFactory,
226
236
  now: runtime.now,
227
- onEvent: (event) => emitWorkerEventProgress(runtime, nextTask, attempt, event),
237
+ onEvent: (event) => emitWorkerEventProgress(runtime, tasksBeforeAttempt, nextTask, attempts, attempt, event),
228
238
  });
229
239
  outcomes.push(outcome);
230
240
 
@@ -270,7 +280,16 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
270
280
  await appendCommitNote(runtime.taskResultPath, commitResult);
271
281
  }
272
282
 
273
- emitTaskOutcomeProgress(runtime, nextTask, outcome, taskCommitHash, taskCommitError, taskCommitSkipped);
283
+ emitTaskOutcomeProgress(
284
+ runtime,
285
+ parseTasks(todoMarkdown),
286
+ nextTask,
287
+ attempts,
288
+ outcome,
289
+ taskCommitHash,
290
+ taskCommitError,
291
+ taskCommitSkipped,
292
+ );
274
293
 
275
294
  const attemptSummary = resultTextForPreviousAttempt(outcome);
276
295
  previousAttempts.set(nextTask.taskId, [...(previousAttempts.get(nextTask.taskId) ?? []), attemptSummary]);
@@ -304,6 +323,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
304
323
  blockedTasks,
305
324
  failedTasks,
306
325
  });
326
+ const taskProgress = buildCompletionTaskProgressModel(finalTasks, attempts, status);
307
327
  const summary = failure
308
328
  ? `Pi Long Task ${status}: ${failure}`
309
329
  : `Pi Long Task completed ${completedTasks}/${finalTasks.length} task(s).`;
@@ -325,6 +345,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
325
345
  outcomes,
326
346
  commits,
327
347
  attempts,
348
+ taskProgress,
328
349
  commit: options.commit,
329
350
  error: failure,
330
351
  };
@@ -333,6 +354,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
333
354
  phase: "complete",
334
355
  status,
335
356
  totalTasks: finalTasks.length,
357
+ taskProgress,
336
358
  });
337
359
  return result;
338
360
  } catch (error) {
@@ -362,11 +384,16 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
362
384
  outcomes,
363
385
  commits,
364
386
  attempts,
387
+ taskProgress: buildTaskProgressModel({ tasks: [], attempts }),
365
388
  commit: options.commit,
366
389
  error: message,
367
390
  };
368
391
  result.message = formatCoordinatorResultMessage(result);
369
- emitProgress(runtime, "Pi Long Task failed.", { phase: "complete", status: "failed" });
392
+ emitProgress(runtime, "Pi Long Task failed.", {
393
+ phase: "complete",
394
+ status: "failed",
395
+ taskProgress: buildTaskProgressModel({ tasks: [], attempts }),
396
+ });
370
397
  return result;
371
398
  }
372
399
  }
@@ -475,14 +502,14 @@ function subtaskProgress(
475
502
  task: Pick<Task, "statusItems">,
476
503
  taskStatus: CoordinatorProgressItemStatus,
477
504
  ): CoordinatorProgressSubtask[] {
478
- let markedInProgress = false;
505
+ let markedActive = false;
479
506
  return task.statusItems.map((item) => {
480
507
  if (item.done || taskStatus === "done") {
481
508
  return { text: item.text, status: "done" };
482
509
  }
483
- if (taskStatus === "in_progress" && !markedInProgress) {
484
- markedInProgress = true;
485
- return { text: item.text, status: "in_progress" };
510
+ if ((taskStatus === "in_progress" || taskStatus === "failed" || taskStatus === "blocked") && !markedActive) {
511
+ markedActive = true;
512
+ return { text: item.text, status: taskStatus };
486
513
  }
487
514
  return { text: item.text, status: "empty" };
488
515
  });
@@ -490,7 +517,9 @@ function subtaskProgress(
490
517
 
491
518
  function emitWorkerEventProgress(
492
519
  runtime: RuntimeOptions,
520
+ tasks: readonly Task[],
493
521
  task: Pick<Task, "taskId" | "title" | "statusItems">,
522
+ attempts: readonly TaskAttemptSummary[],
494
523
  attempt: number,
495
524
  event: { type: string; toolName?: string; isError?: boolean },
496
525
  ): void {
@@ -508,6 +537,7 @@ function emitWorkerEventProgress(
508
537
  workerEventType: event.type,
509
538
  isError: event.isError,
510
539
  ...currentTaskProgress(task, "in_progress"),
540
+ taskProgress: buildTaskProgressModel({ tasks, attempts, currentTaskId: task.taskId }),
511
541
  };
512
542
  if (event.isError) {
513
543
  update.status = "failed";
@@ -517,7 +547,9 @@ function emitWorkerEventProgress(
517
547
 
518
548
  function emitTaskOutcomeProgress(
519
549
  runtime: RuntimeOptions,
550
+ tasks: readonly Task[],
520
551
  task: Pick<Task, "taskId" | "title" | "statusItems">,
552
+ attempts: readonly TaskAttemptSummary[],
521
553
  outcome: SessionOutcome,
522
554
  commitHash: string | undefined,
523
555
  commitError: string | undefined,
@@ -542,7 +574,13 @@ function emitTaskOutcomeProgress(
542
574
  title: task.title,
543
575
  attempt: outcome.attempt,
544
576
  status: outcome.reportedStatus,
545
- ...currentTaskProgress(task, outcome.done ? "done" : "in_progress"),
577
+ ...currentTaskProgress(task, outcomeProgressItemStatus(outcome)),
578
+ taskProgress: buildTaskProgressModel({
579
+ tasks,
580
+ attempts,
581
+ currentTaskId: task.taskId,
582
+ currentTaskStatus: outcomeTaskProgressStatus(outcome),
583
+ }),
546
584
  };
547
585
  if (commitHash) {
548
586
  update.commitHash = commitHash;
@@ -556,6 +594,50 @@ function emitTaskOutcomeProgress(
556
594
  emitProgress(runtime, `TODO ${task.taskId} ${statusText}${commitText}.`, update);
557
595
  }
558
596
 
597
+ function buildCompletionTaskProgressModel(
598
+ tasks: readonly Task[],
599
+ attempts: readonly TaskAttemptSummary[],
600
+ status: CoordinatorStatus,
601
+ ): TaskProgressModel {
602
+ if (status === "done") {
603
+ return buildTaskProgressModel({ tasks, attempts });
604
+ }
605
+
606
+ const lastIncompleteAttempt = [...attempts].reverse().find((attempt) => !attempt.done);
607
+ if (!lastIncompleteAttempt) {
608
+ return buildTaskProgressModel({ tasks, attempts });
609
+ }
610
+
611
+ return buildTaskProgressModel({
612
+ tasks,
613
+ attempts,
614
+ currentTaskId: lastIncompleteAttempt.taskId,
615
+ currentTaskStatus: outcomeTaskProgressStatus(lastIncompleteAttempt),
616
+ });
617
+ }
618
+
619
+ function outcomeTaskProgressStatus(outcome: Pick<SessionOutcome, "done" | "reportedStatus">): TaskProgressStatus {
620
+ if (outcome.done) {
621
+ return "completed";
622
+ }
623
+ if (outcome.reportedStatus === "blocked") {
624
+ return "blocked";
625
+ }
626
+ return "failed";
627
+ }
628
+
629
+ function outcomeProgressItemStatus(
630
+ outcome: Pick<SessionOutcome, "done" | "reportedStatus">,
631
+ ): CoordinatorProgressItemStatus {
632
+ if (outcome.done) {
633
+ return "done";
634
+ }
635
+ if (outcome.reportedStatus === "blocked") {
636
+ return "blocked";
637
+ }
638
+ return "failed";
639
+ }
640
+
559
641
  function initialTaskResultMarkdown(runId: string): string {
560
642
  return `# Pi Long Task TASK_RESULT\n\nRun: ${runId}\n`;
561
643
  }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import { runCoordinator, type CoordinatorProgressUpdate, type CoordinatorResult } from "./coordinator.ts";
4
+ import { longTaskInputTransform } from "./input_router.ts";
4
5
  import { renderLongTaskToolCall, renderLongTaskToolResult } from "./render.ts";
5
6
  import { PiLongTaskParams } from "./types.ts";
6
7
 
@@ -17,15 +18,30 @@ function toolDetails(result: CoordinatorResult) {
17
18
  failedTasks: result.failedTasks,
18
19
  blockedTasks: result.blockedTasks,
19
20
  remainingTasks: result.remainingTasks,
21
+ taskProgress: result.taskProgress,
20
22
  summary: result.summary,
21
23
  };
22
24
  }
23
25
 
24
26
  export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
27
+ pi.on("input", (event) => {
28
+ if (event.source === "extension") {
29
+ return { action: "continue" as const };
30
+ }
31
+
32
+ const transformed = longTaskInputTransform(event.text);
33
+ if (!transformed) {
34
+ return { action: "continue" as const };
35
+ }
36
+
37
+ return { action: "transform" as const, text: transformed };
38
+ });
39
+
25
40
  pi.registerTool({
26
41
  name: "pi_long_task",
27
42
  label: "Pi Long Task",
28
- description: "Break down and run long coding tasks from a request or TODO plan.",
43
+ description:
44
+ "Run long or multi-step coding tasks from a request or TODO plan. Use this when the user asks to run/start/handle a long task; set commit true only when they ask for commits or committing as work progresses.",
29
45
  parameters: PiLongTaskParams,
30
46
  renderCall: renderLongTaskToolCall,
31
47
  renderResult: renderLongTaskToolResult,
@@ -0,0 +1,57 @@
1
+ const LONG_TASK_RE = /\b(?:long[-\s]?task|longtask|large[-\s]?task|big[-\s]?task|multi[-\s]?step(?:\s+task)?)\b/i;
2
+ const DIRECT_LONG_TASK_REQUEST_RE =
3
+ /\b(?:run|start|do|handle|execute|launch|kick\s+off|use)\s+(?:a\s+|the\s+)?(?:long[-\s]?task|longtask|large[-\s]?task|big[-\s]?task|multi[-\s]?step(?:\s+task)?)\b/i;
4
+ const WANT_LONG_TASK_RE =
5
+ /\b(?:please|can\s+you|could\s+you|i\s+want|i\s+need|i'd\s+like|i\s+would\s+like|let's|lets)\b[\s\S]*\b(?:long[-\s]?task|longtask|large[-\s]?task|big[-\s]?task|multi[-\s]?step(?:\s+task)?)\b/i;
6
+ const NEGATED_LONG_TASK_RE =
7
+ /\b(?:do\s+not|don't|dont|never)\s+(?:run|start|do|handle|execute|launch|kick\s+off|use)\s+(?:a\s+|the\s+)?(?:long[-\s]?task|longtask|large[-\s]?task|big[-\s]?task|multi[-\s]?step(?:\s+task)?)\b/i;
8
+ const INFORMATION_QUESTION_RE = /^\s*(?:how|what|why|when|where|who)\b/i;
9
+ const EXPLICIT_TOOL_RE = /\bpi_long_task\b/i;
10
+
11
+ const COMMIT_FALSE_RE =
12
+ /\b(?:without|no|disable|disabled|off)\s+commits?\b|\bcommits?\s*(?:false|off|disabled)\b|\bcommit\s*:\s*(?:false|off|no)\b|\b(?:do\s+not|don't|dont)\s+commit\b/i;
13
+ const COMMIT_TRUE_RE =
14
+ /\bwith\s+commits?\b|\bcommits?\s*(?:true|on|enabled)\b|\bcommit\s*:\s*(?:true|on|yes)\b|\bcommit(?:ting)?\s+as\s+(?:you|we)\s+go\b|\b(?:make|create|include|allow|enable)\s+commits?\b/i;
15
+
16
+ export function longTaskInputTransform(text: string): string | undefined {
17
+ if (!isNaturalLanguageLongTaskRequest(text)) {
18
+ return undefined;
19
+ }
20
+
21
+ const commit = inferCommitSetting(text) ?? false;
22
+ return buildLongTaskToolPrompt(text, commit);
23
+ }
24
+
25
+ export function isNaturalLanguageLongTaskRequest(text: string): boolean {
26
+ const trimmed = text.trim();
27
+ if (!trimmed || trimmed.startsWith("/") || EXPLICIT_TOOL_RE.test(trimmed)) {
28
+ return false;
29
+ }
30
+ if (!LONG_TASK_RE.test(trimmed) || INFORMATION_QUESTION_RE.test(trimmed) || NEGATED_LONG_TASK_RE.test(trimmed)) {
31
+ return false;
32
+ }
33
+ return DIRECT_LONG_TASK_REQUEST_RE.test(trimmed) || WANT_LONG_TASK_RE.test(trimmed);
34
+ }
35
+
36
+ export function inferCommitSetting(text: string): boolean | undefined {
37
+ if (COMMIT_FALSE_RE.test(text)) {
38
+ return false;
39
+ }
40
+ if (COMMIT_TRUE_RE.test(text)) {
41
+ return true;
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ function buildLongTaskToolPrompt(originalText: string, commit: boolean): string {
47
+ return [
48
+ "Use the pi_long_task tool for this request.",
49
+ `Set commit to ${commit ? "true" : "false"}.`,
50
+ "Set inputText to the user's original request below. Do not perform the work directly outside pi_long_task.",
51
+ "",
52
+ "Original request:",
53
+ "```text",
54
+ originalText.trim(),
55
+ "```",
56
+ ].join("\n");
57
+ }
package/src/render.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { AgentToolResult, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
2
- import { Text } from "@earendil-works/pi-tui";
2
+ import { Text, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
3
3
 
4
+ import type { TaskProgressModel, TaskProgressStatus, TaskProgressTask } from "./task_progress.ts";
4
5
  import type { CoordinatorCommitSummary, CoordinatorRemainingTask, CoordinatorStatus } from "./types.ts";
5
6
 
6
7
  export interface CoordinatorResultForRendering {
@@ -15,6 +16,7 @@ export interface CoordinatorResultForRendering {
15
16
  taskResultPath?: string;
16
17
  commits?: CoordinatorCommitSummary[];
17
18
  remainingTasks?: CoordinatorRemainingTask[];
19
+ taskProgress?: TaskProgressModel;
18
20
  error?: string;
19
21
  }
20
22
 
@@ -22,7 +24,7 @@ export interface CoordinatorToolRenderDetails extends CoordinatorResultForRender
22
24
  runId?: string;
23
25
  }
24
26
 
25
- type ProgressItemStatus = "empty" | "in_progress" | "done";
27
+ type ProgressItemStatus = "empty" | "in_progress" | "done" | "failed" | "blocked";
26
28
 
27
29
  interface ProgressTaskRenderDetails {
28
30
  taskId: string;
@@ -35,6 +37,70 @@ interface ProgressSubtaskRenderDetails {
35
37
  status: ProgressItemStatus;
36
38
  }
37
39
 
40
+ const SIDEBAR_MIN_SIDE_BY_SIDE_WIDTH = 84;
41
+ const SIDEBAR_MIN_WIDTH = 26;
42
+ const SIDEBAR_MAX_WIDTH = 40;
43
+ const SIDEBAR_GAP = 2;
44
+
45
+ class LongTaskSidebarShell implements Component {
46
+ private readonly mainText: string;
47
+ private readonly taskProgress: TaskProgressModel;
48
+ private readonly theme: Theme;
49
+
50
+ constructor(mainText: string, taskProgress: TaskProgressModel, theme: Theme) {
51
+ this.mainText = mainText;
52
+ this.taskProgress = taskProgress;
53
+ this.theme = theme;
54
+ }
55
+
56
+ render(width: number): string[] {
57
+ if (width < SIDEBAR_MIN_SIDE_BY_SIDE_WIDTH) {
58
+ return this.renderStacked(width);
59
+ }
60
+
61
+ const sidebarWidth = clamp(Math.floor(width * 0.32), SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH);
62
+ const mainWidth = width - sidebarWidth - SIDEBAR_GAP;
63
+ if (mainWidth < 40) {
64
+ return this.renderStacked(width);
65
+ }
66
+
67
+ const mainLines = renderWrappedLines(this.mainText, mainWidth);
68
+ const sidebarLines = this.renderSidebar(sidebarWidth);
69
+ const height = Math.max(mainLines.length, sidebarLines.length);
70
+ const lines: string[] = [];
71
+ for (let idx = 0; idx < height; idx += 1) {
72
+ const main = padLine(mainLines[idx] ?? "", mainWidth);
73
+ const sidebar = sidebarLines[idx] ?? "";
74
+ lines.push(truncateToWidth(`${main}${" ".repeat(SIDEBAR_GAP)}${sidebar}`, width));
75
+ }
76
+ return lines;
77
+ }
78
+
79
+ invalidate(): void {
80
+ // Rendering is computed from current state on each pass.
81
+ }
82
+
83
+ private renderStacked(width: number): string[] {
84
+ const mainLines = renderWrappedLines(this.mainText, width);
85
+ const sidebarLines = this.renderSidebar(width);
86
+ return [...mainLines, ...sidebarLines].map((line) => truncateToWidth(line, width));
87
+ }
88
+
89
+ private renderSidebar(width: number): string[] {
90
+ if (width < 8) {
91
+ return [];
92
+ }
93
+
94
+ const innerWidth = Math.max(0, width - 2);
95
+ const rows = sidebarRows(this.taskProgress, this.theme);
96
+ return [
97
+ sidebarBorder("Long Task", width, this.theme),
98
+ ...rows.map((row) => sidebarRow(row, innerWidth, this.theme)),
99
+ this.theme.fg("borderMuted", `└${"─".repeat(innerWidth)}┘`),
100
+ ];
101
+ }
102
+ }
103
+
38
104
  export function formatCoordinatorResultMessage(result: CoordinatorResultForRendering): string {
39
105
  const resultPath = result.resultPath ?? result.taskResultPath ?? "unknown";
40
106
  const remaining = result.remainingTasks ?? [];
@@ -84,10 +150,12 @@ export function renderLongTaskToolResult(
84
150
  result: AgentToolResult<unknown>,
85
151
  options: ToolRenderResultOptions,
86
152
  theme: Theme,
87
- ): Text {
153
+ ): Component {
88
154
  const details = recordOrUndefined(result.details);
89
155
  if (options.isPartial) {
90
- return new Text(renderLongTaskProgress(details, contentText(result), theme), 0, 0);
156
+ const taskProgress = taskProgressModel(details?.taskProgress);
157
+ const main = renderLongTaskProgress(details, contentText(result), theme);
158
+ return taskProgress ? new LongTaskSidebarShell(main, taskProgress, theme) : new Text(main, 0, 0);
91
159
  }
92
160
 
93
161
  const finalDetails = longTaskDetails(details);
@@ -95,7 +163,10 @@ export function renderLongTaskToolResult(
95
163
  return new Text(contentText(result), 0, 0);
96
164
  }
97
165
 
98
- return new Text(renderLongTaskSummary(finalDetails, options.expanded, theme), 0, 0);
166
+ const main = renderLongTaskSummary(finalDetails, options.expanded, theme);
167
+ return finalDetails.taskProgress
168
+ ? new LongTaskSidebarShell(main, finalDetails.taskProgress, theme)
169
+ : new Text(main, 0, 0);
99
170
  }
100
171
 
101
172
  function renderLongTaskProgress(details: Record<string, unknown> | undefined, fallback: string, theme: Theme): string {
@@ -173,6 +244,171 @@ function renderLongTaskSummary(details: CoordinatorToolRenderDetails, expanded:
173
244
  return lines.join("\n");
174
245
  }
175
246
 
247
+ function renderWrappedLines(text: string, width: number): string[] {
248
+ return new Text(text, 0, 0).render(Math.max(1, width)).map((line) => truncateToWidth(line, Math.max(1, width)));
249
+ }
250
+
251
+ function padLine(line: string, width: number): string {
252
+ return truncateToWidth(line, width, "…", true);
253
+ }
254
+
255
+ function sidebarRows(taskProgress: TaskProgressModel, theme: Theme): string[] {
256
+ const summary = normalizedTaskProgressSummary(taskProgress);
257
+ const rows = [theme.fg("toolTitle", theme.bold("Task sidebar")), theme.fg("dim", "Centered timeline")];
258
+ if (summary.totalTasks === 0) {
259
+ rows.push("", theme.fg("muted", "Waiting for TODO plan..."));
260
+ return rows;
261
+ }
262
+
263
+ rows.push("", progressBarLine(summary.completedTasks, summary.totalTasks, summary.completedPercent, theme));
264
+ rows.push(progressCountsLine(summary, theme));
265
+
266
+ const currentIndex = focusedTaskIndex(taskProgress);
267
+ if (currentIndex >= 0) {
268
+ rows.push(theme.fg("warning", `Focus: TODO ${taskProgress.tasks[currentIndex]?.taskId ?? "?"}`));
269
+ } else {
270
+ rows.push(theme.fg("success", "No active task"));
271
+ }
272
+
273
+ rows.push("", theme.fg("muted", "Timeline"));
274
+ for (const [index, task] of taskProgress.tasks.entries()) {
275
+ if (currentIndex >= 0 && index === currentIndex && index > 0) {
276
+ rows.push(theme.fg("dim", "──── current ────"));
277
+ }
278
+ rows.push(renderSidebarTaskRow(task, theme));
279
+ if (currentIndex >= 0 && index === currentIndex && index < taskProgress.tasks.length - 1) {
280
+ rows.push(theme.fg("dim", "──── future ─────"));
281
+ }
282
+ }
283
+
284
+ return rows;
285
+ }
286
+
287
+ interface NormalizedTaskProgressSummary {
288
+ totalTasks: number;
289
+ completedTasks: number;
290
+ failedTasks: number;
291
+ blockedTasks: number;
292
+ pendingTasks: number;
293
+ currentTasks: number;
294
+ completedPercent: number;
295
+ }
296
+
297
+ function normalizedTaskProgressSummary(taskProgress: TaskProgressModel): NormalizedTaskProgressSummary {
298
+ const totalTasks = taskProgress.summary?.totalTasks ?? taskProgress.tasks.length;
299
+ const completedTasks = taskProgress.summary?.completedTasks ?? countTasksByStatus(taskProgress.tasks, "completed");
300
+ const failedTasks = taskProgress.summary?.failedTasks ?? countTasksByStatus(taskProgress.tasks, "failed");
301
+ const blockedTasks = taskProgress.summary?.blockedTasks ?? countTasksByStatus(taskProgress.tasks, "blocked");
302
+ const pendingTasks = taskProgress.summary?.pendingTasks ?? countTasksByStatus(taskProgress.tasks, "pending");
303
+ const currentTasks = taskProgress.summary?.currentTasks ?? countTasksByStatus(taskProgress.tasks, "current");
304
+ const completedPercent =
305
+ taskProgress.summary?.completedPercent ??
306
+ (totalTasks === 0 ? 100 : Math.round((completedTasks / totalTasks) * 100));
307
+
308
+ return {
309
+ totalTasks,
310
+ completedTasks,
311
+ failedTasks,
312
+ blockedTasks,
313
+ pendingTasks,
314
+ currentTasks,
315
+ completedPercent,
316
+ };
317
+ }
318
+
319
+ function countTasksByStatus(tasks: readonly TaskProgressTask[], status: TaskProgressStatus): number {
320
+ return tasks.filter((task) => task.status === status).length;
321
+ }
322
+
323
+ function progressBarLine(completedTasks: number, totalTasks: number, percent: number, theme: Theme): string {
324
+ const width = 10;
325
+ const filled = clamp(totalTasks === 0 ? width : Math.round((completedTasks / totalTasks) * width), 0, width);
326
+ const empty = Math.max(0, width - filled);
327
+ return `${theme.fg("muted", "Progress")} [${theme.fg("success", "#".repeat(filled))}${theme.fg(
328
+ "dim",
329
+ "-".repeat(empty),
330
+ )}] ${completedTasks}/${totalTasks} ${percent}%`;
331
+ }
332
+
333
+ function progressCountsLine(summary: NormalizedTaskProgressSummary, theme: Theme): string {
334
+ const parts = [
335
+ theme.fg("success", `✓ ${summary.completedTasks}`),
336
+ summary.currentTasks ? theme.fg("warning", `▶ ${summary.currentTasks}`) : undefined,
337
+ summary.pendingTasks ? theme.fg("dim", `○ ${summary.pendingTasks}`) : undefined,
338
+ summary.failedTasks ? theme.fg("error", `✗ ${summary.failedTasks}`) : undefined,
339
+ summary.blockedTasks ? theme.fg("warning", `! ${summary.blockedTasks}`) : undefined,
340
+ ].filter(Boolean);
341
+ return parts.join(" · ");
342
+ }
343
+
344
+ function focusedTaskIndex(taskProgress: TaskProgressModel): number {
345
+ if (typeof taskProgress.currentIndex === "number" && taskProgress.currentIndex >= 0) {
346
+ return taskProgress.currentIndex;
347
+ }
348
+
349
+ const currentIndex = taskProgress.tasks.findIndex((task) => task.status === "current" || task.position === "current");
350
+ if (currentIndex >= 0) {
351
+ return currentIndex;
352
+ }
353
+
354
+ if (typeof taskProgress.nextIndex === "number" && taskProgress.nextIndex >= 0) {
355
+ return taskProgress.nextIndex;
356
+ }
357
+ return taskProgress.tasks.findIndex((task) => task.status === "pending");
358
+ }
359
+
360
+ function renderSidebarTaskRow(task: TaskProgressTask, theme: Theme): string {
361
+ const { icon, color, label } = sidebarTaskStatusDetails(task.status);
362
+ const attempts =
363
+ task.attempts > 0 && task.status !== "completed"
364
+ ? ` · ${task.attempts} attempt${task.attempts === 1 ? "" : "s"}`
365
+ : "";
366
+ const text = `${icon} [${label}] TODO ${task.taskId} — ${task.title}${attempts}`;
367
+ return task.status === "current" ? theme.fg(color, theme.bold(text)) : theme.fg(color, text);
368
+ }
369
+
370
+ function sidebarTaskStatusDetails(status: TaskProgressStatus): {
371
+ icon: string;
372
+ color: "success" | "warning" | "error" | "dim" | "muted";
373
+ label: string;
374
+ } {
375
+ switch (status) {
376
+ case "completed":
377
+ return { icon: "✓", color: "success", label: "completed" };
378
+ case "current":
379
+ return { icon: "▶", color: "warning", label: "current" };
380
+ case "failed":
381
+ return { icon: "✗", color: "error", label: "failed" };
382
+ case "blocked":
383
+ return { icon: "!", color: "warning", label: "blocked" };
384
+ case "pending":
385
+ return { icon: "○", color: "dim", label: "pending" };
386
+ }
387
+ }
388
+
389
+ function sidebarBorder(title: string, width: number, theme: Theme): string {
390
+ const innerWidth = Math.max(0, width - 2);
391
+ const titleText = truncateToWidth(` ${title} `, innerWidth, "");
392
+ const remaining = Math.max(0, innerWidth - visibleWidth(titleText));
393
+ return theme.fg("borderMuted", `┌${titleText}${"─".repeat(remaining)}┐`);
394
+ }
395
+
396
+ function sidebarRow(row: string, innerWidth: number, theme: Theme): string {
397
+ return `${theme.fg("borderMuted", "│")}${truncateToWidth(row, innerWidth, "…", true)}${theme.fg("borderMuted", "│")}`;
398
+ }
399
+
400
+ function taskProgressModel(value: unknown): TaskProgressModel | undefined {
401
+ const record = recordOrUndefined(value);
402
+ if (!record || !Array.isArray(record.tasks)) {
403
+ return undefined;
404
+ }
405
+ return value as TaskProgressModel;
406
+ }
407
+
408
+ function clamp(value: number, min: number, max: number): number {
409
+ return Math.min(max, Math.max(min, value));
410
+ }
411
+
176
412
  function progressTaskDetails(value: unknown): ProgressTaskRenderDetails | undefined {
177
413
  const record = recordOrUndefined(value);
178
414
  const taskId = stringValue(record?.taskId);
@@ -200,18 +436,32 @@ function progressSubtaskDetails(value: unknown): ProgressSubtaskRenderDetails[]
200
436
  }
201
437
 
202
438
  function progressItemStatus(value: unknown): ProgressItemStatus | undefined {
203
- return value === "empty" || value === "in_progress" || value === "done" ? value : undefined;
439
+ return value === "empty" || value === "in_progress" || value === "done" || value === "failed" || value === "blocked"
440
+ ? value
441
+ : undefined;
204
442
  }
205
443
 
206
444
  function progressBubble(status: ProgressItemStatus, theme: Theme): string {
207
- return status === "empty" ? theme.fg("dim", "○") : theme.fg(progressTextColor(status), "●");
445
+ switch (status) {
446
+ case "empty":
447
+ return theme.fg("dim", "○");
448
+ case "failed":
449
+ return theme.fg("error", "✗");
450
+ case "blocked":
451
+ return theme.fg("warning", "!");
452
+ default:
453
+ return theme.fg(progressTextColor(status), "●");
454
+ }
208
455
  }
209
456
 
210
- function progressTextColor(status: ProgressItemStatus): "success" | "warning" | "dim" {
457
+ function progressTextColor(status: ProgressItemStatus): "success" | "warning" | "dim" | "error" {
211
458
  if (status === "done") {
212
459
  return "success";
213
460
  }
214
- if (status === "in_progress") {
461
+ if (status === "failed") {
462
+ return "error";
463
+ }
464
+ if (status === "in_progress" || status === "blocked") {
215
465
  return "warning";
216
466
  }
217
467
  return "dim";
@@ -252,6 +502,7 @@ function longTaskDetails(details: Record<string, unknown> | undefined): Coordina
252
502
  runId: stringValue(details.runId),
253
503
  commits: commitSummaries(details.commits),
254
504
  remainingTasks: remainingTaskSummaries(details.remainingTasks),
505
+ taskProgress: taskProgressModel(details.taskProgress),
255
506
  error: stringValue(details.error),
256
507
  };
257
508
  }
@@ -0,0 +1,187 @@
1
+ import type { Task, TaskStatusItem } from "./todo_parser.ts";
2
+
3
+ export const TASK_PROGRESS_STATUS_VALUES = ["pending", "current", "completed", "failed", "blocked"] as const;
4
+ export type TaskProgressStatus = (typeof TASK_PROGRESS_STATUS_VALUES)[number];
5
+
6
+ export const TASK_PROGRESS_POSITION_VALUES = ["past", "current", "future"] as const;
7
+ export type TaskProgressPosition = (typeof TASK_PROGRESS_POSITION_VALUES)[number];
8
+
9
+ export interface TaskProgressAttempt {
10
+ taskId: string;
11
+ attempt?: number;
12
+ reportedStatus?: string;
13
+ done?: boolean;
14
+ }
15
+
16
+ export type TaskProgressTaskStatusItem = TaskStatusItem;
17
+
18
+ export interface TaskProgressTask {
19
+ taskId: string;
20
+ title: string;
21
+ status: TaskProgressStatus;
22
+ position: TaskProgressPosition;
23
+ done: boolean;
24
+ statusItems: TaskProgressTaskStatusItem[];
25
+ attempts: number;
26
+ lastReportedStatus?: string;
27
+ }
28
+
29
+ export interface TaskProgressSummary {
30
+ totalTasks: number;
31
+ completedTasks: number;
32
+ failedTasks: number;
33
+ blockedTasks: number;
34
+ pendingTasks: number;
35
+ currentTasks: number;
36
+ attemptedTasks: number;
37
+ completionRatio: number;
38
+ completedPercent: number;
39
+ }
40
+
41
+ export interface TaskProgressModel {
42
+ tasks: TaskProgressTask[];
43
+ summary: TaskProgressSummary;
44
+ currentTaskId?: string;
45
+ currentIndex?: number;
46
+ currentTask?: TaskProgressTask;
47
+ nextTaskId?: string;
48
+ nextIndex?: number;
49
+ nextTask?: TaskProgressTask;
50
+ }
51
+
52
+ export interface BuildTaskProgressModelOptions {
53
+ tasks: readonly Task[];
54
+ attempts?: readonly TaskProgressAttempt[];
55
+ currentTaskId?: string;
56
+ currentTaskStatus?: TaskProgressStatus;
57
+ }
58
+
59
+ export function buildTaskProgressModel(options: BuildTaskProgressModelOptions): TaskProgressModel {
60
+ const attempts = options.attempts ?? [];
61
+ const attemptStats = attemptsByTask(attempts);
62
+ const activeIndex = activeTaskIndex(options.tasks, options.currentTaskId);
63
+
64
+ const progressTasks = options.tasks.map((task, index) => {
65
+ const stats = attemptStats.get(task.taskId);
66
+ const isActive = index === activeIndex;
67
+ const status = taskProgressStatus(task, stats?.lastAttempt, isActive, options.currentTaskStatus);
68
+
69
+ const progressTask: TaskProgressTask = {
70
+ taskId: task.taskId,
71
+ title: task.title,
72
+ status,
73
+ position: taskProgressPosition(index, activeIndex, status),
74
+ done: status === "completed",
75
+ statusItems: task.statusItems.map((item) => ({ ...item })),
76
+ attempts: stats?.attempts ?? 0,
77
+ };
78
+ if (stats?.lastAttempt.reportedStatus) {
79
+ progressTask.lastReportedStatus = stats.lastAttempt.reportedStatus;
80
+ }
81
+ return progressTask;
82
+ });
83
+
84
+ const currentIndex = progressTasks.findIndex((task) => task.position === "current");
85
+ const nextIndex = progressTasks.findIndex((task) => task.status === "pending");
86
+ const summary = taskProgressSummary(progressTasks, attempts.length);
87
+
88
+ const model: TaskProgressModel = {
89
+ tasks: progressTasks,
90
+ summary,
91
+ };
92
+ if (currentIndex >= 0) {
93
+ model.currentIndex = currentIndex;
94
+ model.currentTask = progressTasks[currentIndex];
95
+ model.currentTaskId = progressTasks[currentIndex].taskId;
96
+ }
97
+ if (nextIndex >= 0) {
98
+ model.nextIndex = nextIndex;
99
+ model.nextTask = progressTasks[nextIndex];
100
+ model.nextTaskId = progressTasks[nextIndex].taskId;
101
+ }
102
+ return model;
103
+ }
104
+
105
+ interface TaskAttemptStats {
106
+ attempts: number;
107
+ lastAttempt: TaskProgressAttempt;
108
+ }
109
+
110
+ function attemptsByTask(attempts: readonly TaskProgressAttempt[]): Map<string, TaskAttemptStats> {
111
+ const stats = new Map<string, TaskAttemptStats>();
112
+ for (const attempt of attempts) {
113
+ const existing = stats.get(attempt.taskId);
114
+ stats.set(attempt.taskId, {
115
+ attempts: (existing?.attempts ?? 0) + 1,
116
+ lastAttempt: attempt,
117
+ });
118
+ }
119
+ return stats;
120
+ }
121
+
122
+ function activeTaskIndex(tasks: readonly Task[], currentTaskId: string | undefined): number {
123
+ if (!currentTaskId) {
124
+ return -1;
125
+ }
126
+ return tasks.findIndex((task) => task.taskId === currentTaskId);
127
+ }
128
+
129
+ function taskProgressStatus(
130
+ task: Task,
131
+ lastAttempt: TaskProgressAttempt | undefined,
132
+ isActive: boolean,
133
+ currentTaskStatus: TaskProgressStatus | undefined,
134
+ ): TaskProgressStatus {
135
+ if (task.done || lastAttempt?.done || (isActive && currentTaskStatus === "completed")) {
136
+ return "completed";
137
+ }
138
+ if (isActive) {
139
+ if (currentTaskStatus === "failed" || currentTaskStatus === "blocked") {
140
+ return currentTaskStatus;
141
+ }
142
+ return "current";
143
+ }
144
+ if (lastAttempt?.reportedStatus === "blocked") {
145
+ return "blocked";
146
+ }
147
+ if (lastAttempt) {
148
+ return "failed";
149
+ }
150
+ return "pending";
151
+ }
152
+
153
+ function taskProgressPosition(index: number, activeIndex: number, status: TaskProgressStatus): TaskProgressPosition {
154
+ if (activeIndex >= 0) {
155
+ if (index < activeIndex) {
156
+ return "past";
157
+ }
158
+ if (index === activeIndex) {
159
+ return "current";
160
+ }
161
+ return "future";
162
+ }
163
+
164
+ return status === "pending" || status === "current" ? "future" : "past";
165
+ }
166
+
167
+ function taskProgressSummary(tasks: readonly TaskProgressTask[], attemptedTasks: number): TaskProgressSummary {
168
+ const totalTasks = tasks.length;
169
+ const completedTasks = tasks.filter((task) => task.status === "completed").length;
170
+ const failedTasks = tasks.filter((task) => task.status === "failed").length;
171
+ const blockedTasks = tasks.filter((task) => task.status === "blocked").length;
172
+ const pendingTasks = tasks.filter((task) => task.status === "pending").length;
173
+ const currentTasks = tasks.filter((task) => task.status === "current").length;
174
+ const completionRatio = totalTasks === 0 ? 1 : completedTasks / totalTasks;
175
+
176
+ return {
177
+ totalTasks,
178
+ completedTasks,
179
+ failedTasks,
180
+ blockedTasks,
181
+ pendingTasks,
182
+ currentTasks,
183
+ attemptedTasks,
184
+ completionRatio,
185
+ completedPercent: Math.round(completionRatio * 100),
186
+ };
187
+ }
package/src/types.ts CHANGED
@@ -1,12 +1,18 @@
1
1
  import type { Static } from "typebox";
2
2
  import { Type } from "typebox";
3
3
 
4
+ import type { TaskProgressModel } from "./task_progress.ts";
4
5
  import type { SessionOutcome } from "./worker_session.ts";
5
6
 
6
7
  export const PiLongTaskParams = Type.Object(
7
8
  {
8
- inputText: Type.String({ description: "TODO file content or long-task instructions to process." }),
9
- commit: Type.Boolean({ description: "Whether Pi Long Task may commit completed worker changes." }),
9
+ inputText: Type.String({
10
+ description: "TODO file content or the user's long-task instructions to process.",
11
+ }),
12
+ commit: Type.Boolean({
13
+ description:
14
+ "Whether Pi Long Task may commit completed worker changes. Use true when the user asks for commits or committing as work progresses; otherwise use false.",
15
+ }),
10
16
  },
11
17
  { additionalProperties: false },
12
18
  );
@@ -55,6 +61,7 @@ export interface PiLongTaskResult {
55
61
  commitError?: string;
56
62
  commitSkipped?: string;
57
63
  }>;
64
+ taskProgress: TaskProgressModel;
58
65
  commit: boolean;
59
66
  error?: string;
60
67
  }