@cr1ms0n/pi-subagent 0.8.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/CHANGELOG.md +352 -0
- package/LICENSE +21 -0
- package/README.md +543 -0
- package/docs/ARCHITECTURE.md +125 -0
- package/docs/COST-ACCOUNTING.md +66 -0
- package/docs/PLAN.md +325 -0
- package/docs/RELEASING.md +32 -0
- package/docs/ROADMAP.md +252 -0
- package/docs/SECURITY.md +85 -0
- package/docs/UI-OVERHAUL.md +186 -0
- package/docs/UX.md +141 -0
- package/extensions/subagent.ts +1 -0
- package/package.json +58 -0
- package/skills/subagent/SKILL.md +103 -0
- package/src/agents.ts +285 -0
- package/src/backend.ts +146 -0
- package/src/backends/claude.ts +384 -0
- package/src/backends/codex.ts +330 -0
- package/src/backends/index.ts +26 -0
- package/src/backends/pi.ts +94 -0
- package/src/btw.ts +34 -0
- package/src/config.ts +254 -0
- package/src/distill.ts +222 -0
- package/src/extension.ts +1527 -0
- package/src/format.ts +365 -0
- package/src/index.ts +60 -0
- package/src/launch.ts +120 -0
- package/src/maintenance.ts +6 -0
- package/src/model-policy.ts +157 -0
- package/src/notifications.ts +106 -0
- package/src/orchestrator.ts +247 -0
- package/src/output.ts +124 -0
- package/src/persistence.ts +334 -0
- package/src/policy.ts +500 -0
- package/src/process-lock.ts +687 -0
- package/src/protocol.ts +290 -0
- package/src/registry.ts +632 -0
- package/src/runner.ts +850 -0
- package/src/schema.ts +166 -0
- package/src/semaphore.ts +123 -0
- package/src/structured.ts +169 -0
- package/src/transcript.ts +360 -0
- package/src/types.ts +197 -0
- package/src/ui.ts +545 -0
- package/src/usage.ts +274 -0
- package/src/worktree.ts +753 -0
package/src/ui.ts
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
4
|
+
import type { RunSnapshot } from "./types.js";
|
|
5
|
+
import {
|
|
6
|
+
formatCost,
|
|
7
|
+
formatDuration,
|
|
8
|
+
formatState,
|
|
9
|
+
formatTokens,
|
|
10
|
+
formatPath,
|
|
11
|
+
isActiveState,
|
|
12
|
+
oneLine,
|
|
13
|
+
SPINNERS,
|
|
14
|
+
stateGlyph,
|
|
15
|
+
} from "./format.js";
|
|
16
|
+
import { sessionLineRenderer, tailSessionFile, type TailSessionStatus } from "./transcript.js";
|
|
17
|
+
|
|
18
|
+
export interface SubagentAdapter {
|
|
19
|
+
getActiveRuns(): RunSnapshot[];
|
|
20
|
+
getCompletedRuns(): RunSnapshot[];
|
|
21
|
+
getRunById(id: string): RunSnapshot | null;
|
|
22
|
+
cancelRun(id: string): void;
|
|
23
|
+
dismissRun(id: string): void;
|
|
24
|
+
resumeRun(id: string): Promise<void>;
|
|
25
|
+
showOutput(id: string): void;
|
|
26
|
+
getReadyCount(): number;
|
|
27
|
+
getUsageSummary?(): string;
|
|
28
|
+
subscribe?(listener: () => void): () => void;
|
|
29
|
+
notify?(message: string, level?: "info" | "warn" | "error"): void;
|
|
30
|
+
/** Prompt for a message and inject it into the running child. */
|
|
31
|
+
steerRun?(id: string): Promise<void>;
|
|
32
|
+
/** Apply a finished run's changed worktree into the main checkout. */
|
|
33
|
+
applyWorktree?(id: string): Promise<void>;
|
|
34
|
+
/** Confirm and discard a finished run's worktree + branch. */
|
|
35
|
+
discardWorktree?(id: string): Promise<void>;
|
|
36
|
+
/** Resolve child session .jsonl path for a run (for live transcript tail). */
|
|
37
|
+
getSessionFilePath?(id: string): string | undefined;
|
|
38
|
+
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface UIAction {
|
|
42
|
+
type: "cancel" | "dismiss" | "resume" | "output" | "close" | "select" | "transcript" | "steer";
|
|
43
|
+
id?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Terse footer segment. Pi's native footer already reports session cost, so
|
|
48
|
+
* this only surfaces actionable subagent state: running and ready counts.
|
|
49
|
+
*/
|
|
50
|
+
export class FooterStatusModel {
|
|
51
|
+
constructor(private readonly adapter: SubagentAdapter) {}
|
|
52
|
+
private running = 0;
|
|
53
|
+
private ready = 0;
|
|
54
|
+
private onUpdate?: () => void;
|
|
55
|
+
private notified = new Set<string>();
|
|
56
|
+
|
|
57
|
+
setOnUpdate(callback: () => void): void { this.onUpdate = callback; }
|
|
58
|
+
update(running: number, ready?: number): void {
|
|
59
|
+
const nextReady = ready ?? this.adapter.getReadyCount();
|
|
60
|
+
if (running !== this.running || nextReady !== this.ready) {
|
|
61
|
+
this.running = running;
|
|
62
|
+
this.ready = nextReady;
|
|
63
|
+
this.onUpdate?.();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Empty string means "nothing actionable" — the caller should clear the status. */
|
|
67
|
+
render(theme: Theme, width = 80): string {
|
|
68
|
+
const ready = this.adapter.getReadyCount();
|
|
69
|
+
if (!this.running && !ready) return "";
|
|
70
|
+
const parts = [
|
|
71
|
+
this.running ? theme.fg("warning", `⚙ ${this.running} running`) : "",
|
|
72
|
+
ready ? theme.fg("success", `${ready} ready`) : "",
|
|
73
|
+
theme.fg("dim", "/subagents"),
|
|
74
|
+
].filter(Boolean);
|
|
75
|
+
return truncateToWidth(parts.join(theme.fg("dim", " · ")), width);
|
|
76
|
+
}
|
|
77
|
+
notifyTerminal(id: string, message: string, level: "info" | "warn" = "info"): void {
|
|
78
|
+
if (this.notified.has(id)) return;
|
|
79
|
+
this.notified.add(id);
|
|
80
|
+
this.adapter.notify?.(message, level);
|
|
81
|
+
this.onUpdate?.();
|
|
82
|
+
}
|
|
83
|
+
notifyTransition(message: string, level: "info" | "warn" = "info"): void {
|
|
84
|
+
this.notifyTerminal(message, message, level);
|
|
85
|
+
}
|
|
86
|
+
dispose(): void { this.onUpdate = undefined; this.notified.clear(); }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function wrapLines(text: string, width: number): string[] {
|
|
90
|
+
const wrapped = wrapTextWithAnsi(text, Math.max(10, width));
|
|
91
|
+
return Array.isArray(wrapped) ? wrapped : String(wrapped).split("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function runStats(run: RunSnapshot, now: number): string {
|
|
95
|
+
let turns = 0, tokens = 0, cost = 0;
|
|
96
|
+
for (const result of run.results) {
|
|
97
|
+
turns += result.usage?.turns ?? 0;
|
|
98
|
+
tokens += (result.usage?.input ?? 0) + (result.usage?.output ?? 0);
|
|
99
|
+
cost += result.usage?.cost ?? 0;
|
|
100
|
+
}
|
|
101
|
+
const parts: string[] = [];
|
|
102
|
+
if (turns) parts.push(`↻${turns}`);
|
|
103
|
+
if (tokens) parts.push(`${formatTokens(tokens)} tok`);
|
|
104
|
+
if (cost > 0.00005) parts.push(formatCost(cost));
|
|
105
|
+
parts.push(formatDuration((run.endedAt ?? now) - run.startedAt));
|
|
106
|
+
return parts.join(" · ");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function runTitle(run: RunSnapshot): string {
|
|
110
|
+
const preview = run.taskPreviews[0] ?? run.summary ?? "";
|
|
111
|
+
const label = preview.includes(": ") ? preview.slice(preview.indexOf(": ") + 2) : preview;
|
|
112
|
+
return oneLine(label || "(no task preview)", 100);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class SubagentsOverlay implements Component {
|
|
116
|
+
wantsKeyRelease = false;
|
|
117
|
+
private selected = 0;
|
|
118
|
+
private detailId?: string;
|
|
119
|
+
private scroll = 0;
|
|
120
|
+
private frame = 0;
|
|
121
|
+
private timer?: NodeJS.Timeout;
|
|
122
|
+
private unsubscribe?: () => void;
|
|
123
|
+
private disposed = false;
|
|
124
|
+
/** Live child-session tail visible in the running-run detail view. */
|
|
125
|
+
private liveTranscript = false;
|
|
126
|
+
/** Auto-follow the tail unless the user has scrolled up. */
|
|
127
|
+
private transcriptFollow = true;
|
|
128
|
+
private transcriptLines: string[] = [];
|
|
129
|
+
private transcriptStatus: TailSessionStatus | "unset" = "unset";
|
|
130
|
+
private transcriptPoll?: NodeJS.Timeout;
|
|
131
|
+
|
|
132
|
+
constructor(
|
|
133
|
+
private readonly tui: TUI,
|
|
134
|
+
private readonly theme: Theme,
|
|
135
|
+
private readonly done: () => void,
|
|
136
|
+
private readonly adapter: SubagentAdapter,
|
|
137
|
+
) {
|
|
138
|
+
this.unsubscribe = this.adapter.subscribe?.(() => {
|
|
139
|
+
if (this.disposed) return;
|
|
140
|
+
this.syncAnimation();
|
|
141
|
+
this.invalidate();
|
|
142
|
+
this.tui.requestRender();
|
|
143
|
+
});
|
|
144
|
+
this.syncAnimation();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private runs(): RunSnapshot[] {
|
|
148
|
+
return [...this.adapter.getActiveRuns(), ...this.adapter.getCompletedRuns()];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private syncAnimation(): void {
|
|
152
|
+
const running = this.adapter.getActiveRuns().length > 0;
|
|
153
|
+
if (running && !this.timer && !this.disposed) {
|
|
154
|
+
this.timer = setInterval(() => {
|
|
155
|
+
this.frame = (this.frame + 1) % SPINNERS.length;
|
|
156
|
+
this.invalidate();
|
|
157
|
+
this.tui.requestRender();
|
|
158
|
+
if (this.adapter.getActiveRuns().length === 0) this.stopAnimation();
|
|
159
|
+
}, 100);
|
|
160
|
+
this.timer.unref?.();
|
|
161
|
+
} else if (!running) {
|
|
162
|
+
this.stopAnimation();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private stopAnimation(): void {
|
|
167
|
+
if (this.timer) clearInterval(this.timer);
|
|
168
|
+
this.timer = undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private stopTranscriptPoll(): void {
|
|
172
|
+
if (this.transcriptPoll) clearInterval(this.transcriptPoll);
|
|
173
|
+
this.transcriptPoll = undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private exitLiveTranscript(): void {
|
|
177
|
+
this.liveTranscript = false;
|
|
178
|
+
this.transcriptFollow = true;
|
|
179
|
+
this.transcriptLines = [];
|
|
180
|
+
this.transcriptStatus = "unset";
|
|
181
|
+
this.stopTranscriptPoll();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private refreshTranscript(): void {
|
|
185
|
+
if (this.disposed || !this.liveTranscript || !this.detailId) return;
|
|
186
|
+
const run = this.adapter.getRunById(this.detailId);
|
|
187
|
+
if (!run || !isActiveState(run.state)) {
|
|
188
|
+
// Finished/gone: drop live mode so the normal checkpointed detail shows.
|
|
189
|
+
this.exitLiveTranscript();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const path = this.adapter.getSessionFilePath?.(run.id);
|
|
193
|
+
if (!path) {
|
|
194
|
+
this.transcriptStatus = "missing";
|
|
195
|
+
this.transcriptLines = [];
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// Each backend writes its own transcript dialect; pick the matching renderer.
|
|
199
|
+
const backend = run.results.find((entry) => entry.sessionId)?.backend ?? "pi";
|
|
200
|
+
const result = tailSessionFile(path, undefined, undefined, sessionLineRenderer(backend));
|
|
201
|
+
this.transcriptStatus = result.status;
|
|
202
|
+
this.transcriptLines = result.lines;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private syncTranscriptPoll(): void {
|
|
206
|
+
const want =
|
|
207
|
+
!this.disposed &&
|
|
208
|
+
this.liveTranscript &&
|
|
209
|
+
!!this.detailId &&
|
|
210
|
+
(() => {
|
|
211
|
+
const run = this.adapter.getRunById(this.detailId!);
|
|
212
|
+
return !!run && isActiveState(run.state);
|
|
213
|
+
})();
|
|
214
|
+
if (!want) {
|
|
215
|
+
this.stopTranscriptPoll();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (this.transcriptPoll) return;
|
|
219
|
+
this.refreshTranscript();
|
|
220
|
+
this.transcriptPoll = setInterval(() => {
|
|
221
|
+
if (this.disposed) {
|
|
222
|
+
this.stopTranscriptPoll();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
this.refreshTranscript();
|
|
226
|
+
if (this.liveTranscript && this.transcriptFollow) {
|
|
227
|
+
// Keep the viewport glued to the newest lines while following.
|
|
228
|
+
this.scroll = Number.MAX_SAFE_INTEGER;
|
|
229
|
+
}
|
|
230
|
+
if (!this.liveTranscript) {
|
|
231
|
+
this.stopTranscriptPoll();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
this.invalidate();
|
|
235
|
+
this.tui.requestRender();
|
|
236
|
+
}, 500);
|
|
237
|
+
this.transcriptPoll.unref?.();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private handleAction(data: string, run: RunSnapshot | null): void {
|
|
241
|
+
if (!run) return;
|
|
242
|
+
if (data === "c" && isActiveState(run.state)) this.adapter.cancelRun(run.id);
|
|
243
|
+
if (data === "s" && isActiveState(run.state)) void this.adapter.steerRun?.(run.id);
|
|
244
|
+
if (data === "d") this.adapter.dismissRun(run.id);
|
|
245
|
+
if (data === "r") void this.adapter.resumeRun(run.id);
|
|
246
|
+
if (data === "o") this.adapter.showOutput(run.id);
|
|
247
|
+
const hasWorktree = run.results.some((result) => result.worktree?.changed);
|
|
248
|
+
if (data === "a" && !isActiveState(run.state) && hasWorktree) void this.adapter.applyWorktree?.(run.id);
|
|
249
|
+
if (data === "x" && !isActiveState(run.state) && hasWorktree) void this.adapter.discardWorktree?.(run.id);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
handleInput(data: string): void {
|
|
253
|
+
const runs = this.runs();
|
|
254
|
+
if (this.detailId) {
|
|
255
|
+
const detailRun = this.adapter.getRunById(this.detailId);
|
|
256
|
+
if (matchesKey(data, "escape") || matchesKey(data, "backspace") || data === "b") {
|
|
257
|
+
this.exitLiveTranscript();
|
|
258
|
+
this.detailId = undefined;
|
|
259
|
+
this.scroll = 0;
|
|
260
|
+
} else if (data === "t" && detailRun && isActiveState(detailRun.state)) {
|
|
261
|
+
this.liveTranscript = !this.liveTranscript;
|
|
262
|
+
this.transcriptFollow = true;
|
|
263
|
+
this.scroll = 0;
|
|
264
|
+
if (!this.liveTranscript) {
|
|
265
|
+
this.exitLiveTranscript();
|
|
266
|
+
} else {
|
|
267
|
+
this.syncTranscriptPoll();
|
|
268
|
+
}
|
|
269
|
+
} else if (matchesKey(data, "down") || data === "j") {
|
|
270
|
+
this.scroll++;
|
|
271
|
+
} else if (matchesKey(data, "up") || data === "k") {
|
|
272
|
+
this.scroll = Math.max(0, this.scroll - 1);
|
|
273
|
+
if (this.liveTranscript) this.transcriptFollow = false;
|
|
274
|
+
} else if (matchesKey(data, "pageDown")) {
|
|
275
|
+
this.scroll += 10;
|
|
276
|
+
} else if (matchesKey(data, "pageUp")) {
|
|
277
|
+
this.scroll = Math.max(0, this.scroll - 10);
|
|
278
|
+
if (this.liveTranscript) this.transcriptFollow = false;
|
|
279
|
+
} else {
|
|
280
|
+
// Steering (and other actions) remain available from the live transcript pane.
|
|
281
|
+
this.handleAction(data, detailRun);
|
|
282
|
+
}
|
|
283
|
+
} else if (matchesKey(data, "escape") || data === "q") {
|
|
284
|
+
this.close();
|
|
285
|
+
return;
|
|
286
|
+
} else if (matchesKey(data, "down") || data === "j") {
|
|
287
|
+
this.selected = Math.min(Math.max(0, runs.length - 1), this.selected + 1);
|
|
288
|
+
} else if (matchesKey(data, "up") || data === "k") {
|
|
289
|
+
this.selected = Math.max(0, this.selected - 1);
|
|
290
|
+
} else if (matchesKey(data, "enter") && runs[this.selected]) {
|
|
291
|
+
this.exitLiveTranscript();
|
|
292
|
+
this.detailId = runs[this.selected]!.id;
|
|
293
|
+
this.scroll = 0;
|
|
294
|
+
} else {
|
|
295
|
+
this.handleAction(data, runs[this.selected] ?? null);
|
|
296
|
+
}
|
|
297
|
+
this.syncAnimation();
|
|
298
|
+
this.syncTranscriptPoll();
|
|
299
|
+
this.invalidate();
|
|
300
|
+
this.tui.requestRender();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
private header(width: number): string[] {
|
|
307
|
+
const theme = this.theme;
|
|
308
|
+
const active = this.adapter.getActiveRuns().length;
|
|
309
|
+
const ready = this.adapter.getReadyCount();
|
|
310
|
+
const counters = [
|
|
311
|
+
active ? theme.fg("warning", `${active} running`) : "",
|
|
312
|
+
ready ? theme.fg("success", `${ready} ready`) : "",
|
|
313
|
+
].filter(Boolean).join(theme.fg("dim", " · "));
|
|
314
|
+
const title = theme.bold(theme.fg("accent", " Subagents"));
|
|
315
|
+
const lines = [truncateToWidth(counters ? `${title} ${counters}` : title, width)];
|
|
316
|
+
const usage = this.adapter.getUsageSummary?.();
|
|
317
|
+
if (usage) lines.push(truncateToWidth(theme.fg("muted", ` ${usage}`), width));
|
|
318
|
+
lines.push(theme.fg("dim", "─".repeat(Math.max(0, width))));
|
|
319
|
+
return lines;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private listLines(width: number): string[] {
|
|
323
|
+
const theme = this.theme;
|
|
324
|
+
const runs = this.runs();
|
|
325
|
+
const lines: string[] = [];
|
|
326
|
+
if (!runs.length) {
|
|
327
|
+
lines.push(theme.fg("muted", " No subagent runs in this branch."));
|
|
328
|
+
lines.push("");
|
|
329
|
+
lines.push(theme.fg("dim", " esc close"));
|
|
330
|
+
return lines;
|
|
331
|
+
}
|
|
332
|
+
this.selected = Math.min(this.selected, runs.length - 1);
|
|
333
|
+
const now = Date.now();
|
|
334
|
+
runs.forEach((run, index) => {
|
|
335
|
+
const isSelected = index === this.selected;
|
|
336
|
+
const cursor = isSelected ? theme.fg("accent", "▶") : " ";
|
|
337
|
+
const glyph = stateGlyph(run.state, theme, this.frame);
|
|
338
|
+
const id = theme.fg("dim", run.id.slice(0, 8));
|
|
339
|
+
const state = isActiveState(run.state)
|
|
340
|
+
? theme.fg("warning", formatState(run.state))
|
|
341
|
+
: ["failed", "lost"].includes(run.state)
|
|
342
|
+
? theme.fg("error", formatState(run.state))
|
|
343
|
+
: theme.fg(run.delivered ? "muted" : "success", run.delivered ? formatState(run.state) : `${formatState(run.state)} · ready`);
|
|
344
|
+
const mode = run.mode === "parallel" ? theme.fg("accent", `${run.results.length} tasks`) : "";
|
|
345
|
+
const models = [...new Set(run.results.flatMap((result) => result.attemptedModels ?? (result.model ? [result.model] : [])))];
|
|
346
|
+
const modelText = models.length ? theme.fg("dim", models.join(" → ")) : "";
|
|
347
|
+
const meta = [state, mode, modelText, theme.fg("dim", runStats(run, now))].filter(Boolean).join(theme.fg("dim", " · "));
|
|
348
|
+
lines.push(truncateToWidth(`${cursor} ${glyph} ${id} ${meta}`, width));
|
|
349
|
+
const title = runTitle(run);
|
|
350
|
+
const titleText = isSelected ? theme.fg("text", title) : theme.fg("muted", title);
|
|
351
|
+
lines.push(truncateToWidth(` ${titleText}`, width));
|
|
352
|
+
});
|
|
353
|
+
lines.push("");
|
|
354
|
+
lines.push(truncateToWidth(theme.fg("dim", " ↑↓ select · enter details · c cancel · s steer · o output · r resume · a apply · x discard · d dismiss · esc close"), width));
|
|
355
|
+
return lines;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private detailLines(width: number): string[] {
|
|
359
|
+
const theme = this.theme;
|
|
360
|
+
const run = this.detailId ? this.adapter.getRunById(this.detailId) : null;
|
|
361
|
+
if (!run) return [theme.fg("error", " Run no longer exists"), "", theme.fg("dim", " esc back")];
|
|
362
|
+
const now = Date.now();
|
|
363
|
+
const body: string[] = [];
|
|
364
|
+
|
|
365
|
+
const glyph = stateGlyph(run.state, theme, this.frame);
|
|
366
|
+
body.push(`${glyph} ${theme.fg("dim", run.id)}`);
|
|
367
|
+
body.push(theme.fg("dim", `${run.mode} · ${formatState(run.state)} · ${runStats(run, now)} · ${run.delivered ? "delivered" : "ready"}`));
|
|
368
|
+
|
|
369
|
+
if (this.liveTranscript && isActiveState(run.state)) {
|
|
370
|
+
body.push("");
|
|
371
|
+
const followTag = this.transcriptFollow ? "live · follow" : "live · paused";
|
|
372
|
+
body.push(theme.fg("accent", ` Transcript (${followTag})`));
|
|
373
|
+
body.push(theme.fg("dim", "─".repeat(Math.max(0, width - 1))));
|
|
374
|
+
if (this.transcriptStatus === "missing" || this.transcriptStatus === "unset") {
|
|
375
|
+
body.push(theme.fg("muted", " waiting for child session…"));
|
|
376
|
+
} else if (this.transcriptStatus === "empty" || !this.transcriptLines.length) {
|
|
377
|
+
body.push(theme.fg("muted", " (no messages yet)"));
|
|
378
|
+
} else {
|
|
379
|
+
for (const line of this.transcriptLines) {
|
|
380
|
+
for (const wrapped of wrapLines(line, width - 2)) {
|
|
381
|
+
body.push(` ${theme.fg("toolOutput", wrapped)}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
} else {
|
|
386
|
+
if (run.summary) {
|
|
387
|
+
body.push("");
|
|
388
|
+
for (const line of wrapLines(run.summary, width - 2)) body.push(theme.fg("text", line));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
run.results.forEach((result, index) => {
|
|
392
|
+
body.push("");
|
|
393
|
+
const rGlyph = stateGlyph(result.state, theme, this.frame);
|
|
394
|
+
const label = theme.bold(theme.fg("toolTitle", result.label || `task-${index + 1}`));
|
|
395
|
+
const caps = [
|
|
396
|
+
result.model,
|
|
397
|
+
result.profile ? `${result.profile}/${result.canWrite ? "RW" : "RO"}` : "",
|
|
398
|
+
result.thinking ? `thinking:${result.thinking}` : "",
|
|
399
|
+
].filter(Boolean).join(" · ");
|
|
400
|
+
body.push(truncateToWidth(`${rGlyph} ${label} ${theme.fg("dim", caps)}`, width));
|
|
401
|
+
const usage = result.usage;
|
|
402
|
+
const stats = [
|
|
403
|
+
usage?.turns ? `↻${usage.turns}` : "",
|
|
404
|
+
`${formatTokens((usage?.input ?? 0) + (usage?.output ?? 0))} tok`,
|
|
405
|
+
usage?.cost ? `$${usage.cost.toFixed(4)}` : "",
|
|
406
|
+
].filter(Boolean).join(" · ");
|
|
407
|
+
body.push(theme.fg("dim", ` ${stats}`));
|
|
408
|
+
const pointers = [
|
|
409
|
+
result.outputFile ? `→ ${formatPath(result.outputFile)}` : "",
|
|
410
|
+
result.sessionId ? `session ${result.sessionId.slice(0, 8)}` : "",
|
|
411
|
+
result.worktree ? `⎇ ${result.worktree.branch}` : "",
|
|
412
|
+
].filter(Boolean);
|
|
413
|
+
if (pointers.length) body.push(truncateToWidth(theme.fg("dim", ` ${pointers.join(" · ")}`), width));
|
|
414
|
+
if (result.errorMessage) {
|
|
415
|
+
for (const line of wrapLines(result.errorMessage, width - 2)) body.push(` ${theme.fg("error", line)}`);
|
|
416
|
+
}
|
|
417
|
+
const text = result.transcript || result.finalOutput;
|
|
418
|
+
if (text) {
|
|
419
|
+
for (const line of wrapLines(text, width - 2)) body.push(` ${theme.fg("toolOutput", line)}`);
|
|
420
|
+
} else if (!result.errorMessage) {
|
|
421
|
+
body.push(theme.fg("dim", " (no output)"));
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const pageSize = 24;
|
|
427
|
+
const maxScroll = Math.max(0, body.length - pageSize);
|
|
428
|
+
if (this.liveTranscript && this.transcriptFollow) {
|
|
429
|
+
this.scroll = maxScroll;
|
|
430
|
+
} else {
|
|
431
|
+
this.scroll = Math.max(0, Math.min(this.scroll, maxScroll));
|
|
432
|
+
// Reaching the end re-enables auto-follow for live tails.
|
|
433
|
+
if (this.liveTranscript && this.scroll >= maxScroll) this.transcriptFollow = true;
|
|
434
|
+
}
|
|
435
|
+
const visible = body.slice(this.scroll, this.scroll + pageSize);
|
|
436
|
+
const lines = visible.map((line) => truncateToWidth(line, width));
|
|
437
|
+
if (body.length > pageSize) {
|
|
438
|
+
lines.push(theme.fg("dim", ` ${this.scroll + visible.length}/${body.length} lines`));
|
|
439
|
+
}
|
|
440
|
+
lines.push("");
|
|
441
|
+
const liveHelp = isActiveState(run.state)
|
|
442
|
+
? (this.liveTranscript
|
|
443
|
+
? " ↑↓ scroll · t hide transcript · esc back · c cancel · s steer · o output"
|
|
444
|
+
: " ↑↓ scroll · t transcript · esc back · c cancel · s steer · r resume · o output · a apply · x discard · d dismiss")
|
|
445
|
+
: " ↑↓ scroll · esc back · c cancel · s steer · r resume · o output · a apply · x discard · d dismiss";
|
|
446
|
+
lines.push(truncateToWidth(theme.fg("dim", liveHelp), width));
|
|
447
|
+
return lines;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
render(width: number): string[] {
|
|
451
|
+
this.syncAnimation();
|
|
452
|
+
this.syncTranscriptPoll();
|
|
453
|
+
const lines = this.header(width);
|
|
454
|
+
lines.push(...(this.detailId ? this.detailLines(width) : this.listLines(width)));
|
|
455
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
invalidate(): void {
|
|
459
|
+
// Stateless rendering; method required by Component.
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
close(): void {
|
|
463
|
+
if (this.disposed) return;
|
|
464
|
+
this.dispose();
|
|
465
|
+
this.done();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
dispose(): void {
|
|
469
|
+
this.disposed = true;
|
|
470
|
+
this.stopAnimation();
|
|
471
|
+
this.stopTranscriptPoll();
|
|
472
|
+
this.unsubscribe?.();
|
|
473
|
+
this.unsubscribe = undefined;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function createSubagentsOverlay(tui: TUI, theme: Theme, adapter: SubagentAdapter, done: () => void): SubagentsOverlay {
|
|
478
|
+
return new SubagentsOverlay(tui, theme, done, adapter);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Small pure model retained for regression tests. */
|
|
482
|
+
export class SubagentsUIModel {
|
|
483
|
+
state = {
|
|
484
|
+
listMode: true,
|
|
485
|
+
selectedIndex: 0,
|
|
486
|
+
scrollOffset: 0,
|
|
487
|
+
expanded: false,
|
|
488
|
+
detailId: undefined as string | undefined,
|
|
489
|
+
/** Live transcript pane on a running run's detail view. */
|
|
490
|
+
liveTranscript: false,
|
|
491
|
+
/** Auto-follow newest lines unless the user scrolls up. */
|
|
492
|
+
transcriptFollow: true,
|
|
493
|
+
};
|
|
494
|
+
constructor(private readonly adapter: SubagentAdapter) {}
|
|
495
|
+
get runs(): RunSnapshot[] { return [...this.adapter.getActiveRuns(), ...this.adapter.getCompletedRuns()]; }
|
|
496
|
+
select(index: number): void { this.state.selectedIndex = Math.max(0, Math.min(index, Math.max(0, this.runs.length - 1))); }
|
|
497
|
+
toggleExpanded(): void { this.state.expanded = !this.state.expanded; }
|
|
498
|
+
drillDown(): void {
|
|
499
|
+
const run = this.runs[this.state.selectedIndex];
|
|
500
|
+
if (run) {
|
|
501
|
+
this.state.listMode = false;
|
|
502
|
+
this.state.detailId = run.id;
|
|
503
|
+
this.state.liveTranscript = false;
|
|
504
|
+
this.state.transcriptFollow = true;
|
|
505
|
+
this.state.scrollOffset = 0;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
goBack(): void {
|
|
509
|
+
this.state.listMode = true;
|
|
510
|
+
this.state.detailId = undefined;
|
|
511
|
+
this.state.liveTranscript = false;
|
|
512
|
+
this.state.transcriptFollow = true;
|
|
513
|
+
this.state.scrollOffset = 0;
|
|
514
|
+
}
|
|
515
|
+
/** Toggle live transcript for the active detail run when it is still running. */
|
|
516
|
+
toggleLiveTranscript(): boolean {
|
|
517
|
+
if (this.state.listMode || !this.state.detailId) return false;
|
|
518
|
+
const run = this.adapter.getRunById(this.state.detailId);
|
|
519
|
+
if (!run || !isActiveState(run.state)) return false;
|
|
520
|
+
this.state.liveTranscript = !this.state.liveTranscript;
|
|
521
|
+
this.state.transcriptFollow = true;
|
|
522
|
+
this.state.scrollOffset = 0;
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
scrollUp(amount = 1): void {
|
|
526
|
+
this.state.scrollOffset = Math.max(0, this.state.scrollOffset - amount);
|
|
527
|
+
if (this.state.liveTranscript) this.state.transcriptFollow = false;
|
|
528
|
+
}
|
|
529
|
+
scrollDown(amount = 1): void {
|
|
530
|
+
this.state.scrollOffset += amount;
|
|
531
|
+
// Down toward the end does not pause follow; up does.
|
|
532
|
+
}
|
|
533
|
+
simulateKey(key: string): UIAction | null {
|
|
534
|
+
if (key === "Escape") return { type: "close" };
|
|
535
|
+
if (key === "Enter") { this.drillDown(); return { type: "select", id: this.state.detailId }; }
|
|
536
|
+
if (key === "t") {
|
|
537
|
+
if (this.toggleLiveTranscript()) return { type: "transcript", id: this.state.detailId };
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
if (key === "c") return { type: "cancel", id: this.runs[this.state.selectedIndex]?.id };
|
|
541
|
+
if (key === "s" && this.state.detailId) return { type: "steer", id: this.state.detailId };
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
isRunning(): boolean { return this.runs.some((run) => isActiveState(run.state)); }
|
|
545
|
+
}
|