pi-simple-background-tasks 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/FORK-CHANGES.md CHANGED
@@ -34,10 +34,10 @@ Relative to the 2.6.9 source, the fork changes **63 files (31,260 lines removed,
34
34
  - **`/clean` command + `bg_clean` tool** — delete finished tasks (`session` / `all` / `<id>`). New file `src/core/task-cleanup.ts`; empty run directories are removed after cleanup (tasks live under `~/.pi/agent/tasks`).
35
35
  - **Task runtime moved to `~/.pi/agent/tasks`** with the PI extension-config pattern (`pi-background-tasks.json`, `tasksDir`).
36
36
  - **KISS packaging** — single entry point `src/extension.ts`, no `dist/`, no tests, no docs tree; fork identity in `package.json`.
37
- - **Task manager UI** — opens top-right as a fixed 96-column modal (96 % width left the box stuck left on wide terminals); `d` deletes the selected task; both shortcuts registered (Shift↓ / Ctrl+Alt+B); footer options contextual to the selected task; empty-state footer shows only sensible actions; task detail adapts to terminal height (modal title dropped).
37
+ - **Task manager UI** — opens top-right as a fixed 96-column modal (96 % width left the box stuck left on wide terminals); `d` deletes the selected task; both shortcuts registered (Shift↓ / Ctrl+Alt+B); footer options contextual to the selected task; empty-state footer shows only sensible actions; task detail adapts to terminal height (modal title dropped); **f** toggles fullscreen (gated on terminal size), **w** toggles output wrap.
38
38
  - **`--agent` flag** removed from `/bg` (stale).
39
39
  - **Lazy module loader simplified.**
40
- - **Dependencies** — pi `^1.3.0`, TypeScript `^7.0.2`, `@types/node ^26.6.5`, tsx `^4.23.15`, typebox `^1.4.1`; peer ranges broadened to `|| ^1.0.0` (installs on pi 1.x, still accepts 0.81.1–0.84.0).
40
+ - **Dependencies** — pi `^1.3.0`, TypeScript `^7.0.2`, `@types/node ^26.6.5`, tsx `^4.23.15`, typebox `^1.4.2`; peer ranges broadened to `|| ^1.0.0` (installs on pi 1.x, still accepts 0.81.1–0.84.0).
41
41
  - **biome** lint/format + `tsconfig.json` for `tsc --noEmit`.
42
42
 
43
43
  ## Upstream since the fork base (2.6.9 → 2.6.10)
@@ -51,7 +51,7 @@ Checked 2026-10-17. All 4 commits are about one new subsystem, **host-api**: an
51
51
  | `7781fe3` | land host-api: `host-api.ts`, `host-protocol.ts`, `host-results.ts`, docs, tests | No |
52
52
  | `1857b3c` | test(host-api): freeze background UI contract and terminal guard | No |
53
53
  | `9923a95` | test(host-api): correct passing group count label | No |
54
- | `799182f` | chore(release): bump version to 2.6.10 | n/a (this fork uses 0.1.x versioning) |
54
+ | `799182f` | chore(release): bump version to 2.6.10 | n/a (this fork uses its own 0.x versioning) |
55
55
 
56
56
  ## Re-checking the divergence
57
57
 
package/README.md CHANGED
@@ -8,7 +8,7 @@ A KISS fork of [pi-background-tasks](https://github.com/ismailsaleekh/pi-backgro
8
8
 
9
9
  - **Run long jobs without blocking**: start a named shell command in the background, keep talking to Pi, and get a completion notification in chat when it finishes.
10
10
  - **Durable tasks**: task state is persisted under `~/.pi/agent/tasks`, so it survives session restarts.
11
- - **Task manager overlay**: a TUI panel lists running and recent tasks with status and bounded output (default **Shift↓**).
11
+ - **Task manager overlay**: a TUI panel lists running and recent tasks with status and bounded output (default **Shift↓**); **f** toggles fullscreen, **w** wraps long output lines.
12
12
  - **Survive Pi reloads**: opt in to keeping a shell task alive across a same-process `/reload`.
13
13
  - **Cross-platform**: POSIX shell and Windows (process-group kill).
14
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-simple-background-tasks",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Durable background shell tasks for Pi. KISS fork of pi-background-tasks.",
5
5
  "type": "module",
6
6
  "license": "ISC",
@@ -52,7 +52,7 @@
52
52
  "@earendil-works/pi-tui": "^1.3.0",
53
53
  "@types/node": "^26.6.5",
54
54
  "tsx": "^4.23.15",
55
- "typebox": "^1.4.1",
55
+ "typebox": "^1.4.2",
56
56
  "typescript": "^7.0.2"
57
57
  },
58
58
  "engines": {
package/src/extension.ts CHANGED
@@ -9,7 +9,13 @@ import type {
9
9
  ToolRenderResultOptions,
10
10
  } from '@earendil-works/pi-coding-agent';
11
11
  import { formatSize } from '@earendil-works/pi-coding-agent';
12
- import { type KeyId, Text } from '@earendil-works/pi-tui';
12
+ import {
13
+ type KeyId,
14
+ type OverlayHandle,
15
+ type OverlayOptions,
16
+ Text,
17
+ type TUI,
18
+ } from '@earendil-works/pi-tui';
13
19
  import { type Static, Type } from 'typebox';
14
20
  import {
15
21
  type BgKillDetails,
@@ -47,7 +53,12 @@ import {
47
53
  } from './core/reload-shell-owner.js';
48
54
  import { createShellPolicyGuidanceHandler, initializeShellPolicy } from './core/shell-policy.js';
49
55
  import type { CleanupScope, CleanupSummary } from './core/task-cleanup.js';
50
- import type { BackgroundTaskForUi, TaskManagerResult } from './ui/background-tasks-manager.js';
56
+ import {
57
+ type BackgroundTaskForUi,
58
+ type BackgroundTasksManager,
59
+ FULLSCREEN_MARGINS,
60
+ type TaskManagerOptions,
61
+ } from './ui/background-tasks-manager.js';
51
62
 
52
63
  /**
53
64
  * Project-local Pi background task manager.
@@ -381,55 +392,88 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
381
392
  );
382
393
  return;
383
394
  }
395
+ const normalOptions: OverlayOptions = {
396
+ anchor: 'top-right',
397
+ width: 96,
398
+ minWidth: 64,
399
+ margin: { top: 1, left: 1, right: 1 },
400
+ };
401
+ // Pi resolves overlayOptions once (at overlay creation), so a fullscreen toggle
402
+ // re-creates the overlay with the same manager instance and fresh options.
403
+ const fullscreenOptions = (): OverlayOptions => ({
404
+ anchor: 'top-left',
405
+ width: '100%',
406
+ maxHeight: Math.max(
407
+ 1,
408
+ process.stdout.rows - FULLSCREEN_MARGINS.top - FULLSCREEN_MARGINS.bottom,
409
+ ),
410
+ margin: FULLSCREEN_MARGINS,
411
+ });
412
+
384
413
  const { BackgroundTasksManager } = await taskManagerLoader.run((runtime) => runtime);
385
- await ctx.ui.custom<TaskManagerResult>(
386
- (tui, theme, _keybindings, done) => {
387
- const managerOptions = {
388
- getTasks: () => registry.allTasks(),
389
- stopTask: async (task: BackgroundTaskForUi) => {
390
- await registry.stopTask(registry.resolveTask(task.id), 'user');
391
- },
392
- stopAllRunning: async () => {
393
- const result = await registry.stopAllRunning('user');
394
- return result;
395
- },
396
- rerunTask: async (task: BackgroundTaskForUi) => {
397
- const rerunOptions: StartTaskOptions = {
398
- name: taskDisplayName(task),
399
- surviveReload: task.surviveReload,
400
- notifyOnCompletion: true,
401
- triggerOnCompletion: false,
402
- };
403
- if (task.description !== undefined) rerunOptions.description = task.description;
404
- if (task.timeoutSeconds !== undefined)
405
- rerunOptions.timeoutSeconds = task.timeoutSeconds;
406
- const rerun = await startTask(ctx, task.command, rerunOptions);
407
- return rerun;
408
- },
409
- showOutputPath: (task: BackgroundTaskForUi) => {
410
- ctx.ui.notify(
411
- `Output path for ${taskDisplayName(task)} (${task.id}):\n${task.outputPath}`,
412
- 'info',
413
- );
414
- },
415
- deleteTask: async (task: BackgroundTaskForUi) => {
416
- await registry.cleanupFinishedTasks('one', task.id);
417
- },
414
+ let manager: BackgroundTasksManager | null = null;
415
+ let currentHandle: OverlayHandle | null = null;
416
+ let rawTui: TUI | null = null;
417
+
418
+ const rePresent = (options: OverlayOptions) => {
419
+ if (!manager || !rawTui) return;
420
+ currentHandle?.hide();
421
+ currentHandle = rawTui.showOverlay(manager, options);
422
+ };
423
+
424
+ const managerOptions: TaskManagerOptions = {
425
+ getTasks: () => registry.allTasks(),
426
+ stopTask: async (task: BackgroundTaskForUi) => {
427
+ await registry.stopTask(registry.resolveTask(task.id), 'user');
428
+ },
429
+ stopAllRunning: async () => {
430
+ const result = await registry.stopAllRunning('user');
431
+ return result;
432
+ },
433
+ rerunTask: async (task: BackgroundTaskForUi) => {
434
+ const rerunOptions: StartTaskOptions = {
435
+ name: taskDisplayName(task),
436
+ surviveReload: task.surviveReload,
437
+ notifyOnCompletion: true,
438
+ triggerOnCompletion: false,
418
439
  };
419
- if (initialTaskId)
420
- return new BackgroundTasksManager(tui, theme, done, {
421
- ...managerOptions,
422
- initialTaskId,
423
- });
424
- return new BackgroundTasksManager(tui, theme, done, managerOptions);
440
+ if (task.description !== undefined) rerunOptions.description = task.description;
441
+ if (task.timeoutSeconds !== undefined) rerunOptions.timeoutSeconds = task.timeoutSeconds;
442
+ const rerun = await startTask(ctx, task.command, rerunOptions);
443
+ return rerun;
444
+ },
445
+ showOutputPath: (task: BackgroundTaskForUi) => {
446
+ ctx.ui.notify(
447
+ `Output path for ${taskDisplayName(task)} (${task.id}):\n${task.outputPath}`,
448
+ 'info',
449
+ );
450
+ },
451
+ deleteTask: async (task: BackgroundTaskForUi) => {
452
+ await registry.cleanupFinishedTasks('one', task.id);
453
+ },
454
+ onFullscreenToggle: (fullscreen) =>
455
+ rePresent(fullscreen ? fullscreenOptions() : normalOptions),
456
+ onClose: () => {
457
+ currentHandle?.hide();
458
+ currentHandle = null;
459
+ },
460
+ };
461
+
462
+ await ctx.ui.custom(
463
+ (tui, theme, _keybindings, done) => {
464
+ rawTui = tui;
465
+ const created = new BackgroundTasksManager(tui, theme, done, {
466
+ ...managerOptions,
467
+ ...(initialTaskId ? { initialTaskId } : {}),
468
+ });
469
+ manager = created;
470
+ return created;
425
471
  },
426
472
  {
427
473
  overlay: true,
428
- overlayOptions: {
429
- anchor: 'top-right',
430
- width: 96,
431
- minWidth: 64,
432
- margin: { top: 1, left: 1, right: 1 },
474
+ overlayOptions: normalOptions,
475
+ onHandle: (handle) => {
476
+ currentHandle = handle;
433
477
  },
434
478
  },
435
479
  );
@@ -32,6 +32,13 @@ const STATUS_INTERVAL_MS = 1000;
32
32
  const DETAIL_TAIL_BYTES = 128 * 1024;
33
33
  const LIST_VISIBLE_ROWS = 14;
34
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;
35
42
  const LIGHT_BLUE_BG = '\x1b[48;2;183;223;255m';
36
43
  const LIGHT_BLUE_FG = '\x1b[38;2;11;70;110m';
37
44
  const LIGHT_BLUE_BORDER = '\x1b[38;2;83;160;215m';
@@ -124,6 +131,8 @@ export interface TaskManagerOptions {
124
131
  rerunTask: (task: BgTask) => Promise<BgTask>;
125
132
  showOutputPath: (task: BgTask) => void;
126
133
  deleteTask: (task: BgTask) => Promise<void>;
134
+ onFullscreenToggle?: (fullscreen: boolean) => void;
135
+ onClose?: () => void;
127
136
  }
128
137
 
129
138
  export class BackgroundTasksManager implements Component {
@@ -136,6 +145,11 @@ export class BackgroundTasksManager implements Component {
136
145
  private detailFollow = true;
137
146
  private detailScrollTop = 0;
138
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[] = [];
139
153
  private tailBytesRead = 0;
140
154
  private tailTotalBytes = 0;
141
155
  private tailTruncated = false;
@@ -192,19 +206,25 @@ export class BackgroundTasksManager implements Component {
192
206
  return;
193
207
  }
194
208
 
209
+ if (data === 'f' || data === 'F') {
210
+ this.toggleFullscreen();
211
+ return;
212
+ }
213
+
195
214
  if (this.mode === 'detail') {
196
215
  this.handleDetailInput(data);
197
216
  return;
198
217
  }
199
218
  this.handleListInput(data);
200
219
  }
201
-
202
220
  render(width: number): string[] {
203
- const boxWidth = Math.max(2, Math.min(width, 118));
221
+ if (this.fullscreen && !this.canFullscreen()) this.setFullscreen(false);
222
+ const boxWidth = Math.max(2, this.fullscreen ? width : Math.min(width, 118));
204
223
  return this.mode === 'detail' ? this.renderDetail(boxWidth) : this.renderList(boxWidth);
205
224
  }
206
225
 
207
226
  private close(): void {
227
+ this.options.onClose?.();
208
228
  this.done('closed');
209
229
  }
210
230
 
@@ -231,13 +251,13 @@ export class BackgroundTasksManager implements Component {
231
251
  return;
232
252
  }
233
253
  if (matchesKey(data, 'pageUp')) {
234
- this.selectedIndex = Math.max(0, this.selectedIndex - LIST_VISIBLE_ROWS);
254
+ this.selectedIndex = Math.max(0, this.selectedIndex - this.listVisibleRows);
235
255
  this.ensureSelectionVisible();
236
256
  this.tui.requestRender();
237
257
  return;
238
258
  }
239
259
  if (matchesKey(data, 'pageDown')) {
240
- this.selectedIndex = Math.min(tasks.length - 1, this.selectedIndex + LIST_VISIBLE_ROWS);
260
+ this.selectedIndex = Math.min(tasks.length - 1, this.selectedIndex + this.listVisibleRows);
241
261
  this.ensureSelectionVisible();
242
262
  this.tui.requestRender();
243
263
  return;
@@ -310,6 +330,11 @@ export class BackgroundTasksManager implements Component {
310
330
  void this.refreshTail();
311
331
  return;
312
332
  }
333
+
334
+ if (data === 'w' || data === 'W') {
335
+ this.toggleWrap();
336
+ return;
337
+ }
313
338
  if (data === 'R' && task) {
314
339
  void this.rerunTaskFromUi(task);
315
340
  return;
@@ -360,8 +385,8 @@ export class BackgroundTasksManager implements Component {
360
385
 
361
386
  private ensureSelectionVisible(): void {
362
387
  if (this.selectedIndex < this.listScroll) this.listScroll = this.selectedIndex;
363
- if (this.selectedIndex >= this.listScroll + LIST_VISIBLE_ROWS)
364
- this.listScroll = this.selectedIndex - LIST_VISIBLE_ROWS + 1;
388
+ if (this.selectedIndex >= this.listScroll + this.listVisibleRows)
389
+ this.listScroll = this.selectedIndex - this.listVisibleRows + 1;
365
390
  }
366
391
 
367
392
  private async stopTaskFromUi(task: BgTask): Promise<void> {
@@ -456,7 +481,7 @@ export class BackgroundTasksManager implements Component {
456
481
 
457
482
  /** Scroll the detail output window. Scrolling up pauses the live tail; reaching the bottom resumes it. */
458
483
  private scrollDetail(delta: number): void {
459
- const maxTop = Math.max(0, this.detailLines.length - this.outputRows);
484
+ const maxTop = Math.max(0, this.outputDisplayLines.length - this.outputRows);
460
485
  if (maxTop === 0) return;
461
486
  if (this.detailFollow) {
462
487
  this.detailFollow = false;
@@ -544,15 +569,19 @@ export class BackgroundTasksManager implements Component {
544
569
  if (allRunning > 0) parts.push('a stop all');
545
570
  if (historyCount > 0) parts.push(`h ${this.showHistory ? 'hide' : 'show'} history`);
546
571
  if (hasSelection) parts.push('R rerun', 'c path');
572
+ parts.push('f fullscreen');
547
573
  parts.push('x close');
548
574
  return parts.filter((part) => part.length > 0).join(' · ');
549
575
  }
550
576
 
551
577
  private detailHelp(task: BgTask): string {
578
+ const wrapHint = this.wrapOutput ? 'w truncate' : 'w wrap';
552
579
  const parts = [
553
580
  '↑/↓ scroll',
554
581
  '← list',
555
582
  'r refresh',
583
+ wrapHint,
584
+ 'f fullscreen',
556
585
  this.taskActionLabel(task),
557
586
  'R rerun',
558
587
  'c path',
@@ -577,6 +606,14 @@ export class BackgroundTasksManager implements Component {
577
606
  const subtitle = subtitleParts.join(' · ');
578
607
 
579
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;
580
617
  if (tasks.length === 0) {
581
618
  const message =
582
619
  allTasks.length === 0
@@ -587,7 +624,7 @@ export class BackgroundTasksManager implements Component {
587
624
  body.push(this.theme.fg('dim', message));
588
625
  } else {
589
626
  const maxNameWidth = Math.max(12, width - 78);
590
- const visible = tasks.slice(this.listScroll, this.listScroll + LIST_VISIBLE_ROWS);
627
+ const visible = tasks.slice(this.listScroll, this.listScroll + this.listVisibleRows);
591
628
  for (let i = 0; i < visible.length; i++) {
592
629
  const task = visible[i];
593
630
  if (!task) continue;
@@ -614,10 +651,10 @@ export class BackgroundTasksManager implements Component {
614
651
  if (selected) row = lightBlue(padAnsi(truncateToWidth(row, width - 4), width - 4));
615
652
  body.push(row);
616
653
  }
617
- if (tasks.length > LIST_VISIBLE_ROWS) {
654
+ if (tasks.length > this.listVisibleRows) {
618
655
  const firstVisible = formatCount(this.listScroll + 1);
619
656
  const lastVisible = formatCount(
620
- Math.min(tasks.length, this.listScroll + LIST_VISIBLE_ROWS),
657
+ Math.min(tasks.length, this.listScroll + this.listVisibleRows),
621
658
  );
622
659
  const totalVisible = formatCount(tasks.length);
623
660
  body.push(
@@ -643,51 +680,135 @@ export class BackgroundTasksManager implements Component {
643
680
  const name = taskDisplayName(task);
644
681
  const status = statusColor(this.theme, task.status);
645
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 ?? '';
646
687
  const body: string[] = [
647
- ` ${this.theme.fg('toolTitle', 'Name:')} ${this.theme.fg('accent', name)}`,
648
- ` ${this.theme.fg('toolTitle', 'ID:')} ${this.theme.fg('accent', task.id)}`,
688
+ ...(hasDescription
689
+ ? [
690
+ ` ${this.theme.fg('toolTitle', 'Description:')} ${truncateToWidth(
691
+ description,
692
+ width - 16,
693
+ )}`,
694
+ ]
695
+ : []),
649
696
  ` ${this.theme.fg('toolTitle', 'Status:')} ${status}${this.theme.fg('dim', exit)}`,
650
697
  ` ${this.theme.fg('toolTitle', 'Runtime:')} ${taskAge(task)}${this.theme.fg('dim', formatPidText(task.pid))}`,
651
- ` ${this.theme.fg('toolTitle', 'Started:')} ${formatTime(task.startTime)}${task.endTime ? this.theme.fg('dim', ` · ended ${formatTime(task.endTime)}`) : ''}`,
698
+ ` ${this.theme.fg('toolTitle', 'Started:')} ${formatTime(task.startTime)}${
699
+ task.endTime ? this.theme.fg('dim', ` · ended ${formatTime(task.endTime)}`) : ''
700
+ }`,
652
701
  ` ${this.theme.fg('toolTitle', 'Output:')} ${this.theme.fg('accent', task.outputPath)}`,
653
- ];
654
- if (task.description && compactWhitespace(task.description) !== compactWhitespace(name)) {
655
- body.push(
656
- ` ${this.theme.fg('toolTitle', 'Description:')} ${truncateToWidth(task.description, width - 16)}`,
657
- );
658
- }
659
- body.push(
660
702
  ` ${this.theme.fg('toolTitle', 'Command:')} ${truncateToWidth(task.command, width - 13)}`,
661
- );
703
+ ];
662
704
  if (task.error) body.push(` ${this.theme.fg('error', `Error: ${task.error}`)}`);
663
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;
664
710
  this.outputRows = this.computeOutputRows(
665
- wrapTextWithAnsi(this.detailHelp(task), Math.max(1, width - 3)).length,
666
- !!task.description && compactWhitespace(task.description) !== compactWhitespace(name),
711
+ footerLines,
712
+ hasDescription,
667
713
  !!task.error,
668
714
  !!this.actionMessage,
669
715
  );
716
+ this.outputDisplayLines = this.detailLines.flatMap((line) =>
717
+ this.wrapOutput ? wrapTextWithAnsi(line, this.lastOutputInnerWidth) : [line],
718
+ );
670
719
  body.push(...this.renderOutputBox(width - 4));
671
- if (this.actionMessage) body.push(this.theme.fg('warning', ` ${this.actionMessage}`));
672
- const subtitle = `${task.id} · ${task.status === 'running' ? 'live tail refreshes every second' : 'final output'}`;
720
+ if (this.actionMessage) body.push(this.theme.fg('warning', ` ${this.actionMessage}`));
721
+ const subtitle = `${task.id} · ${name}`;
673
722
  const footer = this.theme.fg('dim', this.detailHelp(task));
674
723
  return this.frame(subtitle, body, footer, width);
675
724
  }
676
725
 
677
- /** Output-box rows that fit the terminal, so the whole detail stays visible in short panes. */
726
+ /** Output-box rows that fit the terminal. Fullscreen fills the height instead of capping at 12. */
678
727
  private computeOutputRows(
679
728
  footerLines: number,
680
729
  hasDescription: boolean,
681
730
  hasError: boolean,
682
731
  hasAction: boolean,
683
732
  ): number {
684
- // Fixed detail lines excluding the output-box content rows: frame chrome
685
- // (top, subtitle, two blanks, footer, bottom) + six fields + command +
686
- // "Output tail:" + the output box's own top/bottom/status rows.
687
733
  const fixed =
688
- 17 + footerLines + (hasDescription ? 1 : 0) + (hasError ? 1 : 0) + (hasAction ? 1 : 0);
689
- const available = this.terminalRows() - 2;
690
- return Math.max(1, Math.min(DETAIL_VISIBLE_OUTPUT_LINES, available - 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();
691
812
  }
692
813
 
693
814
  private terminalRows(): number {
@@ -706,9 +827,9 @@ export class BackgroundTasksManager implements Component {
706
827
  } else if (this.detailLines.length === 0) {
707
828
  lines.push(row(this.theme.fg('dim', 'No output yet')));
708
829
  } else {
709
- const maxTop = Math.max(0, this.detailLines.length - this.outputRows);
830
+ const maxTop = Math.max(0, this.outputDisplayLines.length - this.outputRows);
710
831
  const start = this.detailFollow ? maxTop : Math.min(this.detailScrollTop, maxTop);
711
- const windowLines = this.detailLines.slice(start, start + this.outputRows);
832
+ const windowLines = this.outputDisplayLines.slice(start, start + this.outputRows);
712
833
  for (const line of windowLines) lines.push(row(this.theme.fg('toolOutput', line)));
713
834
  }
714
835
  while (lines.length < this.outputRows + 1) lines.push(row());
@@ -718,7 +839,7 @@ export class BackgroundTasksManager implements Component {
718
839
  }
719
840
 
720
841
  private outputStatusLine(): string {
721
- const total = this.detailLines.length;
842
+ const total = this.outputDisplayLines.length;
722
843
  if (!this.detailFollow && total > 0) {
723
844
  const maxTop = Math.max(0, total - this.outputRows);
724
845
  const start = Math.min(this.detailScrollTop, maxTop);