@pi-unipi/background-tasks 2.6.1
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/README.md +87 -0
- package/extensions/anthropic-attribution.ts +1 -0
- package/extensions/delegate-child.ts +1 -0
- package/extensions/fusion-child.ts +1 -0
- package/package.json +40 -0
- package/src/__tests__/anthropic-attribution.test.ts +195 -0
- package/src/__tests__/config.test.ts +137 -0
- package/src/__tests__/core.test.ts +493 -0
- package/src/__tests__/delegate-artifacts.test.ts +528 -0
- package/src/__tests__/delegate-budget.test.ts +456 -0
- package/src/__tests__/delegate-launch.test.ts +676 -0
- package/src/__tests__/delegate-result-package.test.ts +350 -0
- package/src/__tests__/delegate-seed.test.ts +392 -0
- package/src/__tests__/durable-fs.test.ts +559 -0
- package/src/__tests__/extension-api.test.ts +579 -0
- package/src/__tests__/fusion-artifacts.test.ts +1039 -0
- package/src/__tests__/fusion-budget.test.ts +1356 -0
- package/src/__tests__/fusion-claude-cache.test.ts +320 -0
- package/src/__tests__/fusion-config.test.ts +335 -0
- package/src/__tests__/fusion-context-prompts.test.ts +670 -0
- package/src/__tests__/fusion-evaluation.test.ts +315 -0
- package/src/__tests__/fusion-extraction-equivalence.test.ts +58 -0
- package/src/__tests__/fusion-golden-bytes.test.ts +35 -0
- package/src/__tests__/fusion-high-cardinality.test.ts +192 -0
- package/src/__tests__/fusion-model-selector.test.ts +205 -0
- package/src/__tests__/fusion-orchestrator.test.ts +1194 -0
- package/src/__tests__/fusion-rpc.test.ts +369 -0
- package/src/__tests__/fusion-sdk.test.ts +1226 -0
- package/src/__tests__/fusion-v5-core.test.ts +219 -0
- package/src/__tests__/fusion-validate-orchestrator.test.ts +240 -0
- package/src/__tests__/fusion-web-fetch.test.ts +485 -0
- package/src/__tests__/fusion-workflows.test.ts +59 -0
- package/src/__tests__/helpers/delegate-deterministic-seed.ts +109 -0
- package/src/__tests__/helpers/delegate-seed-subprocess.ts +10 -0
- package/src/__tests__/helpers/fusion-canonical-subprocess.ts +21 -0
- package/src/__tests__/helpers/fusion-canonical.ts +140 -0
- package/src/__tests__/helpers/fusion-fake-pi.ts +279 -0
- package/src/__tests__/helpers/fusion-golden-corpus.ts +500 -0
- package/src/__tests__/helpers/fusion-high-cardinality.ts +140 -0
- package/src/__tests__/helpers/normalize.ts +22 -0
- package/src/__tests__/helpers/pi-hook-contract-evidence.json +18 -0
- package/src/__tests__/pi-launch.test.ts +202 -0
- package/src/__tests__/registry.test.ts +1580 -0
- package/src/__tests__/scripted-provider/delegate-ambient-provider.test.ts +130 -0
- package/src/__tests__/scripted-provider/delegate-child-guard.test.ts +631 -0
- package/src/__tests__/scripted-provider/delegate-guard-provider.ts +403 -0
- package/src/__tests__/scripted-provider/follow-up.test.ts +448 -0
- package/src/__tests__/scripted-provider/fusion-output-recovery.test.ts +132 -0
- package/src/__tests__/scripted-provider/fusion-reason.test.ts +310 -0
- package/src/__tests__/scripted-provider/fusion-runtime-guard.test.ts +163 -0
- package/src/__tests__/scripted-provider/hook-contract-provider.ts +179 -0
- package/src/__tests__/scripted-provider/hook-probe-a.ts +3 -0
- package/src/__tests__/scripted-provider/hook-probe-b.ts +3 -0
- package/src/__tests__/scripted-provider/hook-probe-extension.ts +126 -0
- package/src/__tests__/scripted-provider/output-recovery-provider.ts +153 -0
- package/src/__tests__/scripted-provider/pi-hook-contract-evidence.json +18 -0
- package/src/__tests__/scripted-provider/pi-hook-contract.test.ts +477 -0
- package/src/__tests__/scripted-provider/runtime-guard-probe.ts +28 -0
- package/src/__tests__/scripted-provider/runtime-guard-provider.ts +49 -0
- package/src/__tests__/scripted-provider/scripted-provider-extension.ts +408 -0
- package/src/__tests__/task-manager.test.ts +479 -0
- package/src/__tests__/windows-taskkill.test.ts +161 -0
- package/src/anthropic-attribution-path.ts +21 -0
- package/src/anthropic-attribution.ts +1983 -0
- package/src/attested-pi-run.ts +612 -0
- package/src/child-process.ts +55 -0
- package/src/common.ts +8 -0
- package/src/config.ts +292 -0
- package/src/context-parent-snapshot.ts +142 -0
- package/src/context-token-budget.ts +903 -0
- package/src/context-visible-conversation-v2.ts +551 -0
- package/src/delegate/artifacts.ts +487 -0
- package/src/delegate/budget.ts +415 -0
- package/src/delegate/hook-contract-evidence.json +18 -0
- package/src/delegate/hook-contract.ts +154 -0
- package/src/delegate/launch.ts +497 -0
- package/src/delegate/result-package.ts +459 -0
- package/src/delegate/runner.ts +449 -0
- package/src/delegate/seed.ts +423 -0
- package/src/delegate/types.ts +323 -0
- package/src/delegate-child-extension.ts +978 -0
- package/src/delegate-extension.ts +806 -0
- package/src/durable-fs.ts +386 -0
- package/src/extension-api.ts +548 -0
- package/src/fixtures/delegate-context-incident.json +17 -0
- package/src/fixtures/fusion-golden-bytes.json +310 -0
- package/src/fixtures/fusion-validate-golden-bytes.json +282 -0
- package/src/fusion/artifacts.ts +967 -0
- package/src/fusion/budget.ts +1162 -0
- package/src/fusion/child-protocol.ts +305 -0
- package/src/fusion/claude-cache.ts +207 -0
- package/src/fusion/clean-context.ts +91 -0
- package/src/fusion/config.ts +449 -0
- package/src/fusion/context.ts +265 -0
- package/src/fusion/evaluation.ts +800 -0
- package/src/fusion/orchestrator.ts +1288 -0
- package/src/fusion/output-contract.ts +34 -0
- package/src/fusion/pi-child.ts +2373 -0
- package/src/fusion/prompts.ts +345 -0
- package/src/fusion/result-package.ts +959 -0
- package/src/fusion/source-policy.ts +257 -0
- package/src/fusion/types.ts +1139 -0
- package/src/fusion/web-fetch.ts +1060 -0
- package/src/fusion/workflows.ts +184 -0
- package/src/fusion-child-extension.ts +1052 -0
- package/src/fusion-extension.ts +1293 -0
- package/src/index.ts +295 -0
- package/src/pi-launch.ts +225 -0
- package/src/registry.ts +2424 -0
- package/src/settings-overlay.ts +208 -0
- package/src/task-manager.ts +774 -0
- package/src/tools.ts +530 -0
- package/src/turndown.d.ts +15 -0
- package/src/types.ts +963 -0
- package/src/ui/fusion-model-selector.ts +322 -0
- package/src/windows-taskkill.ts +250 -0
|
@@ -0,0 +1,774 @@
|
|
|
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
|
+
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 './types.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
|
+
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';
|
|
39
|
+
|
|
40
|
+
function formatTime(timestamp: number): string {
|
|
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)}` : '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Normalize raw output bytes into display lines, dropping only the trailing empty line from a final newline. */
|
|
61
|
+
function toOutputLines(content: string): string[] {
|
|
62
|
+
return content
|
|
63
|
+
.replace(/\r/g, '')
|
|
64
|
+
.split('\n')
|
|
65
|
+
.filter((line, index, array) => line.length > 0 || index < array.length - 1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function padAnsi(value: string, width: number): string {
|
|
69
|
+
return value + ' '.repeat(Math.max(0, width - visibleWidth(value)));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function lightBlue(value: string): string {
|
|
73
|
+
return `${LIGHT_BLUE_BG}${LIGHT_BLUE_FG}${value}${ANSI_RESET}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function blueBorder(value: string): string {
|
|
77
|
+
return `${LIGHT_BLUE_BORDER}${value}${ANSI_RESET}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
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';
|
|
85
|
+
}
|
|
86
|
+
|
|
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);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function taskAge(task: BgTask, now = Date.now()): string {
|
|
99
|
+
return formatDuration((task.endTime ?? now) - task.startTime);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function formatContextUsage(task: BgTask): string {
|
|
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}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function formatContextDetail(task: BgTask): string {
|
|
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)`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function formatTokenUsage(task: BgTask): string {
|
|
119
|
+
const usage = task.tokenUsage;
|
|
120
|
+
if (!usage || usage.totalTokens <= 0) return '';
|
|
121
|
+
return `tok ${formatCompactNumber(usage.totalTokens)}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatTokenDetail(task: BgTask): string {
|
|
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(' · ');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function formatToolUsage(task: BgTask): string {
|
|
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}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function formatToolDetail(task: BgTask): string {
|
|
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(' · ');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function shortModelName(model: string): string {
|
|
160
|
+
const slash = model.lastIndexOf('/');
|
|
161
|
+
return slash >= 0 ? model.slice(slash + 1) : model;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function formatModel(task: BgTask): string {
|
|
165
|
+
if (!task.model) return '';
|
|
166
|
+
return `model ${shortModelName(task.model)}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function formatModelDetail(task: BgTask): string {
|
|
170
|
+
if (!task.model) return 'not reported by this background task';
|
|
171
|
+
return task.model;
|
|
172
|
+
}
|
|
173
|
+
|
|
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);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function sortTasksForUi(tasks: BgTask[]): BgTask[] {
|
|
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
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export type TaskManagerResult = 'closed';
|
|
192
|
+
|
|
193
|
+
export interface StopAllResult {
|
|
194
|
+
stopped: number;
|
|
195
|
+
failures: string[];
|
|
196
|
+
}
|
|
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
|
+
}
|