pi-simple-background-tasks 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,852 @@
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 {
5
+ type Component,
6
+ matchesKey,
7
+ type TUI,
8
+ truncateToWidth,
9
+ visibleWidth,
10
+ wrapTextWithAnsi,
11
+ } from '@earendil-works/pi-tui';
12
+ import {
13
+ type BgTaskSnapshot,
14
+ boundedRead,
15
+ compactWhitespace,
16
+ formatDuration,
17
+ taskDisplayName,
18
+ truncateChars,
19
+ } from '../core/common.js';
20
+
21
+ export interface TaskManagerTheme {
22
+ fg: Theme['fg'];
23
+ }
24
+
25
+ export type BackgroundTaskForUi = BgTaskSnapshot & { name: string; outputAbsPath: string };
26
+ type BgTask = BackgroundTaskForUi;
27
+
28
+ const STATUS_INTERVAL_MS = 1000;
29
+ // Detail-view scrollback reservoir. Larger than the model-facing log cap because
30
+ // this is a UI-only read; a local 128KiB tail read per second is negligible and
31
+ // gives thousands of lines of scrollback when the user pauses the live tail.
32
+ const DETAIL_TAIL_BYTES = 128 * 1024;
33
+ const LIST_VISIBLE_ROWS = 14;
34
+ const DETAIL_VISIBLE_OUTPUT_LINES = 12;
35
+ // Fullscreen frame margins: 1 line/column on each side, plus extra space at the
36
+ // bottom so the terminal dock (input editor + footer) stays visible.
37
+ export const FULLSCREEN_MARGINS = { top: 1, left: 1, right: 1, bottom: 6 } as const;
38
+ // The terminal must be bigger than the modal's largest normal size before
39
+ // fullscreen is allowed (content = terminal size minus FULLSCREEN_MARGINS).
40
+ export const FULLSCREEN_MIN_CONTENT_WIDTH = 100;
41
+ export const FULLSCREEN_MIN_CONTENT_HEIGHT = 34;
42
+ const LIGHT_BLUE_BG = '\x1b[48;2;183;223;255m';
43
+ const LIGHT_BLUE_FG = '\x1b[38;2;11;70;110m';
44
+ const LIGHT_BLUE_BORDER = '\x1b[38;2;83;160;215m';
45
+ const ANSI_RESET = '\x1b[0m';
46
+
47
+ function formatTime(timestamp: number): string {
48
+ return new Date(timestamp).toLocaleTimeString();
49
+ }
50
+
51
+ function formatCount(value: number): string {
52
+ return value.toString();
53
+ }
54
+
55
+ function pluralSuffix(count: number): '' | 's' {
56
+ return count === 1 ? '' : 's';
57
+ }
58
+
59
+ function formatExitCodeText(exitCode: BgTask['exitCode']): string {
60
+ return typeof exitCode === 'number' ? ` exit=${formatCount(exitCode)}` : '';
61
+ }
62
+
63
+ function formatPidText(pid: BgTask['pid']): string {
64
+ return typeof pid === 'number' ? ` · pid ${formatCount(pid)}` : '';
65
+ }
66
+
67
+ /** Normalize raw output bytes into display lines, dropping only the trailing empty line from a final newline. */
68
+ function toOutputLines(content: string): string[] {
69
+ return content
70
+ .replace(/\r/g, '')
71
+ .split('\n')
72
+ .filter((line, index, array) => line.length > 0 || index < array.length - 1);
73
+ }
74
+
75
+ function padAnsi(value: string, width: number): string {
76
+ return value + ' '.repeat(Math.max(0, width - visibleWidth(value)));
77
+ }
78
+
79
+ function lightBlue(value: string): string {
80
+ return `${LIGHT_BLUE_BG}${LIGHT_BLUE_FG}${value}${ANSI_RESET}`;
81
+ }
82
+
83
+ function blueBorder(value: string): string {
84
+ return `${LIGHT_BLUE_BORDER}${value}${ANSI_RESET}`;
85
+ }
86
+
87
+ function statusLabel(status: BgTaskSnapshot['status']): string {
88
+ if (status === 'completed') return 'done';
89
+ if (status === 'failed') return 'error';
90
+ if (status === 'killed') return 'stopped';
91
+ return 'running';
92
+ }
93
+
94
+ function statusColor(
95
+ theme: TaskManagerTheme,
96
+ status: BgTaskSnapshot['status'],
97
+ text = statusLabel(status),
98
+ ): string {
99
+ if (status === 'completed') return theme.fg('success', text);
100
+ if (status === 'failed') return theme.fg('error', text);
101
+ if (status === 'killed') return theme.fg('warning', text);
102
+ return theme.fg('accent', text);
103
+ }
104
+
105
+ function taskAge(task: BgTask, now = Date.now()): string {
106
+ return formatDuration((task.endTime ?? now) - task.startTime);
107
+ }
108
+
109
+ function sortTasksForUi(tasks: BgTask[]): BgTask[] {
110
+ const rank = (task: BgTask) =>
111
+ task.status === 'running' ? 0 : task.status === 'failed' ? 1 : task.status === 'killed' ? 2 : 3;
112
+ return [...tasks].sort((a, b) => {
113
+ const rankDiff = rank(a) - rank(b);
114
+ if (rankDiff !== 0) return rankDiff;
115
+ return (b.endTime ?? b.startTime) - (a.endTime ?? a.startTime);
116
+ });
117
+ }
118
+
119
+ export type TaskManagerResult = 'closed';
120
+
121
+ export interface StopAllResult {
122
+ stopped: number;
123
+ failures: string[];
124
+ }
125
+
126
+ export interface TaskManagerOptions {
127
+ initialTaskId?: string;
128
+ getTasks: () => BgTask[];
129
+ stopTask: (task: BgTask) => Promise<void>;
130
+ stopAllRunning: () => Promise<StopAllResult>;
131
+ rerunTask: (task: BgTask) => Promise<BgTask>;
132
+ showOutputPath: (task: BgTask) => void;
133
+ deleteTask: (task: BgTask) => Promise<void>;
134
+ onFullscreenToggle?: (fullscreen: boolean) => void;
135
+ onClose?: () => void;
136
+ }
137
+
138
+ export class BackgroundTasksManager implements Component {
139
+ private mode: 'list' | 'detail' = 'list';
140
+ private selectedIndex = 0;
141
+ private listScroll = 0;
142
+ private detailTaskId: string | undefined;
143
+ private showHistory = false;
144
+ private detailLines: string[] = [];
145
+ private detailFollow = true;
146
+ private detailScrollTop = 0;
147
+ private outputRows = DETAIL_VISIBLE_OUTPUT_LINES;
148
+ private fullscreen = false;
149
+ private wrapOutput = false;
150
+ private lastOutputInnerWidth = 1;
151
+ private listVisibleRows = LIST_VISIBLE_ROWS;
152
+ private outputDisplayLines: string[] = [];
153
+ private tailBytesRead = 0;
154
+ private tailTotalBytes = 0;
155
+ private tailTruncated = false;
156
+ private tailError: string | undefined;
157
+ private actionMessage: string | undefined;
158
+ private confirmStopAllArmed = false;
159
+ private readonly lastBytesByTask = new Map<string, number>();
160
+ private readonly recentActivityByTask = new Map<string, { delta: number; timestamp: number }>();
161
+ private refreshTimer: NodeJS.Timeout;
162
+
163
+ constructor(
164
+ private readonly tui: Pick<TUI, 'requestRender'>,
165
+ private readonly theme: TaskManagerTheme,
166
+ private readonly done: (result: TaskManagerResult) => void,
167
+ private readonly options: TaskManagerOptions,
168
+ ) {
169
+ if (options.initialTaskId) {
170
+ this.detailTaskId = options.initialTaskId;
171
+ this.mode = 'detail';
172
+ void this.refreshTail();
173
+ } else {
174
+ const allTasks = options.getTasks();
175
+ const hasRunning = allTasks.some((task) => task.status === 'running');
176
+ const hasFinished = allTasks.some((task) => task.status !== 'running');
177
+ if (!hasRunning && hasFinished) this.showHistory = true;
178
+ }
179
+ this.refreshTimer = setInterval(() => {
180
+ if (this.mode === 'detail') void this.refreshTail();
181
+ this.tui.requestRender();
182
+ }, STATUS_INTERVAL_MS);
183
+ }
184
+
185
+ dispose(): void {
186
+ clearInterval(this.refreshTimer);
187
+ }
188
+
189
+ invalidate(): void {
190
+ this.tui.requestRender();
191
+ }
192
+
193
+ handleInput(data: string): void {
194
+ const stopAllKey = data === 'a' || data === 'A' || data === 'K';
195
+ if (!stopAllKey) this.confirmStopAllArmed = false;
196
+ this.actionMessage = undefined;
197
+
198
+ if (
199
+ matchesKey(data, 'escape') ||
200
+ data === 'q' ||
201
+ data === 'Q' ||
202
+ data === 'x' ||
203
+ data === 'X'
204
+ ) {
205
+ this.close();
206
+ return;
207
+ }
208
+
209
+ if (data === 'f' || data === 'F') {
210
+ this.toggleFullscreen();
211
+ return;
212
+ }
213
+
214
+ if (this.mode === 'detail') {
215
+ this.handleDetailInput(data);
216
+ return;
217
+ }
218
+ this.handleListInput(data);
219
+ }
220
+ render(width: number): string[] {
221
+ if (this.fullscreen && !this.canFullscreen()) this.setFullscreen(false);
222
+ const boxWidth = Math.max(2, this.fullscreen ? width : Math.min(width, 118));
223
+ return this.mode === 'detail' ? this.renderDetail(boxWidth) : this.renderList(boxWidth);
224
+ }
225
+
226
+ private close(): void {
227
+ this.options.onClose?.();
228
+ this.done('closed');
229
+ }
230
+
231
+ private handleListInput(data: string): void {
232
+ const tasks = this.currentTasks();
233
+ if (tasks.length === 0) {
234
+ if (matchesKey(data, 'return')) this.close();
235
+ if (data === 'h' || data === 'H') {
236
+ this.showHistory = !this.showHistory;
237
+ this.tui.requestRender();
238
+ }
239
+ return;
240
+ }
241
+ if (matchesKey(data, 'up')) {
242
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
243
+ this.ensureSelectionVisible();
244
+ this.tui.requestRender();
245
+ return;
246
+ }
247
+ if (matchesKey(data, 'down')) {
248
+ this.selectedIndex = Math.min(tasks.length - 1, this.selectedIndex + 1);
249
+ this.ensureSelectionVisible();
250
+ this.tui.requestRender();
251
+ return;
252
+ }
253
+ if (matchesKey(data, 'pageUp')) {
254
+ this.selectedIndex = Math.max(0, this.selectedIndex - this.listVisibleRows);
255
+ this.ensureSelectionVisible();
256
+ this.tui.requestRender();
257
+ return;
258
+ }
259
+ if (matchesKey(data, 'pageDown')) {
260
+ this.selectedIndex = Math.min(tasks.length - 1, this.selectedIndex + this.listVisibleRows);
261
+ this.ensureSelectionVisible();
262
+ this.tui.requestRender();
263
+ return;
264
+ }
265
+ if (matchesKey(data, 'return') || matchesKey(data, 'right')) {
266
+ const task = tasks[this.selectedIndex];
267
+ if (!task) return;
268
+ this.openDetail(task.id);
269
+ return;
270
+ }
271
+ if (data === 'k') {
272
+ const task = tasks[this.selectedIndex];
273
+ if (task) void this.stopTaskFromUi(task);
274
+ return;
275
+ }
276
+ if (data === 'K' || data === 'a' || data === 'A') {
277
+ void this.stopAllFromUi();
278
+ return;
279
+ }
280
+ if (data === 'R') {
281
+ const task = tasks[this.selectedIndex];
282
+ if (task) void this.rerunTaskFromUi(task);
283
+ return;
284
+ }
285
+ if (data === 'c' || data === 'C') {
286
+ const task = tasks[this.selectedIndex];
287
+ if (task) this.showOutputPathFromUi(task);
288
+ return;
289
+ }
290
+ if (data === 'd' || data === 'D') {
291
+ const task = tasks[this.selectedIndex];
292
+ if (task) void this.deleteTaskFromUi(task);
293
+ return;
294
+ }
295
+ if (data === 'h' || data === 'H') {
296
+ this.showHistory = !this.showHistory;
297
+ this.selectedIndex = 0;
298
+ this.listScroll = 0;
299
+ this.tui.requestRender();
300
+ }
301
+ }
302
+
303
+ private handleDetailInput(data: string): void {
304
+ const task = this.detailTask();
305
+ if (matchesKey(data, 'left')) {
306
+ this.mode = 'list';
307
+ this.detailTaskId = undefined;
308
+ this.tui.requestRender();
309
+ return;
310
+ }
311
+ if (matchesKey(data, 'up')) {
312
+ this.scrollDetail(-1);
313
+ return;
314
+ }
315
+ if (matchesKey(data, 'down')) {
316
+ this.scrollDetail(1);
317
+ return;
318
+ }
319
+ if (matchesKey(data, 'pageUp')) {
320
+ this.scrollDetail(-this.outputRows);
321
+ return;
322
+ }
323
+ if (matchesKey(data, 'pageDown')) {
324
+ this.scrollDetail(this.outputRows);
325
+ return;
326
+ }
327
+ if (data === 'r') {
328
+ this.detailFollow = true;
329
+ this.detailScrollTop = 0;
330
+ void this.refreshTail();
331
+ return;
332
+ }
333
+
334
+ if (data === 'w' || data === 'W') {
335
+ this.toggleWrap();
336
+ return;
337
+ }
338
+ if (data === 'R' && task) {
339
+ void this.rerunTaskFromUi(task);
340
+ return;
341
+ }
342
+ if (data === 'k' && task) {
343
+ void this.stopTaskFromUi(task);
344
+ return;
345
+ }
346
+ if (data === 'd' || data === 'D') {
347
+ if (task) void this.deleteTaskFromUi(task);
348
+ return;
349
+ }
350
+ if ((data === 'c' || data === 'C') && task) {
351
+ this.showOutputPathFromUi(task);
352
+ }
353
+ }
354
+
355
+ private currentTasks(): BgTask[] {
356
+ const allTasks = this.options.getTasks();
357
+ let visible = this.showHistory
358
+ ? allTasks
359
+ : allTasks.filter((task) => task.status === 'running');
360
+ if (
361
+ !this.showHistory &&
362
+ visible.length === 0 &&
363
+ allTasks.some((task) => task.status !== 'running')
364
+ ) {
365
+ this.showHistory = true;
366
+ visible = allTasks;
367
+ }
368
+ return sortTasksForUi(visible);
369
+ }
370
+
371
+ private detailTask(): BgTask | undefined {
372
+ return this.options.getTasks().find((task) => task.id === this.detailTaskId);
373
+ }
374
+
375
+ private openDetail(taskId: string): void {
376
+ this.detailTaskId = taskId;
377
+ this.mode = 'detail';
378
+ this.detailLines = [];
379
+ this.detailFollow = true;
380
+ this.detailScrollTop = 0;
381
+ this.tailError = undefined;
382
+ void this.refreshTail();
383
+ this.tui.requestRender();
384
+ }
385
+
386
+ private ensureSelectionVisible(): void {
387
+ if (this.selectedIndex < this.listScroll) this.listScroll = this.selectedIndex;
388
+ if (this.selectedIndex >= this.listScroll + this.listVisibleRows)
389
+ this.listScroll = this.selectedIndex - this.listVisibleRows + 1;
390
+ }
391
+
392
+ private async stopTaskFromUi(task: BgTask): Promise<void> {
393
+ if (task.status !== 'running') {
394
+ this.actionMessage = `${taskDisplayName(task)} is ${task.status}; nothing to stop.`;
395
+ this.tui.requestRender();
396
+ return;
397
+ }
398
+ this.actionMessage = `Stopping ${taskDisplayName(task)}…`;
399
+ this.tui.requestRender();
400
+ try {
401
+ await this.options.stopTask(task);
402
+ this.actionMessage = `Stopped ${taskDisplayName(task)}.`;
403
+ } catch (error) {
404
+ this.actionMessage = `Stop failed: ${error instanceof Error ? error.message : String(error)}`;
405
+ }
406
+ this.tui.requestRender();
407
+ }
408
+
409
+ private async deleteTaskFromUi(task: BgTask): Promise<void> {
410
+ if (task.status === 'running') {
411
+ this.actionMessage = `${taskDisplayName(task)} is running; stop it first (k).`;
412
+ this.tui.requestRender();
413
+ return;
414
+ }
415
+ this.actionMessage = `Deleting ${taskDisplayName(task)}…`;
416
+ this.tui.requestRender();
417
+ try {
418
+ await this.options.deleteTask(task);
419
+ const tasks = this.currentTasks();
420
+ if (this.selectedIndex >= tasks.length) this.selectedIndex = Math.max(0, tasks.length - 1);
421
+ this.actionMessage = `Deleted ${taskDisplayName(task)}.`;
422
+ } catch (error) {
423
+ this.actionMessage = `Delete failed: ${error instanceof Error ? error.message : String(error)}`;
424
+ }
425
+ this.tui.requestRender();
426
+ }
427
+
428
+ private async stopAllFromUi(): Promise<void> {
429
+ const running = this.options.getTasks().filter((task) => task.status === 'running');
430
+ if (running.length === 0) {
431
+ this.actionMessage = 'No running background tasks to stop.';
432
+ this.tui.requestRender();
433
+ return;
434
+ }
435
+ if (!this.confirmStopAllArmed) {
436
+ this.confirmStopAllArmed = true;
437
+ this.actionMessage = `Press a/K again to stop all ${formatCount(running.length)} running task${pluralSuffix(running.length)}.`;
438
+ this.tui.requestRender();
439
+ return;
440
+ }
441
+
442
+ this.confirmStopAllArmed = false;
443
+ this.actionMessage = `Stopping ${formatCount(running.length)} running task${pluralSuffix(running.length)}…`;
444
+ this.tui.requestRender();
445
+ try {
446
+ const result = await this.options.stopAllRunning();
447
+ this.actionMessage =
448
+ result.failures.length > 0
449
+ ? `Stopped ${formatCount(result.stopped)}; ${formatCount(result.failures.length)} failed: ${result.failures.join('; ')}`
450
+ : `Stopped ${formatCount(result.stopped)} running task${pluralSuffix(result.stopped)}.`;
451
+ } catch (error) {
452
+ this.actionMessage = `Stop-all failed: ${error instanceof Error ? error.message : String(error)}`;
453
+ }
454
+ this.tui.requestRender();
455
+ }
456
+
457
+ private async rerunTaskFromUi(task: BgTask): Promise<void> {
458
+ this.actionMessage = `Rerunning ${taskDisplayName(task)}…`;
459
+ this.tui.requestRender();
460
+ try {
461
+ const rerun = await this.options.rerunTask(task);
462
+ this.showHistory = false;
463
+ const tasks = this.currentTasks();
464
+ const index = tasks.findIndex((candidate) => candidate.id === rerun.id);
465
+ if (index >= 0) {
466
+ this.selectedIndex = index;
467
+ this.ensureSelectionVisible();
468
+ }
469
+ this.actionMessage = `Reran as ${taskDisplayName(rerun)} (${rerun.id}).`;
470
+ } catch (error) {
471
+ this.actionMessage = `Rerun failed: ${error instanceof Error ? error.message : String(error)}`;
472
+ }
473
+ this.tui.requestRender();
474
+ }
475
+
476
+ private showOutputPathFromUi(task: BgTask): void {
477
+ this.options.showOutputPath(task);
478
+ this.actionMessage = `Output path shown for ${taskDisplayName(task)}.`;
479
+ this.tui.requestRender();
480
+ }
481
+
482
+ /** Scroll the detail output window. Scrolling up pauses the live tail; reaching the bottom resumes it. */
483
+ private scrollDetail(delta: number): void {
484
+ const maxTop = Math.max(0, this.outputDisplayLines.length - this.outputRows);
485
+ if (maxTop === 0) return;
486
+ if (this.detailFollow) {
487
+ this.detailFollow = false;
488
+ this.detailScrollTop = maxTop;
489
+ }
490
+ this.detailScrollTop = Math.min(maxTop, Math.max(0, this.detailScrollTop + delta));
491
+ if (this.detailScrollTop >= maxTop) {
492
+ this.detailFollow = true;
493
+ this.detailScrollTop = 0;
494
+ void this.refreshTail();
495
+ }
496
+ this.tui.requestRender();
497
+ }
498
+
499
+ private async refreshTail(): Promise<void> {
500
+ const task = this.detailTask();
501
+ if (!task) return;
502
+ // While the user has scrolled up, the buffer is frozen so their view stays stable.
503
+ if (!this.detailFollow) return;
504
+ try {
505
+ if (!existsSync(task.outputAbsPath)) {
506
+ this.detailLines = [];
507
+ this.tailBytesRead = 0;
508
+ this.tailTotalBytes = 0;
509
+ this.tailTruncated = false;
510
+ this.tailError = `Output file not found: ${task.outputPath}`;
511
+ this.tui.requestRender();
512
+ return;
513
+ }
514
+ const read = await boundedRead(task.outputAbsPath, DETAIL_TAIL_BYTES, true);
515
+ this.detailLines = toOutputLines(read.content);
516
+ this.tailBytesRead = read.bytesRead;
517
+ this.tailTotalBytes = read.totalBytes;
518
+ this.tailTruncated = read.truncated;
519
+ this.tailError = undefined;
520
+ } catch (error) {
521
+ this.tailError = `Output read failed: ${error instanceof Error ? error.message : String(error)}`;
522
+ }
523
+ this.tui.requestRender();
524
+ }
525
+
526
+ private activityLabel(task: BgTask): string {
527
+ if (task.status !== 'running') return '';
528
+ const now = Date.now();
529
+ const previous = this.lastBytesByTask.get(task.id);
530
+ this.lastBytesByTask.set(task.id, task.bytesWritten);
531
+ if (previous !== undefined && task.bytesWritten > previous) {
532
+ const delta = task.bytesWritten - previous;
533
+ this.recentActivityByTask.set(task.id, { delta, timestamp: now });
534
+ return `+${formatSize(delta)} ↑`;
535
+ }
536
+ const recent = this.recentActivityByTask.get(task.id);
537
+ if (recent && now - recent.timestamp < 3000) return `+${formatSize(recent.delta)} ↑`;
538
+ return '';
539
+ }
540
+
541
+ private frame(subtitle: string, body: string[], footer: string, width: number): string[] {
542
+ const inner = Math.max(1, width - 2);
543
+ const top = blueBorder(`╭${'─'.repeat(inner)}╮`);
544
+ const bottom = blueBorder(`╰${'─'.repeat(inner)}╯`);
545
+ const row = (content = '') =>
546
+ `${blueBorder('│')}${padAnsi(truncateToWidth(content, inner), inner)}${blueBorder('│')}`;
547
+ const subtitleLine = subtitle
548
+ ? lightBlue(padAnsi(` ${subtitle}`, inner))
549
+ : lightBlue(' '.repeat(inner));
550
+ const lines = [top, row(subtitleLine), row()];
551
+ for (const line of body) lines.push(row(line));
552
+ lines.push(row());
553
+ const footerWidth = Math.max(1, inner - 1);
554
+ for (const line of wrapTextWithAnsi(footer, footerWidth)) lines.push(row(` ${line}`));
555
+ lines.push(bottom);
556
+ return lines;
557
+ }
558
+
559
+ /** Contextual action for the selected task: stop while running, delete once it ends. */
560
+ private taskActionLabel(task: BgTask | undefined): string {
561
+ if (!task) return '';
562
+ return task.status === 'running' ? 'k stop' : 'd delete';
563
+ }
564
+
565
+ private listHelp(selected: BgTask | undefined, allRunning: number, historyCount: number): string {
566
+ const hasSelection = selected !== undefined;
567
+ const parts: string[] = [];
568
+ if (hasSelection) parts.push('↑/↓ select', 'Enter logs', this.taskActionLabel(selected));
569
+ if (allRunning > 0) parts.push('a stop all');
570
+ if (historyCount > 0) parts.push(`h ${this.showHistory ? 'hide' : 'show'} history`);
571
+ if (hasSelection) parts.push('R rerun', 'c path');
572
+ parts.push('f fullscreen');
573
+ parts.push('x close');
574
+ return parts.filter((part) => part.length > 0).join(' · ');
575
+ }
576
+
577
+ private detailHelp(task: BgTask): string {
578
+ const wrapHint = this.wrapOutput ? 'w truncate' : 'w wrap';
579
+ const parts = [
580
+ '↑/↓ scroll',
581
+ '← list',
582
+ 'r refresh',
583
+ wrapHint,
584
+ 'f fullscreen',
585
+ this.taskActionLabel(task),
586
+ 'R rerun',
587
+ 'c path',
588
+ 'x close',
589
+ ];
590
+ return parts.join(' · ');
591
+ }
592
+
593
+ private renderList(width: number): string[] {
594
+ const tasks = this.currentTasks();
595
+ if (this.selectedIndex >= tasks.length) this.selectedIndex = Math.max(0, tasks.length - 1);
596
+ this.ensureSelectionVisible();
597
+
598
+ const allTasks = this.options.getTasks();
599
+ const allRunning = allTasks.filter((task) => task.status === 'running').length;
600
+ const historyCount = allTasks.length - allRunning;
601
+ const subtitleParts = this.showHistory
602
+ ? [`${formatCount(allRunning)} active`, `${formatCount(historyCount)} history`]
603
+ : allRunning > 0
604
+ ? [`${formatCount(allRunning)} active shell${pluralSuffix(allRunning)}`]
605
+ : ['No active shells'];
606
+ const subtitle = subtitleParts.join(' · ');
607
+
608
+ const body: string[] = [];
609
+ const footerLines = wrapTextWithAnsi(
610
+ this.theme.fg('dim', this.listHelp(tasks[this.selectedIndex], allRunning, historyCount)),
611
+ Math.max(1, width - 3),
612
+ ).length;
613
+ let listRows = this.computeListRows(footerLines, false, !!this.actionMessage);
614
+ if (tasks.length > listRows)
615
+ listRows = this.computeListRows(footerLines, true, !!this.actionMessage);
616
+ this.listVisibleRows = listRows;
617
+ if (tasks.length === 0) {
618
+ const message =
619
+ allTasks.length === 0
620
+ ? ' No background tasks in this session.'
621
+ : this.showHistory
622
+ ? ' No background tasks in this view.'
623
+ : ' No running background tasks. Press h to show recent history.';
624
+ body.push(this.theme.fg('dim', message));
625
+ } else {
626
+ const maxNameWidth = Math.max(12, width - 78);
627
+ const visible = tasks.slice(this.listScroll, this.listScroll + this.listVisibleRows);
628
+ for (let i = 0; i < visible.length; i++) {
629
+ const task = visible[i];
630
+ if (!task) continue;
631
+ const index = this.listScroll + i;
632
+ const selected = index === this.selectedIndex;
633
+ const pointer = selected ? '›' : ' ';
634
+ const rawName = truncateChars(taskDisplayName(task), maxNameWidth);
635
+ const name =
636
+ task.status === 'failed'
637
+ ? this.theme.fg('error', rawName)
638
+ : task.status === 'killed'
639
+ ? this.theme.fg('warning', rawName)
640
+ : task.status === 'completed'
641
+ ? this.theme.fg('success', rawName)
642
+ : this.theme.fg('text', rawName);
643
+ const status = statusColor(this.theme, task.status, statusLabel(task.status));
644
+ const runtime = taskAge(task);
645
+ const size = formatSize(task.bytesWritten);
646
+ const activity = this.activityLabel(task);
647
+ const activityText = activity ? ` ${this.theme.fg('warning', activity)}` : '';
648
+ const exit =
649
+ task.status !== 'running' ? this.theme.fg('dim', formatExitCodeText(task.exitCode)) : '';
650
+ let row = ` ${pointer} ${name} ${this.theme.fg('dim', task.id)} ${this.theme.fg('dim', '·')} ${status}${exit} ${this.theme.fg('dim', `${runtime} ${size}`)}${activityText}`;
651
+ if (selected) row = lightBlue(padAnsi(truncateToWidth(row, width - 4), width - 4));
652
+ body.push(row);
653
+ }
654
+ if (tasks.length > this.listVisibleRows) {
655
+ const firstVisible = formatCount(this.listScroll + 1);
656
+ const lastVisible = formatCount(
657
+ Math.min(tasks.length, this.listScroll + this.listVisibleRows),
658
+ );
659
+ const totalVisible = formatCount(tasks.length);
660
+ body.push(
661
+ this.theme.fg('dim', ` Showing ${firstVisible}-${lastVisible} of ${totalVisible}`),
662
+ );
663
+ }
664
+ }
665
+ if (this.actionMessage) body.push(this.theme.fg('warning', ` ${this.actionMessage}`));
666
+ return this.frame(
667
+ subtitle,
668
+ body,
669
+ this.theme.fg('dim', this.listHelp(tasks[this.selectedIndex], allRunning, historyCount)),
670
+ width,
671
+ );
672
+ }
673
+
674
+ private renderDetail(width: number): string[] {
675
+ const task = this.detailTask();
676
+ if (!task) {
677
+ this.mode = 'list';
678
+ return this.renderList(width);
679
+ }
680
+ const name = taskDisplayName(task);
681
+ const status = statusColor(this.theme, task.status);
682
+ const exit = formatExitCodeText(task.exitCode);
683
+ const hasDescription =
684
+ !!task.description && compactWhitespace(task.description) !== compactWhitespace(name);
685
+ this.lastOutputInnerWidth = Math.max(1, width - 6);
686
+ const description = task.description ?? '';
687
+ const body: string[] = [
688
+ ...(hasDescription
689
+ ? [
690
+ ` ${this.theme.fg('toolTitle', 'Description:')} ${truncateToWidth(
691
+ description,
692
+ width - 16,
693
+ )}`,
694
+ ]
695
+ : []),
696
+ ` ${this.theme.fg('toolTitle', 'Status:')} ${status}${this.theme.fg('dim', exit)}`,
697
+ ` ${this.theme.fg('toolTitle', 'Runtime:')} ${taskAge(task)}${this.theme.fg('dim', formatPidText(task.pid))}`,
698
+ ` ${this.theme.fg('toolTitle', 'Started:')} ${formatTime(task.startTime)}${
699
+ task.endTime ? this.theme.fg('dim', ` · ended ${formatTime(task.endTime)}`) : ''
700
+ }`,
701
+ ` ${this.theme.fg('toolTitle', 'Output:')} ${this.theme.fg('accent', task.outputPath)}`,
702
+ ` ${this.theme.fg('toolTitle', 'Command:')} ${truncateToWidth(task.command, width - 13)}`,
703
+ ];
704
+ if (task.error) body.push(` ${this.theme.fg('error', `Error: ${task.error}`)}`);
705
+ body.push('', ` ${this.theme.fg('toolTitle', 'Output tail:')}`);
706
+ const footerLines = wrapTextWithAnsi(
707
+ this.theme.fg('dim', this.detailHelp(task)),
708
+ Math.max(1, width - 3),
709
+ ).length;
710
+ this.outputRows = this.computeOutputRows(
711
+ footerLines,
712
+ hasDescription,
713
+ !!task.error,
714
+ !!this.actionMessage,
715
+ );
716
+ this.outputDisplayLines = this.detailLines.flatMap((line) =>
717
+ this.wrapOutput ? wrapTextWithAnsi(line, this.lastOutputInnerWidth) : [line],
718
+ );
719
+ body.push(...this.renderOutputBox(width - 4));
720
+ if (this.actionMessage) body.push(this.theme.fg('warning', ` ${this.actionMessage}`));
721
+ const subtitle = `${task.id} · ${name}`;
722
+ const footer = this.theme.fg('dim', this.detailHelp(task));
723
+ return this.frame(subtitle, body, footer, width);
724
+ }
725
+
726
+ /** Output-box rows that fit the terminal. Fullscreen fills the height instead of capping at 12. */
727
+ private computeOutputRows(
728
+ footerLines: number,
729
+ hasDescription: boolean,
730
+ hasError: boolean,
731
+ hasAction: boolean,
732
+ ): number {
733
+ const fixed =
734
+ 15 + footerLines + (hasDescription ? 1 : 0) + (hasError ? 1 : 0) + (hasAction ? 1 : 0);
735
+ const available = this.fullscreen
736
+ ? this.terminalRows() - FULLSCREEN_MARGINS.top - FULLSCREEN_MARGINS.bottom
737
+ : this.terminalRows() - 2;
738
+ const cap = this.fullscreen ? Number.POSITIVE_INFINITY : DETAIL_VISIBLE_OUTPUT_LINES;
739
+ return Math.max(1, Math.min(cap, available - fixed));
740
+ }
741
+
742
+ /** Task rows the list box can show: 14 in normal mode, or the height left in fullscreen. */
743
+ private computeListRows(footerLines: number, hasShowing: boolean, hasAction: boolean): number {
744
+ if (!this.fullscreen) return LIST_VISIBLE_ROWS;
745
+ const fixed = 5 + footerLines + (hasShowing ? 1 : 0) + (hasAction ? 1 : 0);
746
+ const available = this.terminalRows() - FULLSCREEN_MARGINS.top - FULLSCREEN_MARGINS.bottom;
747
+ return Math.max(1, available - fixed);
748
+ }
749
+
750
+ private setFullscreen(next: boolean): void {
751
+ if (next === this.fullscreen) return;
752
+ this.fullscreen = next;
753
+ this.options.onFullscreenToggle?.(next);
754
+ }
755
+
756
+ /**
757
+ * Toggle fullscreen in either view. Entering is gated on the terminal being big
758
+ * enough to exceed the modal's largest normal size.
759
+ */
760
+ private toggleFullscreen(): void {
761
+ if (this.fullscreen) {
762
+ this.setFullscreen(false);
763
+ } else if (this.canFullscreen()) {
764
+ this.setFullscreen(true);
765
+ } else {
766
+ const minCols =
767
+ FULLSCREEN_MIN_CONTENT_WIDTH + FULLSCREEN_MARGINS.left + FULLSCREEN_MARGINS.right;
768
+ const minRows =
769
+ FULLSCREEN_MIN_CONTENT_HEIGHT + FULLSCREEN_MARGINS.top + FULLSCREEN_MARGINS.bottom;
770
+ this.actionMessage = `Terminal too small for fullscreen (need at least ${minCols}x${minRows}).`;
771
+ }
772
+ this.invalidate();
773
+ }
774
+
775
+ private canFullscreen(): boolean {
776
+ const cols = process.stdout.columns ?? 0;
777
+ const rows = process.stdout.rows ?? 0;
778
+ const contentWidth = cols - FULLSCREEN_MARGINS.left - FULLSCREEN_MARGINS.right;
779
+ const contentHeight = rows - FULLSCREEN_MARGINS.top - FULLSCREEN_MARGINS.bottom;
780
+ return (
781
+ contentWidth >= FULLSCREEN_MIN_CONTENT_WIDTH && contentHeight >= FULLSCREEN_MIN_CONTENT_HEIGHT
782
+ );
783
+ }
784
+
785
+ /**
786
+ * Toggle wrap/truncate for the output tail. Keeps the cursor on the same logical
787
+ * line by remapping the display-line scroll position to that line.
788
+ */
789
+ private toggleWrap(): void {
790
+ const inner = this.lastOutputInnerWidth;
791
+ if (this.detailFollow) {
792
+ this.wrapOutput = !this.wrapOutput;
793
+ this.invalidate();
794
+ return;
795
+ }
796
+ const prevCounts = this.detailLines.map((line) =>
797
+ this.wrapOutput ? wrapTextWithAnsi(line, inner).length : 1,
798
+ );
799
+ let lineIndex = 0;
800
+ let acc = 0;
801
+ while (
802
+ lineIndex + 1 < prevCounts.length &&
803
+ acc + prevCounts[lineIndex] - 1 < this.detailScrollTop
804
+ ) {
805
+ acc += prevCounts[lineIndex];
806
+ lineIndex += 1;
807
+ }
808
+ this.wrapOutput = !this.wrapOutput;
809
+ this.detailScrollTop = acc;
810
+ this.detailFollow = false;
811
+ this.invalidate();
812
+ }
813
+
814
+ private terminalRows(): number {
815
+ return process.stdout.rows || Number(process.env.LINES) || 24;
816
+ }
817
+
818
+ private renderOutputBox(width: number): string[] {
819
+ const inner = Math.max(1, width - 2);
820
+ const top = ` ${blueBorder(`╭${'─'.repeat(inner)}╮`)}`;
821
+ const bottom = ` ${blueBorder(`╰${'─'.repeat(inner)}╯`)}`;
822
+ const row = (content = '') =>
823
+ ` ${blueBorder('│')}${padAnsi(truncateToWidth(content, inner), inner)}${blueBorder('│')}`;
824
+ const lines = [top];
825
+ if (this.tailError) {
826
+ lines.push(row(this.theme.fg('error', this.tailError)));
827
+ } else if (this.detailLines.length === 0) {
828
+ lines.push(row(this.theme.fg('dim', 'No output yet')));
829
+ } else {
830
+ const maxTop = Math.max(0, this.outputDisplayLines.length - this.outputRows);
831
+ const start = this.detailFollow ? maxTop : Math.min(this.detailScrollTop, maxTop);
832
+ const windowLines = this.outputDisplayLines.slice(start, start + this.outputRows);
833
+ for (const line of windowLines) lines.push(row(this.theme.fg('toolOutput', line)));
834
+ }
835
+ while (lines.length < this.outputRows + 1) lines.push(row());
836
+ lines.push(bottom);
837
+ lines.push(` ${this.theme.fg('dim', this.outputStatusLine())}`);
838
+ return lines;
839
+ }
840
+
841
+ private outputStatusLine(): string {
842
+ const total = this.outputDisplayLines.length;
843
+ if (!this.detailFollow && total > 0) {
844
+ const maxTop = Math.max(0, total - this.outputRows);
845
+ const start = Math.min(this.detailScrollTop, maxTop);
846
+ const end = Math.min(total, start + this.outputRows);
847
+ return `lines ${formatCount(start + 1)}\u2013${formatCount(end)} of ${formatCount(total)} · ↑/↓ PgUp/PgDn scroll · ↓ at bottom follows`;
848
+ }
849
+ const suffix = this.tailTruncated ? ` of ${formatSize(this.tailTotalBytes)}` : '';
850
+ return `following tail ${formatSize(this.tailBytesRead)}${suffix} · ↑ scroll`;
851
+ }
852
+ }