pi-background-tasks 0.1.0 → 0.3.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/PUBLISHING.md +10 -8
- package/README.md +68 -9
- package/TESTING.md +116 -0
- package/TEST_PLAN.md +98 -0
- package/extensions/background-tasks.ts +1 -1305
- package/package.json +28 -2
- package/src/core/common.ts +397 -0
- package/src/core/registry.ts +958 -0
- package/src/extension.ts +507 -0
- package/src/testing/normalize.ts +3 -0
- package/src/ui/background-tasks-manager.ts +622 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { formatSize } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { matchesKey, truncateToWidth, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
5
|
+
import {
|
|
6
|
+
boundedRead,
|
|
7
|
+
compactWhitespace,
|
|
8
|
+
formatCompactNumber,
|
|
9
|
+
formatDuration,
|
|
10
|
+
taskDisplayName,
|
|
11
|
+
truncateChars,
|
|
12
|
+
type BgTaskSnapshot,
|
|
13
|
+
} from "../core/common.js";
|
|
14
|
+
|
|
15
|
+
export type BackgroundTaskForUi = BgTaskSnapshot & { name: string; outputAbsPath: string };
|
|
16
|
+
type BgTask = BackgroundTaskForUi;
|
|
17
|
+
|
|
18
|
+
const STATUS_INTERVAL_MS = 1000;
|
|
19
|
+
const DETAIL_TAIL_BYTES = 8 * 1024;
|
|
20
|
+
const LIST_VISIBLE_ROWS = 14;
|
|
21
|
+
const DETAIL_VISIBLE_OUTPUT_LINES = 12;
|
|
22
|
+
const LIGHT_BLUE_BG = "\x1b[48;2;183;223;255m";
|
|
23
|
+
const LIGHT_BLUE_FG = "\x1b[38;2;11;70;110m";
|
|
24
|
+
const LIGHT_BLUE_BORDER = "\x1b[38;2;83;160;215m";
|
|
25
|
+
const ANSI_RESET = "\x1b[0m";
|
|
26
|
+
|
|
27
|
+
function formatTime(timestamp: number): string {
|
|
28
|
+
return new Date(timestamp).toLocaleTimeString();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function padAnsi(value: string, width: number): string {
|
|
32
|
+
return value + " ".repeat(Math.max(0, width - visibleWidth(value)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function lightBlue(value: string): string {
|
|
36
|
+
return `${LIGHT_BLUE_BG}${LIGHT_BLUE_FG}${value}${ANSI_RESET}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function blueBorder(value: string): string {
|
|
40
|
+
return `${LIGHT_BLUE_BORDER}${value}${ANSI_RESET}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function statusLabel(status: BgTaskSnapshot["status"]): string {
|
|
44
|
+
if (status === "completed") return "done";
|
|
45
|
+
if (status === "failed") return "error";
|
|
46
|
+
if (status === "killed") return "stopped";
|
|
47
|
+
return "running";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function statusColor(theme: Theme, status: BgTaskSnapshot["status"], text = statusLabel(status)): string {
|
|
51
|
+
if (status === "completed") return theme.fg("success", text);
|
|
52
|
+
if (status === "failed") return theme.fg("error", text);
|
|
53
|
+
if (status === "killed") return theme.fg("warning", text);
|
|
54
|
+
return theme.fg("accent", text);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function taskAge(task: BgTask, now = Date.now()): string {
|
|
58
|
+
return formatDuration((task.endTime ?? now) - task.startTime);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatContextUsage(task: BgTask): string {
|
|
62
|
+
const usage = task.contextUsage;
|
|
63
|
+
if (!usage || !usage.contextWindow) return "—";
|
|
64
|
+
const window = formatCompactNumber(usage.contextWindow);
|
|
65
|
+
if (usage.percent === null || usage.tokens === null) return `?/${window}`;
|
|
66
|
+
return `${usage.percent.toFixed(1)}%/${window}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatContextDetail(task: BgTask): string {
|
|
70
|
+
const usage = task.contextUsage;
|
|
71
|
+
if (!usage || !usage.contextWindow) return "not reported by this background task";
|
|
72
|
+
const window = formatCompactNumber(usage.contextWindow);
|
|
73
|
+
if (usage.percent === null || usage.tokens === null) return `unknown tokens / ${window} window`;
|
|
74
|
+
return `${usage.percent.toFixed(1)}% of ${window} window (${formatCompactNumber(usage.tokens)} tokens)`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatTokenUsage(task: BgTask): string {
|
|
78
|
+
const usage = task.tokenUsage;
|
|
79
|
+
if (!usage || usage.totalTokens <= 0) return "";
|
|
80
|
+
return `tok ${formatCompactNumber(usage.totalTokens)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatTokenDetail(task: BgTask): string {
|
|
84
|
+
const usage = task.tokenUsage;
|
|
85
|
+
if (!usage || usage.totalTokens <= 0) return "not reported by this background task";
|
|
86
|
+
const parts = [
|
|
87
|
+
`input ${formatCompactNumber(usage.input)}`,
|
|
88
|
+
`output ${formatCompactNumber(usage.output)}`,
|
|
89
|
+
`cache read ${formatCompactNumber(usage.cacheRead)}`,
|
|
90
|
+
`cache write ${formatCompactNumber(usage.cacheWrite)}`,
|
|
91
|
+
`total ${formatCompactNumber(usage.totalTokens)}`,
|
|
92
|
+
];
|
|
93
|
+
return parts.join(" · ");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatToolUsage(task: BgTask): string {
|
|
97
|
+
const usage = task.toolUsage;
|
|
98
|
+
if (!usage || (usage.total <= 0 && usage.failed <= 0)) return "";
|
|
99
|
+
return usage.failed > 0 ? `tools ${usage.total}/${usage.failed} failed` : `tools ${usage.total}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function formatToolDetail(task: BgTask): string {
|
|
103
|
+
const usage = task.toolUsage;
|
|
104
|
+
if (!usage || (usage.total <= 0 && usage.failed <= 0)) return "not reported by this background task";
|
|
105
|
+
const byName = Object.entries(usage.byName ?? {})
|
|
106
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
107
|
+
.slice(0, 6)
|
|
108
|
+
.map(([name, count]) => `${name} ${count}`);
|
|
109
|
+
const parts = [`${usage.total} total`];
|
|
110
|
+
if (usage.failed > 0) parts.push(`${usage.failed} failed`);
|
|
111
|
+
parts.push(...byName);
|
|
112
|
+
return parts.join(" · ");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function shortModelName(model: string): string {
|
|
116
|
+
const slash = model.lastIndexOf("/");
|
|
117
|
+
return slash >= 0 ? model.slice(slash + 1) : model;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatModel(task: BgTask): string {
|
|
121
|
+
if (!task.model) return "";
|
|
122
|
+
return `model ${shortModelName(task.model)}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function formatModelDetail(task: BgTask): string {
|
|
126
|
+
if (!task.model) return "not reported by this background task";
|
|
127
|
+
return task.model;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function contextColor(theme: Theme, task: BgTask, text: string): string {
|
|
131
|
+
const percent = task.contextUsage?.percent ?? 0;
|
|
132
|
+
if (percent > 90) return theme.fg("error", text);
|
|
133
|
+
if (percent > 70) return theme.fg("warning", text);
|
|
134
|
+
return theme.fg("dim", text);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function sortTasksForUi(tasks: BgTask[]): BgTask[] {
|
|
138
|
+
const rank = (task: BgTask) => (task.status === "running" ? 0 : task.status === "failed" ? 1 : task.status === "killed" ? 2 : 3);
|
|
139
|
+
return [...tasks].sort((a, b) => {
|
|
140
|
+
const rankDiff = rank(a) - rank(b);
|
|
141
|
+
if (rankDiff !== 0) return rankDiff;
|
|
142
|
+
return (b.endTime ?? b.startTime) - (a.endTime ?? a.startTime);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type TaskManagerResult = "closed";
|
|
147
|
+
|
|
148
|
+
export type StopAllResult = {
|
|
149
|
+
stopped: number;
|
|
150
|
+
failures: string[];
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export type TaskManagerOptions = {
|
|
154
|
+
initialTaskId?: string;
|
|
155
|
+
getTasks: () => BgTask[];
|
|
156
|
+
stopTask: (task: BgTask) => Promise<void>;
|
|
157
|
+
stopAllRunning: () => Promise<StopAllResult>;
|
|
158
|
+
rerunTask: (task: BgTask) => Promise<BgTask>;
|
|
159
|
+
showOutputPath: (task: BgTask) => void;
|
|
160
|
+
markSeen: (taskId: string) => void;
|
|
161
|
+
markFinishedSeen: (taskIds: string[]) => void;
|
|
162
|
+
isSeen: (taskId: string) => boolean;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export class BackgroundTasksManager implements Component {
|
|
166
|
+
private mode: "list" | "detail" = "list";
|
|
167
|
+
private selectedIndex = 0;
|
|
168
|
+
private listScroll = 0;
|
|
169
|
+
private detailTaskId: string | undefined;
|
|
170
|
+
private showHistory = false;
|
|
171
|
+
private tailText = "";
|
|
172
|
+
private tailBytesRead = 0;
|
|
173
|
+
private tailTotalBytes = 0;
|
|
174
|
+
private tailTruncated = false;
|
|
175
|
+
private tailError: string | undefined;
|
|
176
|
+
private actionMessage: string | undefined;
|
|
177
|
+
private confirmStopAllArmed = false;
|
|
178
|
+
private readonly lastBytesByTask = new Map<string, number>();
|
|
179
|
+
private readonly recentActivityByTask = new Map<string, { delta: number; timestamp: number }>();
|
|
180
|
+
private refreshTimer: NodeJS.Timeout;
|
|
181
|
+
|
|
182
|
+
constructor(
|
|
183
|
+
private readonly tui: Pick<TUI, "requestRender">,
|
|
184
|
+
private readonly theme: Theme,
|
|
185
|
+
private readonly done: (result: TaskManagerResult) => void,
|
|
186
|
+
private readonly options: TaskManagerOptions,
|
|
187
|
+
) {
|
|
188
|
+
if (options.initialTaskId) {
|
|
189
|
+
this.detailTaskId = options.initialTaskId;
|
|
190
|
+
this.mode = "detail";
|
|
191
|
+
this.options.markSeen(options.initialTaskId);
|
|
192
|
+
void this.refreshTail();
|
|
193
|
+
} else {
|
|
194
|
+
const allTasks = options.getTasks();
|
|
195
|
+
const hasRunning = allTasks.some((task) => task.status === "running");
|
|
196
|
+
const hasFinished = allTasks.some((task) => task.status !== "running");
|
|
197
|
+
if (!hasRunning && hasFinished) this.showHistory = true;
|
|
198
|
+
}
|
|
199
|
+
this.refreshTimer = setInterval(() => {
|
|
200
|
+
if (this.mode === "detail") void this.refreshTail();
|
|
201
|
+
this.tui.requestRender();
|
|
202
|
+
}, STATUS_INTERVAL_MS);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
dispose(): void {
|
|
206
|
+
clearInterval(this.refreshTimer);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
invalidate(): void {}
|
|
210
|
+
|
|
211
|
+
handleInput(data: string): void {
|
|
212
|
+
const stopAllKey = data === "a" || data === "A" || data === "K";
|
|
213
|
+
if (!stopAllKey) this.confirmStopAllArmed = false;
|
|
214
|
+
this.actionMessage = undefined;
|
|
215
|
+
|
|
216
|
+
if (matchesKey(data, "escape") || data === "q" || data === "Q" || data === "x" || data === "X") {
|
|
217
|
+
this.close();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (this.mode === "detail") {
|
|
222
|
+
this.handleDetailInput(data);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
this.handleListInput(data);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
render(width: number): string[] {
|
|
229
|
+
const boxWidth = Math.max(2, Math.min(width, 118));
|
|
230
|
+
return this.mode === "detail" ? this.renderDetail(boxWidth) : this.renderList(boxWidth);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private close(): void {
|
|
234
|
+
this.done("closed");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private handleListInput(data: string): void {
|
|
238
|
+
const tasks = this.currentTasks();
|
|
239
|
+
if (tasks.length === 0) {
|
|
240
|
+
if (matchesKey(data, "return")) this.close();
|
|
241
|
+
if (data === "h" || data === "H") {
|
|
242
|
+
this.showHistory = !this.showHistory;
|
|
243
|
+
this.tui.requestRender();
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (matchesKey(data, "up")) {
|
|
248
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
249
|
+
this.ensureSelectionVisible();
|
|
250
|
+
this.tui.requestRender();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (matchesKey(data, "down")) {
|
|
254
|
+
this.selectedIndex = Math.min(tasks.length - 1, this.selectedIndex + 1);
|
|
255
|
+
this.ensureSelectionVisible();
|
|
256
|
+
this.tui.requestRender();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (matchesKey(data, "pageUp")) {
|
|
260
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - LIST_VISIBLE_ROWS);
|
|
261
|
+
this.ensureSelectionVisible();
|
|
262
|
+
this.tui.requestRender();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (matchesKey(data, "pageDown")) {
|
|
266
|
+
this.selectedIndex = Math.min(tasks.length - 1, this.selectedIndex + LIST_VISIBLE_ROWS);
|
|
267
|
+
this.ensureSelectionVisible();
|
|
268
|
+
this.tui.requestRender();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (matchesKey(data, "return") || matchesKey(data, "right")) {
|
|
272
|
+
const task = tasks[this.selectedIndex];
|
|
273
|
+
if (!task) return;
|
|
274
|
+
this.openDetail(task.id);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (data === "k") {
|
|
278
|
+
const task = tasks[this.selectedIndex];
|
|
279
|
+
if (task) void this.stopTaskFromUi(task);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (data === "K" || data === "a" || data === "A") {
|
|
283
|
+
void this.stopAllFromUi();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (data === "R") {
|
|
287
|
+
const task = tasks[this.selectedIndex];
|
|
288
|
+
if (task) void this.rerunTaskFromUi(task);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (data === "c" || data === "C") {
|
|
292
|
+
const task = tasks[this.selectedIndex];
|
|
293
|
+
if (task) this.showOutputPathFromUi(task);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (data === "h" || data === "H") {
|
|
297
|
+
this.showHistory = !this.showHistory;
|
|
298
|
+
this.selectedIndex = 0;
|
|
299
|
+
this.listScroll = 0;
|
|
300
|
+
this.tui.requestRender();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private handleDetailInput(data: string): void {
|
|
305
|
+
const task = this.detailTask();
|
|
306
|
+
if (matchesKey(data, "left")) {
|
|
307
|
+
this.mode = "list";
|
|
308
|
+
this.detailTaskId = undefined;
|
|
309
|
+
this.tui.requestRender();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (data === "r") {
|
|
313
|
+
void this.refreshTail();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (data === "R" && task) {
|
|
317
|
+
void this.rerunTaskFromUi(task);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (data === "k" && task) {
|
|
321
|
+
void this.stopTaskFromUi(task);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if ((data === "c" || data === "C") && task) {
|
|
325
|
+
this.showOutputPathFromUi(task);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private currentTasks(): BgTask[] {
|
|
330
|
+
const allTasks = this.options.getTasks();
|
|
331
|
+
let visible = this.showHistory ? allTasks : allTasks.filter((task) => task.status === "running");
|
|
332
|
+
if (!this.showHistory && visible.length === 0 && allTasks.some((task) => task.status !== "running")) {
|
|
333
|
+
this.showHistory = true;
|
|
334
|
+
visible = allTasks;
|
|
335
|
+
}
|
|
336
|
+
return sortTasksForUi(visible);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private detailTask(): BgTask | undefined {
|
|
340
|
+
return this.options.getTasks().find((task) => task.id === this.detailTaskId);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private openDetail(taskId: string): void {
|
|
344
|
+
this.detailTaskId = taskId;
|
|
345
|
+
this.mode = "detail";
|
|
346
|
+
this.tailText = "";
|
|
347
|
+
this.tailError = undefined;
|
|
348
|
+
this.options.markSeen(taskId);
|
|
349
|
+
void this.refreshTail();
|
|
350
|
+
this.tui.requestRender();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private ensureSelectionVisible(): void {
|
|
354
|
+
if (this.selectedIndex < this.listScroll) this.listScroll = this.selectedIndex;
|
|
355
|
+
if (this.selectedIndex >= this.listScroll + LIST_VISIBLE_ROWS) this.listScroll = this.selectedIndex - LIST_VISIBLE_ROWS + 1;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private async stopTaskFromUi(task: BgTask): Promise<void> {
|
|
359
|
+
if (task.status !== "running") {
|
|
360
|
+
this.actionMessage = `${taskDisplayName(task)} is ${task.status}; nothing to stop.`;
|
|
361
|
+
this.tui.requestRender();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
this.actionMessage = `Stopping ${taskDisplayName(task)}…`;
|
|
365
|
+
this.tui.requestRender();
|
|
366
|
+
try {
|
|
367
|
+
await this.options.stopTask(task);
|
|
368
|
+
this.actionMessage = `Stopped ${taskDisplayName(task)}.`;
|
|
369
|
+
} catch (error) {
|
|
370
|
+
this.actionMessage = `Stop failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
371
|
+
}
|
|
372
|
+
this.tui.requestRender();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private async stopAllFromUi(): Promise<void> {
|
|
376
|
+
const running = this.options.getTasks().filter((task) => task.status === "running");
|
|
377
|
+
if (running.length === 0) {
|
|
378
|
+
this.actionMessage = "No running background tasks to stop.";
|
|
379
|
+
this.tui.requestRender();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (!this.confirmStopAllArmed) {
|
|
383
|
+
this.confirmStopAllArmed = true;
|
|
384
|
+
this.actionMessage = `Press a/K again to stop all ${running.length} running task${running.length === 1 ? "" : "s"}.`;
|
|
385
|
+
this.tui.requestRender();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
this.confirmStopAllArmed = false;
|
|
390
|
+
this.actionMessage = `Stopping ${running.length} running task${running.length === 1 ? "" : "s"}…`;
|
|
391
|
+
this.tui.requestRender();
|
|
392
|
+
try {
|
|
393
|
+
const result = await this.options.stopAllRunning();
|
|
394
|
+
this.actionMessage = result.failures.length > 0
|
|
395
|
+
? `Stopped ${result.stopped}; ${result.failures.length} failed: ${result.failures.join("; ")}`
|
|
396
|
+
: `Stopped ${result.stopped} running task${result.stopped === 1 ? "" : "s"}.`;
|
|
397
|
+
} catch (error) {
|
|
398
|
+
this.actionMessage = `Stop-all failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
399
|
+
}
|
|
400
|
+
this.tui.requestRender();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private async rerunTaskFromUi(task: BgTask): Promise<void> {
|
|
404
|
+
this.actionMessage = `Rerunning ${taskDisplayName(task)}…`;
|
|
405
|
+
this.tui.requestRender();
|
|
406
|
+
try {
|
|
407
|
+
const rerun = await this.options.rerunTask(task);
|
|
408
|
+
this.showHistory = false;
|
|
409
|
+
const tasks = this.currentTasks();
|
|
410
|
+
const index = tasks.findIndex((candidate) => candidate.id === rerun.id);
|
|
411
|
+
if (index >= 0) {
|
|
412
|
+
this.selectedIndex = index;
|
|
413
|
+
this.ensureSelectionVisible();
|
|
414
|
+
}
|
|
415
|
+
this.actionMessage = `Reran as ${taskDisplayName(rerun)} (${rerun.id}).`;
|
|
416
|
+
} catch (error) {
|
|
417
|
+
this.actionMessage = `Rerun failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
418
|
+
}
|
|
419
|
+
this.tui.requestRender();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private showOutputPathFromUi(task: BgTask): void {
|
|
423
|
+
this.options.showOutputPath(task);
|
|
424
|
+
this.actionMessage = `Output path shown for ${taskDisplayName(task)}.`;
|
|
425
|
+
this.tui.requestRender();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private async refreshTail(): Promise<void> {
|
|
429
|
+
const task = this.detailTask();
|
|
430
|
+
if (!task) return;
|
|
431
|
+
try {
|
|
432
|
+
if (!existsSync(task.outputAbsPath)) {
|
|
433
|
+
this.tailText = "";
|
|
434
|
+
this.tailBytesRead = 0;
|
|
435
|
+
this.tailTotalBytes = 0;
|
|
436
|
+
this.tailTruncated = false;
|
|
437
|
+
this.tailError = `Output file not found: ${task.outputPath}`;
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const read = await boundedRead(task.outputAbsPath, DETAIL_TAIL_BYTES, true);
|
|
441
|
+
this.tailText = read.content;
|
|
442
|
+
this.tailBytesRead = read.bytesRead;
|
|
443
|
+
this.tailTotalBytes = read.totalBytes;
|
|
444
|
+
this.tailTruncated = read.truncated;
|
|
445
|
+
this.tailError = undefined;
|
|
446
|
+
} catch (error) {
|
|
447
|
+
this.tailError = `Output read failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
448
|
+
}
|
|
449
|
+
this.tui.requestRender();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private activityLabel(task: BgTask): string {
|
|
453
|
+
if (task.status !== "running") return "";
|
|
454
|
+
const now = Date.now();
|
|
455
|
+
const previous = this.lastBytesByTask.get(task.id);
|
|
456
|
+
this.lastBytesByTask.set(task.id, task.bytesWritten);
|
|
457
|
+
if (previous !== undefined && task.bytesWritten > previous) {
|
|
458
|
+
const delta = task.bytesWritten - previous;
|
|
459
|
+
this.recentActivityByTask.set(task.id, { delta, timestamp: now });
|
|
460
|
+
return `+${formatSize(delta)} ↑`;
|
|
461
|
+
}
|
|
462
|
+
const recent = this.recentActivityByTask.get(task.id);
|
|
463
|
+
if (recent && now - recent.timestamp < 3000) return `+${formatSize(recent.delta)} ↑`;
|
|
464
|
+
return "";
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private frame(title: string, subtitle: string, body: string[], footer: string, width: number): string[] {
|
|
468
|
+
const inner = Math.max(1, width - 2);
|
|
469
|
+
const top = blueBorder(`╭${"─".repeat(inner)}╮`);
|
|
470
|
+
const bottom = blueBorder(`╰${"─".repeat(inner)}╯`);
|
|
471
|
+
const row = (content = "") => `${blueBorder("│")}${padAnsi(truncateToWidth(content, inner), inner)}${blueBorder("│")}`;
|
|
472
|
+
const header = lightBlue(padAnsi(` ${title}`, inner));
|
|
473
|
+
const subtitleLine = subtitle ? lightBlue(padAnsi(` ${subtitle}`, inner)) : lightBlue(" ".repeat(inner));
|
|
474
|
+
const lines = [top, row(header), row(subtitleLine), row()];
|
|
475
|
+
for (const line of body) lines.push(row(line));
|
|
476
|
+
lines.push(row());
|
|
477
|
+
lines.push(row(footer));
|
|
478
|
+
lines.push(bottom);
|
|
479
|
+
return lines;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private renderList(width: number): string[] {
|
|
483
|
+
const tasks = this.currentTasks();
|
|
484
|
+
if (this.selectedIndex >= tasks.length) this.selectedIndex = Math.max(0, tasks.length - 1);
|
|
485
|
+
this.ensureSelectionVisible();
|
|
486
|
+
|
|
487
|
+
const allTasks = this.options.getTasks();
|
|
488
|
+
const allRunning = allTasks.filter((task) => task.status === "running").length;
|
|
489
|
+
const historyCount = allTasks.length - allRunning;
|
|
490
|
+
const unseenFailed = allTasks.filter((task) => task.status === "failed" && !this.options.isSeen(task.id)).length;
|
|
491
|
+
const unseenStopped = allTasks.filter((task) => task.status === "killed" && !this.options.isSeen(task.id)).length;
|
|
492
|
+
const unseenDone = allTasks.filter((task) => task.status === "completed" && !this.options.isSeen(task.id)).length;
|
|
493
|
+
const unread = unseenFailed + unseenStopped + unseenDone;
|
|
494
|
+
const subtitleParts = this.showHistory
|
|
495
|
+
? [`${allRunning} active`, `${historyCount} history`]
|
|
496
|
+
: allRunning > 0
|
|
497
|
+
? [`${allRunning} active shell${allRunning === 1 ? "" : "s"}`]
|
|
498
|
+
: ["No active shells"];
|
|
499
|
+
if (unseenFailed) subtitleParts.push(`${unseenFailed} failed`);
|
|
500
|
+
if (unseenStopped) subtitleParts.push(`${unseenStopped} stopped`);
|
|
501
|
+
if (unseenDone) subtitleParts.push(`${unseenDone} done`);
|
|
502
|
+
if (unread) subtitleParts.push(`${unread} unread`);
|
|
503
|
+
const subtitle = subtitleParts.join(" · ");
|
|
504
|
+
|
|
505
|
+
const body: string[] = [];
|
|
506
|
+
if (tasks.length === 0) {
|
|
507
|
+
const message = allTasks.length === 0
|
|
508
|
+
? " No background tasks in this session."
|
|
509
|
+
: this.showHistory
|
|
510
|
+
? " No background tasks in this view."
|
|
511
|
+
: " No running background tasks. Press h to show recent history.";
|
|
512
|
+
body.push(this.theme.fg("dim", message));
|
|
513
|
+
} else {
|
|
514
|
+
const maxNameWidth = Math.max(12, width - 78);
|
|
515
|
+
const visible = tasks.slice(this.listScroll, this.listScroll + LIST_VISIBLE_ROWS);
|
|
516
|
+
for (let i = 0; i < visible.length; i++) {
|
|
517
|
+
const task = visible[i];
|
|
518
|
+
if (!task) continue;
|
|
519
|
+
const index = this.listScroll + i;
|
|
520
|
+
const selected = index === this.selectedIndex;
|
|
521
|
+
const pointer = selected ? "›" : " ";
|
|
522
|
+
const unseen = task.status !== "running" && !this.options.isSeen(task.id);
|
|
523
|
+
const unreadMark = unseen ? this.theme.fg("warning", "●") : " ";
|
|
524
|
+
const rawName = truncateChars(taskDisplayName(task), maxNameWidth);
|
|
525
|
+
const name = task.status === "failed"
|
|
526
|
+
? this.theme.fg("error", rawName)
|
|
527
|
+
: task.status === "killed"
|
|
528
|
+
? this.theme.fg("warning", rawName)
|
|
529
|
+
: task.status === "completed"
|
|
530
|
+
? this.theme.fg("success", rawName)
|
|
531
|
+
: this.theme.fg("text", rawName);
|
|
532
|
+
const status = statusColor(this.theme, task.status, statusLabel(task.status));
|
|
533
|
+
const runtime = taskAge(task);
|
|
534
|
+
const size = formatSize(task.bytesWritten);
|
|
535
|
+
const context = formatContextUsage(task);
|
|
536
|
+
const contextText = ` ${contextColor(this.theme, task, `ctx ${context}`)}`;
|
|
537
|
+
const model = formatModel(task);
|
|
538
|
+
const modelText = model ? ` ${this.theme.fg("dim", model)}` : "";
|
|
539
|
+
const tokenUsage = formatTokenUsage(task);
|
|
540
|
+
const tokenText = tokenUsage ? ` ${this.theme.fg("dim", tokenUsage)}` : "";
|
|
541
|
+
const toolUsage = formatToolUsage(task);
|
|
542
|
+
const toolText = toolUsage ? ` ${this.theme.fg("dim", toolUsage)}` : "";
|
|
543
|
+
const activity = this.activityLabel(task);
|
|
544
|
+
const activityText = activity ? ` ${this.theme.fg("warning", activity)}` : "";
|
|
545
|
+
const exit = task.exitCode !== undefined && task.status !== "running" ? this.theme.fg("dim", ` exit=${task.exitCode}`) : "";
|
|
546
|
+
let row = ` ${pointer} ${unreadMark} ${name} ${this.theme.fg("dim", task.id)} ${this.theme.fg("dim", "·")} ${status}${exit} ${this.theme.fg("dim", `${runtime} ${size}`)}${contextText}${modelText}${tokenText}${toolText}${activityText}`;
|
|
547
|
+
if (selected) row = lightBlue(padAnsi(truncateToWidth(row, width - 4), width - 4));
|
|
548
|
+
body.push(row);
|
|
549
|
+
}
|
|
550
|
+
if (tasks.length > LIST_VISIBLE_ROWS) {
|
|
551
|
+
body.push(this.theme.fg("dim", ` Showing ${this.listScroll + 1}-${Math.min(tasks.length, this.listScroll + LIST_VISIBLE_ROWS)} of ${tasks.length}`));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (this.actionMessage) body.push(this.theme.fg("warning", ` ${this.actionMessage}`));
|
|
555
|
+
return this.frame(
|
|
556
|
+
"bg tasks focused",
|
|
557
|
+
subtitle,
|
|
558
|
+
body,
|
|
559
|
+
` ${this.theme.fg("dim", `↑/↓ select · Enter logs · k stop · a stop all · h ${this.showHistory ? "hide" : "show"} history · R rerun · c path · x close`)}`,
|
|
560
|
+
width,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private renderDetail(width: number): string[] {
|
|
565
|
+
const task = this.detailTask();
|
|
566
|
+
if (!task) {
|
|
567
|
+
this.mode = "list";
|
|
568
|
+
return this.renderList(width);
|
|
569
|
+
}
|
|
570
|
+
const name = taskDisplayName(task);
|
|
571
|
+
const status = statusColor(this.theme, task.status);
|
|
572
|
+
const exit = task.exitCode !== undefined ? ` exit=${task.exitCode}` : "";
|
|
573
|
+
const body: string[] = [
|
|
574
|
+
` ${this.theme.fg("toolTitle", "Name:")} ${this.theme.fg("accent", name)}`,
|
|
575
|
+
` ${this.theme.fg("toolTitle", "ID:")} ${this.theme.fg("accent", task.id)}`,
|
|
576
|
+
` ${this.theme.fg("toolTitle", "Status:")} ${status}${this.theme.fg("dim", exit)}`,
|
|
577
|
+
` ${this.theme.fg("toolTitle", "Runtime:")} ${taskAge(task)}${task.pid ? this.theme.fg("dim", ` · pid ${task.pid}`) : ""}`,
|
|
578
|
+
` ${this.theme.fg("toolTitle", "Started:")} ${formatTime(task.startTime)}${task.endTime ? this.theme.fg("dim", ` · ended ${formatTime(task.endTime)}`) : ""}`,
|
|
579
|
+
` ${this.theme.fg("toolTitle", "Output:")} ${this.theme.fg("accent", task.outputPath)}`,
|
|
580
|
+
];
|
|
581
|
+
if (task.description && compactWhitespace(task.description) !== compactWhitespace(name)) {
|
|
582
|
+
body.push(` ${this.theme.fg("toolTitle", "Description:")} ${truncateToWidth(task.description, width - 16)}`);
|
|
583
|
+
}
|
|
584
|
+
const modelDetail = formatModelDetail(task);
|
|
585
|
+
body.push(` ${this.theme.fg("toolTitle", "Model:")} ${task.model ? this.theme.fg("accent", modelDetail) : this.theme.fg("dim", modelDetail)}`);
|
|
586
|
+
const context = formatContextDetail(task);
|
|
587
|
+
body.push(` ${this.theme.fg("toolTitle", "Context:")} ${contextColor(this.theme, task, context)}`);
|
|
588
|
+
body.push(` ${this.theme.fg("toolTitle", "Tokens:")} ${this.theme.fg("dim", formatTokenDetail(task))}`);
|
|
589
|
+
body.push(` ${this.theme.fg("toolTitle", "Tools:")} ${this.theme.fg("dim", formatToolDetail(task))}`);
|
|
590
|
+
body.push(` ${this.theme.fg("toolTitle", "Command:")} ${truncateToWidth(task.command, width - 13)}`);
|
|
591
|
+
if (task.error) body.push(` ${this.theme.fg("error", `Error: ${task.error}`)}`);
|
|
592
|
+
body.push("", ` ${this.theme.fg("toolTitle", "Output tail:")}`);
|
|
593
|
+
body.push(...this.renderOutputBox(width - 4));
|
|
594
|
+
if (this.actionMessage) body.push(this.theme.fg("warning", ` ${this.actionMessage}`));
|
|
595
|
+
const subtitle = `${task.id} · ${task.status === "running" ? "live tail refreshes every second" : "final output"}`;
|
|
596
|
+
const footer = ` ${this.theme.fg("dim", "← list · r refresh · k stop · R rerun · c path · x close")}`;
|
|
597
|
+
return this.frame(`bg: ${truncateChars(name, 64)}`, subtitle, body, footer, width);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private renderOutputBox(width: number): string[] {
|
|
601
|
+
const inner = Math.max(1, width - 2);
|
|
602
|
+
const top = ` ${blueBorder(`╭${"─".repeat(inner)}╮`)}`;
|
|
603
|
+
const bottom = ` ${blueBorder(`╰${"─".repeat(inner)}╯`)}`;
|
|
604
|
+
const row = (content = "") => ` ${blueBorder("│")}${padAnsi(truncateToWidth(content, inner), inner)}${blueBorder("│")}`;
|
|
605
|
+
const lines = [top];
|
|
606
|
+
if (this.tailError) {
|
|
607
|
+
lines.push(row(this.theme.fg("error", this.tailError)));
|
|
608
|
+
} else if (!this.tailText) {
|
|
609
|
+
lines.push(row(this.theme.fg("dim", "No output yet")));
|
|
610
|
+
} else {
|
|
611
|
+
const outputLines = this.tailText.replace(/\r/g, "").split("\n").filter((line, index, array) => line.length > 0 || index < array.length - 1);
|
|
612
|
+
const visible = outputLines.slice(-DETAIL_VISIBLE_OUTPUT_LINES);
|
|
613
|
+
for (const line of visible) lines.push(row(this.theme.fg("toolOutput", line)));
|
|
614
|
+
}
|
|
615
|
+
while (lines.length < DETAIL_VISIBLE_OUTPUT_LINES + 1) lines.push(row());
|
|
616
|
+
lines.push(bottom);
|
|
617
|
+
const suffix = this.tailTruncated ? ` of ${formatSize(this.tailTotalBytes)}` : "";
|
|
618
|
+
lines.push(` ${this.theme.fg("dim", `Showing tail ${formatSize(this.tailBytesRead)}${suffix}`)}`);
|
|
619
|
+
return lines;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|