pi-background-tasks 0.6.0 → 0.7.2

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