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 +3 -3
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/extension.ts +90 -46
- package/src/ui/background-tasks-manager.ts +156 -35
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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 (
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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 -
|
|
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 +
|
|
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 +
|
|
364
|
-
this.listScroll = this.selectedIndex -
|
|
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.
|
|
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 +
|
|
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 >
|
|
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 +
|
|
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
|
-
|
|
648
|
-
|
|
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)}${
|
|
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
|
-
|
|
666
|
-
|
|
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', `
|
|
672
|
-
const subtitle = `${task.id} · ${
|
|
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
|
|
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
|
-
|
|
689
|
-
const available = this.
|
|
690
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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);
|