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 +27 -24
- package/package.json +1 -1
- package/src/index.ts +364 -18
- package/src/render.ts +4 -236
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,
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
103
|
-
Worker TODO 2 — Add parser tests
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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. **
|
|
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
|
-
- **
|
|
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
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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,
|
|
2
|
+
import { Text, type Component } from "@earendil-works/pi-tui";
|
|
3
3
|
|
|
4
|
-
import type { TaskProgressModel
|
|
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
|
-
|
|
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
|
-
|
|
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);
|