@d3ara1n/pi-subagent 1.7.1 → 2.1.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/README.md +18 -9
- package/package.json +1 -1
- package/preview.png +0 -0
- package/src/index.ts +35 -10
- package/src/render-async.ts +57 -3
- package/src/roles.ts +4 -2
- package/src/run.ts +2 -0
- package/src/spawn.test.ts +58 -1
- package/src/spawn.ts +102 -53
- package/src/types.ts +25 -2
- package/src/utils.test.ts +104 -0
- package/src/utils.ts +85 -0
- package/src/view.ts +304 -51
package/src/view.ts
CHANGED
|
@@ -9,43 +9,71 @@
|
|
|
9
9
|
* adjacent spinning lines. Streamed assistant text is the single growing
|
|
10
10
|
* element: it renders as the run's last line and freezes at message_end.
|
|
11
11
|
*
|
|
12
|
+
* The focused run has two full-width pages, toggled with `d`:
|
|
13
|
+
* - activity (default): the live feed, scrollable with ↑/↓/PgUp/PgDn. The
|
|
14
|
+
* view pins to the end and auto-follows new entries; scrolling up unpins
|
|
15
|
+
* (a "⋮ N earlier" marker appears), reaching the bottom again (or End)
|
|
16
|
+
* re-pins.
|
|
17
|
+
* - brief: the run's inputs and vitals — task and context verbatim (wrapped;
|
|
18
|
+
* head+tail elided when huge), the reference file list annotated with ✓/·
|
|
19
|
+
* for whether the child's tool calls touched each file, usage and time
|
|
20
|
+
* stats, the fallback trace, and a stderr tail on failures.
|
|
21
|
+
*
|
|
22
|
+
* Steer input is modal so keys never conflict with the editor: browse mode
|
|
23
|
+
* owns navigation; `s` opens the editor (Enter sends and returns to browse,
|
|
24
|
+
* Esc cancels and clears). Esc in browse closes the overlay; Tab cycles
|
|
25
|
+
* the focused run and resets scrolls (activity re-pins, brief returns to
|
|
26
|
+
* the top).
|
|
27
|
+
*
|
|
12
28
|
* Layout: a centered screen overlay (overlay:true) occupying most of the
|
|
13
29
|
* terminal, framed with a thin border. An embedded Editor accepts steering
|
|
14
|
-
* input for the focused run
|
|
15
|
-
*
|
|
16
|
-
*
|
|
30
|
+
* input for the focused run; Enter queues the message through the run's RPC
|
|
31
|
+
* stdin channel — delivered after the child's current tool batch, before its
|
|
32
|
+
* next LLM call.
|
|
17
33
|
*/
|
|
18
34
|
|
|
19
35
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
20
36
|
import {
|
|
37
|
+
decodeKittyPrintable,
|
|
21
38
|
Editor,
|
|
22
39
|
type Component,
|
|
23
40
|
type EditorTheme,
|
|
24
41
|
type Focusable,
|
|
25
42
|
Key,
|
|
26
43
|
matchesKey,
|
|
44
|
+
stripTerminalSequences,
|
|
27
45
|
truncateToWidth,
|
|
28
46
|
visibleWidth,
|
|
47
|
+
wrapTextWithAnsi,
|
|
29
48
|
} from "@earendil-works/pi-tui";
|
|
30
49
|
import type { RunHandle } from "./run.ts";
|
|
31
50
|
import type { ActivityEntry } from "./types.ts";
|
|
32
51
|
import {
|
|
52
|
+
briefFilesUsed,
|
|
53
|
+
formatFallback,
|
|
33
54
|
formatThinking,
|
|
34
55
|
formatTimePart,
|
|
35
56
|
formatToolCall,
|
|
57
|
+
formatTokens,
|
|
36
58
|
formatUsageStats,
|
|
37
59
|
runIcon,
|
|
60
|
+
shortenPath,
|
|
38
61
|
statusStyle,
|
|
39
62
|
taskPreview,
|
|
40
63
|
} from "./utils.ts";
|
|
41
64
|
|
|
42
|
-
/** Max body lines kept visible; older
|
|
65
|
+
/** Max body lines kept visible; older lines scroll off behind a marker. */
|
|
43
66
|
const VIEWPORT_LINES = 26;
|
|
44
67
|
/** Animation tick for the running-entry ellipsis. */
|
|
45
68
|
const ANIMATION_INTERVAL_MS = 150;
|
|
69
|
+
/** Brief-page text (task/context) is elided to head+tail beyond this many
|
|
70
|
+
* chars, so huge contexts stay cheap to re-wrap on every animation tick. */
|
|
71
|
+
const BRIEF_TEXT_CAP = 20_000;
|
|
46
72
|
|
|
47
73
|
type TuiLike = { requestRender(): void };
|
|
48
74
|
type Fg = (color: string, text: string) => string;
|
|
75
|
+
type Page = "activity" | "brief";
|
|
76
|
+
type Mode = "browse" | "steer";
|
|
49
77
|
|
|
50
78
|
/** Pad a string with trailing spaces to a visible width (left-justified). */
|
|
51
79
|
function padRight(s: string, width: number): string {
|
|
@@ -58,6 +86,28 @@ function dots(): string {
|
|
|
58
86
|
return ".".repeat(1 + (Math.floor(Date.now() / 300) % 3));
|
|
59
87
|
}
|
|
60
88
|
|
|
89
|
+
/** Normalize one keypress to its printable character (Kitty CSI-u aware). */
|
|
90
|
+
function printableChar(data: string): string | undefined {
|
|
91
|
+
const kitty = decodeKittyPrintable(data);
|
|
92
|
+
if (kitty !== undefined) return kitty;
|
|
93
|
+
return data.length === 1 && data >= " " && data <= "~" ? data : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Center a string within `width` visible columns (pad-right handles the tail). */
|
|
97
|
+
function centerText(s: string, width: number): string {
|
|
98
|
+
const pad = Math.max(0, Math.floor((width - visibleWidth(s)) / 2));
|
|
99
|
+
return " ".repeat(pad) + s;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Elide oversized brief text to head + tail around an elision marker. */
|
|
103
|
+
function capBriefText(text: string): string {
|
|
104
|
+
if (text.length <= BRIEF_TEXT_CAP) return text;
|
|
105
|
+
const head = text.slice(0, Math.floor(BRIEF_TEXT_CAP * 0.8));
|
|
106
|
+
const tail = text.slice(-Math.floor(BRIEF_TEXT_CAP * 0.2));
|
|
107
|
+
const elided = text.length - head.length - tail.length;
|
|
108
|
+
return `${head}\n… [${formatTokens(elided)} chars elided] …\n${tail}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
61
111
|
/**
|
|
62
112
|
* Build the display list of runs for the panel: running/queued first, then
|
|
63
113
|
* finished, each group ordered by registry id.
|
|
@@ -75,8 +125,19 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
75
125
|
private tui: TuiLike;
|
|
76
126
|
private close: () => void;
|
|
77
127
|
private editor: Editor;
|
|
78
|
-
/** Focused run (Tab cycles); the whole viewport belongs to it. */
|
|
79
|
-
private
|
|
128
|
+
/** Focused run id (Tab cycles); the whole viewport belongs to it. */
|
|
129
|
+
private focusId: string | null = null;
|
|
130
|
+
/** browse = navigation keys; steer = the editor owns input. */
|
|
131
|
+
private mode: Mode = "browse";
|
|
132
|
+
/** Focused run's visible page. */
|
|
133
|
+
private page: Page = "activity";
|
|
134
|
+
/** Activity scroll: null = pinned to the end (auto-follow), else the
|
|
135
|
+
* index of the first visible entry line. */
|
|
136
|
+
private activityTop: number | null = null;
|
|
137
|
+
/** Brief scroll: index of the first visible line (clamped in render). */
|
|
138
|
+
private briefTop = 0;
|
|
139
|
+
/** Brief scroll ceiling, recomputed each render (content is static). */
|
|
140
|
+
private briefMax = 0;
|
|
80
141
|
/** Transient feedback line ("steer sent to sub-N"), auto-clears. */
|
|
81
142
|
private flash = "";
|
|
82
143
|
private flashUntil = 0;
|
|
@@ -116,11 +177,38 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
116
177
|
}, ANIMATION_INTERVAL_MS);
|
|
117
178
|
}
|
|
118
179
|
|
|
180
|
+
/** Resolve the focused run by id; stable across sort-order reshuffles
|
|
181
|
+
* (e.g. a run finishing re-ranks the list). Scroll state resets only when
|
|
182
|
+
* the focused run actually changes. */
|
|
119
183
|
private focusedRun(): RunHandle | undefined {
|
|
120
184
|
const runs = sortViewRuns(this.runsProvider());
|
|
121
|
-
if (runs.length === 0)
|
|
122
|
-
|
|
123
|
-
|
|
185
|
+
if (runs.length === 0) {
|
|
186
|
+
this.focusId = null;
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
let run = runs.find((r) => r.id === this.focusId);
|
|
190
|
+
if (!run) {
|
|
191
|
+
run = runs[0];
|
|
192
|
+
this.focusId = run.id;
|
|
193
|
+
this.resetScrolls();
|
|
194
|
+
}
|
|
195
|
+
return run;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private resetScrolls(): void {
|
|
199
|
+
this.activityTop = null;
|
|
200
|
+
this.briefTop = 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private cycleRun(): void {
|
|
204
|
+
const runs = sortViewRuns(this.runsProvider());
|
|
205
|
+
if (runs.length < 2) return;
|
|
206
|
+
const idx = runs.findIndex((r) => r.id === this.focusId);
|
|
207
|
+
const next = runs[((idx >= 0 ? idx : 0) + 1) % runs.length];
|
|
208
|
+
if (next.id === this.focusId) return;
|
|
209
|
+
this.focusId = next.id;
|
|
210
|
+
this.resetScrolls();
|
|
211
|
+
this.tui.requestRender();
|
|
124
212
|
}
|
|
125
213
|
|
|
126
214
|
private submitSteer(value: string): void {
|
|
@@ -134,6 +222,7 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
134
222
|
target.steer(text);
|
|
135
223
|
this.editor.setText("");
|
|
136
224
|
this.showFlash(`steer queued for ${target.id} (${target.role})`);
|
|
225
|
+
this.mode = "browse";
|
|
137
226
|
}
|
|
138
227
|
|
|
139
228
|
private showFlash(message: string): void {
|
|
@@ -142,19 +231,66 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
142
231
|
}
|
|
143
232
|
|
|
144
233
|
handleInput(data: string): void {
|
|
234
|
+
if (this.mode === "steer") {
|
|
235
|
+
// Everything types into the editor; Esc cancels (never closes the panel).
|
|
236
|
+
if (matchesKey(data, Key.escape)) {
|
|
237
|
+
this.editor.setText("");
|
|
238
|
+
this.mode = "browse";
|
|
239
|
+
this.tui.requestRender();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this.editor.handleInput(data);
|
|
243
|
+
this.tui.requestRender();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
145
247
|
if (matchesKey(data, Key.escape)) {
|
|
146
248
|
this.closePanel();
|
|
147
249
|
return;
|
|
148
250
|
}
|
|
149
251
|
if (matchesKey(data, Key.tab)) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
252
|
+
this.cycleRun();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const ch = printableChar(data);
|
|
256
|
+
if (ch === "d") {
|
|
257
|
+
this.page = this.page === "activity" ? "brief" : "activity";
|
|
258
|
+
this.tui.requestRender();
|
|
155
259
|
return;
|
|
156
260
|
}
|
|
157
|
-
|
|
261
|
+
if (ch === "s") {
|
|
262
|
+
this.mode = "steer";
|
|
263
|
+
this.tui.requestRender();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const page = Math.max(3, this.browseBudget() - 1);
|
|
267
|
+
if (matchesKey(data, Key.up)) this.scrollBy(-1);
|
|
268
|
+
else if (matchesKey(data, Key.down)) this.scrollBy(1);
|
|
269
|
+
else if (matchesKey(data, Key.pageUp)) this.scrollBy(-page);
|
|
270
|
+
else if (matchesKey(data, Key.pageDown)) this.scrollBy(page);
|
|
271
|
+
else if (matchesKey(data, Key.home)) {
|
|
272
|
+
if (this.page === "activity") this.activityTop = 0;
|
|
273
|
+
else this.briefTop = 0;
|
|
274
|
+
this.tui.requestRender();
|
|
275
|
+
} else if (matchesKey(data, Key.end)) {
|
|
276
|
+
if (this.page === "activity") this.activityTop = null;
|
|
277
|
+
else this.briefTop = this.briefMax;
|
|
278
|
+
this.tui.requestRender();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Scroll the visible page by `delta` lines. Activity pins back to the end
|
|
283
|
+
* once the bottom is reached (auto-follow resumes). */
|
|
284
|
+
private scrollBy(delta: number): void {
|
|
285
|
+
if (this.page === "activity") {
|
|
286
|
+
const n = this.focusedRun()?.snapshot.activityLog.length ?? 0;
|
|
287
|
+
const maxTop = Math.max(0, n - this.browseBudget());
|
|
288
|
+
const cur = this.activityTop ?? maxTop;
|
|
289
|
+
const next = Math.max(0, Math.min(maxTop, cur + delta));
|
|
290
|
+
this.activityTop = next >= maxTop ? null : next;
|
|
291
|
+
} else {
|
|
292
|
+
this.briefTop = Math.max(0, Math.min(this.briefMax, this.briefTop + delta));
|
|
293
|
+
}
|
|
158
294
|
this.tui.requestRender();
|
|
159
295
|
}
|
|
160
296
|
|
|
@@ -166,6 +302,11 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
166
302
|
this.close();
|
|
167
303
|
}
|
|
168
304
|
|
|
305
|
+
/** Content line budget in browse mode (tab row + header + steer hint + key hint). */
|
|
306
|
+
private browseBudget(): number {
|
|
307
|
+
return Math.max(3, VIEWPORT_LINES - 4);
|
|
308
|
+
}
|
|
309
|
+
|
|
169
310
|
/** Render one activity entry as a static line; running entries get the
|
|
170
311
|
* animated ellipsis suffix. */
|
|
171
312
|
private renderEntry(e: ActivityEntry, width: number, fg: Fg): string {
|
|
@@ -197,6 +338,88 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
197
338
|
return truncateToWidth(indent + line, width);
|
|
198
339
|
}
|
|
199
340
|
|
|
341
|
+
/** Render the brief page's full content (pre-scroll): task/context verbatim,
|
|
342
|
+
* annotated file list, stats, fallback trace, failure stderr tail. */
|
|
343
|
+
private renderBriefLines(run: RunHandle, width: number, fg: Fg): string[] {
|
|
344
|
+
const snap = run.snapshot;
|
|
345
|
+
const lines: string[] = [];
|
|
346
|
+
const section = (title: string) => {
|
|
347
|
+
const label = `── ${title} `;
|
|
348
|
+
lines.push(fg("dim", label + "─".repeat(Math.max(0, width - visibleWidth(label)))));
|
|
349
|
+
};
|
|
350
|
+
const body = (text: string, color?: string) => {
|
|
351
|
+
for (const ln of wrapTextWithAnsi(capBriefText(text), width - 2)) {
|
|
352
|
+
lines.push(color ? ` ${fg(color, ln)}` : ` ${ln}`);
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
section("task");
|
|
357
|
+
body(run.task);
|
|
358
|
+
|
|
359
|
+
if (run.context) {
|
|
360
|
+
section(`context · ${formatTokens(run.context.length)} chars`);
|
|
361
|
+
body(run.context);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (run.files && run.files.length > 0) {
|
|
365
|
+
section(`files · ${run.files.length}`);
|
|
366
|
+
const used = briefFilesUsed(run.files, snap.activityLog);
|
|
367
|
+
for (const f of run.files) {
|
|
368
|
+
lines.push(
|
|
369
|
+
used.get(f)
|
|
370
|
+
? fg("success", ` ✓ ${shortenPath(f)}`)
|
|
371
|
+
: fg("muted", ` · ${shortenPath(f)}`),
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
section("stats");
|
|
377
|
+
const usage = formatUsageStats(snap.usage, snap.model);
|
|
378
|
+
lines.push(fg("dim", ` ${usage || "no usage yet"}`));
|
|
379
|
+
const time = formatTimePart({ ...snap, exitCode: run.state === "queued" ? -1 : snap.exitCode });
|
|
380
|
+
if (time) lines.push(fg("dim", ` ${time}`));
|
|
381
|
+
if (run.state === "finished" || run.state === "failed") {
|
|
382
|
+
lines.push(fg("dim", ` exit ${snap.exitCode}${snap.stopReason ? ` · ${snap.stopReason}` : ""}`));
|
|
383
|
+
}
|
|
384
|
+
if (snap.fallbackFrom) {
|
|
385
|
+
lines.push(fg("warning", ` ⚠ ${formatFallback(snap.fallbackFrom)}`));
|
|
386
|
+
}
|
|
387
|
+
if (run.state === "failed") {
|
|
388
|
+
if (snap.errorMessage) body(snap.errorMessage, "error");
|
|
389
|
+
const tail = stripTerminalSequences(snap.stderr)
|
|
390
|
+
.split("\n")
|
|
391
|
+
.map((l) => l.trim())
|
|
392
|
+
.filter(Boolean)
|
|
393
|
+
.slice(-6);
|
|
394
|
+
if (tail.length > 0) {
|
|
395
|
+
lines.push(fg("dim", " stderr (tail):"));
|
|
396
|
+
for (const l of tail) lines.push(truncateToWidth(fg("muted", ` ${l}`), width));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return lines;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Push the focused run's current page (activity or brief) as framed rows. */
|
|
403
|
+
private renderPage(run: RunHandle, budget: number, innerW: number, fg: Fg, row: (s: string) => string): string[] {
|
|
404
|
+
const lines: string[] = [];
|
|
405
|
+
if (this.page === "activity") {
|
|
406
|
+
const entries = run.snapshot.activityLog.map((e) => this.renderEntry(e, innerW, fg));
|
|
407
|
+
const maxTop = Math.max(0, entries.length - budget);
|
|
408
|
+
const top = this.activityTop === null ? maxTop : Math.min(this.activityTop, maxTop);
|
|
409
|
+
if (top > 0) lines.push(row(fg("muted", `⋮ ${top} earlier`)));
|
|
410
|
+
for (const ln of entries.slice(top, top + budget - (top > 0 ? 1 : 0))) lines.push(row(ln));
|
|
411
|
+
return lines;
|
|
412
|
+
}
|
|
413
|
+
const all = this.renderBriefLines(run, innerW, fg);
|
|
414
|
+
this.briefMax = Math.max(0, all.length - budget);
|
|
415
|
+
this.briefTop = Math.min(this.briefTop, this.briefMax);
|
|
416
|
+
if (this.briefTop > 0) lines.push(row(fg("muted", `⋮ ${this.briefTop} earlier`)));
|
|
417
|
+
for (const ln of all.slice(this.briefTop, this.briefTop + budget - (this.briefTop > 0 ? 1 : 0))) {
|
|
418
|
+
lines.push(row(ln));
|
|
419
|
+
}
|
|
420
|
+
return lines;
|
|
421
|
+
}
|
|
422
|
+
|
|
200
423
|
render(width: number): string[] {
|
|
201
424
|
const th = this.theme;
|
|
202
425
|
// Same adaptation render.ts uses: utils formatters take a loose Fg.
|
|
@@ -210,16 +433,18 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
210
433
|
|
|
211
434
|
const runs = sortViewRuns(this.runsProvider());
|
|
212
435
|
const lines: string[] = [];
|
|
213
|
-
|
|
214
436
|
const runningCount = runs.filter((r) => r.state === "running").length;
|
|
437
|
+
const focused = this.focusedRun();
|
|
215
438
|
|
|
216
439
|
// ── Tab row: one cell per run; the focused one is highlighted. ──
|
|
217
440
|
if (runs.length > 0) {
|
|
218
|
-
|
|
219
|
-
|
|
441
|
+
// Brackets stay on every cell, focused included — the selectedBg +
|
|
442
|
+
// accent highlight is the indicator, so Tab doesn't shift text.
|
|
443
|
+
const cells = runs.map((r) => {
|
|
444
|
+
const isFocused = r === focused;
|
|
220
445
|
const label = `${runIcon(r.snapshot, fg)} ${r.id} ${r.role}`;
|
|
221
|
-
const styled =
|
|
222
|
-
return
|
|
446
|
+
const styled = isFocused ? th.bg("selectedBg", fg("accent", label)) : fg("dim", label);
|
|
447
|
+
return `[${styled}]`;
|
|
223
448
|
});
|
|
224
449
|
lines.push(
|
|
225
450
|
row(
|
|
@@ -228,47 +453,75 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
228
453
|
),
|
|
229
454
|
);
|
|
230
455
|
} else {
|
|
231
|
-
|
|
456
|
+
// Empty registry — every run left the view (a run disappears once its
|
|
457
|
+
// result is in the conversation). Give the state real presence — a
|
|
458
|
+
// full-size panel with a centered message and the close hint — and
|
|
459
|
+
// fold steer mode back to browse so Esc closes immediately.
|
|
460
|
+
if (this.mode === "steer") {
|
|
461
|
+
this.editor.setText("");
|
|
462
|
+
this.mode = "browse";
|
|
463
|
+
}
|
|
464
|
+
lines.push(row(""));
|
|
465
|
+
lines.push(row(fg("muted", centerText("no subagent runs", innerW))));
|
|
466
|
+
lines.push(
|
|
467
|
+
row(
|
|
468
|
+
fg(
|
|
469
|
+
"dim",
|
|
470
|
+
centerText("a run leaves the view once its result is in the conversation", innerW),
|
|
471
|
+
),
|
|
472
|
+
),
|
|
473
|
+
);
|
|
474
|
+
lines.push(row(""));
|
|
475
|
+
lines.push(row(""));
|
|
476
|
+
lines.push(row(fg("dim", "Esc close")));
|
|
232
477
|
}
|
|
233
478
|
|
|
234
|
-
// ── Focused run:
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const run = this.focusedRun();
|
|
238
|
-
if (run) {
|
|
239
|
-
const snap = run.snapshot;
|
|
479
|
+
// ── Focused run: header line, then the current page. ──
|
|
480
|
+
if (focused) {
|
|
481
|
+
const snap = focused.snapshot;
|
|
240
482
|
const icon = runIcon(snap, fg);
|
|
241
|
-
const time = formatTimePart({ ...snap, exitCode:
|
|
483
|
+
const time = formatTimePart({ ...snap, exitCode: focused.state === "queued" ? -1 : snap.exitCode });
|
|
484
|
+
const inputBits: string[] = [];
|
|
485
|
+
if (focused.files?.length) {
|
|
486
|
+
inputBits.push(`${focused.files.length} file${focused.files.length === 1 ? "" : "s"}`);
|
|
487
|
+
}
|
|
488
|
+
if (focused.context) inputBits.push(`context ${formatTokens(focused.context.length)}`);
|
|
242
489
|
const parts = [
|
|
243
|
-
`${icon} ${fg("accent", th.bold(
|
|
244
|
-
fg("text",
|
|
245
|
-
fg("dim", taskPreview(
|
|
490
|
+
`${icon} ${fg("accent", th.bold(focused.id))}`,
|
|
491
|
+
fg("text", focused.role),
|
|
492
|
+
fg("dim", taskPreview(focused.task)),
|
|
493
|
+
...(inputBits.length > 0 ? [fg("dim", inputBits.join(" · "))] : []),
|
|
246
494
|
time ? fg("dim", time) : "",
|
|
247
495
|
fg("dim", formatUsageStats(snap.usage, snap.model)),
|
|
248
496
|
].filter(Boolean);
|
|
249
497
|
lines.push(row(parts.join(th.fg("dim", " · "))));
|
|
250
|
-
const entries = snap.activityLog.map((entry) => this.renderEntry(entry, innerW, fg));
|
|
251
|
-
if (entries.length > budget) {
|
|
252
|
-
entries.splice(0, entries.length - budget);
|
|
253
|
-
lines.push(row(fg("muted", "⋮ earlier activity")));
|
|
254
|
-
}
|
|
255
|
-
for (const ln of entries.slice(0, budget)) lines.push(row(ln));
|
|
256
|
-
}
|
|
257
498
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
499
|
+
if (this.mode === "steer") {
|
|
500
|
+
const edLines = this.editor.render(innerW);
|
|
501
|
+
const budget = Math.max(3, VIEWPORT_LINES - 3 - edLines.length);
|
|
502
|
+
lines.push(...this.renderPage(focused, budget, innerW, fg, row));
|
|
503
|
+
const label =
|
|
504
|
+
focused.state === "running"
|
|
505
|
+
? fg("accent", `${focused.id} (${focused.role})`)
|
|
506
|
+
: fg("dim", focused.state === "queued" ? `${focused.id} still queued` : `${focused.id} not running`);
|
|
507
|
+
lines.push(row(fg("dim", `steer → ${label}`)));
|
|
508
|
+
for (const el of edLines) lines.push(row(el));
|
|
509
|
+
lines.push(row(fg("dim", "Enter send · Esc cancel")));
|
|
510
|
+
} else {
|
|
511
|
+
lines.push(...this.renderPage(focused, this.browseBudget(), innerW, fg, row));
|
|
512
|
+
if (Date.now() < this.flashUntil) {
|
|
513
|
+
lines.push(row(fg("success", this.flash)));
|
|
514
|
+
} else {
|
|
515
|
+
const label =
|
|
516
|
+
focused.state === "running"
|
|
517
|
+
? fg("accent", `${focused.id} (${focused.role})`)
|
|
518
|
+
: fg("dim", focused.state === "queued" ? `${focused.id} still queued` : `${focused.id} not running`);
|
|
519
|
+
lines.push(row(fg("dim", `steer → ${label} · press s`)));
|
|
520
|
+
}
|
|
521
|
+
const pageKey = this.page === "activity" ? "d brief" : "d activity";
|
|
522
|
+
lines.push(row(fg("dim", `↑↓ scroll · ${pageKey} · Tab run · s steer · Esc close`)));
|
|
523
|
+
}
|
|
270
524
|
}
|
|
271
|
-
lines.push(row(fg("dim", "Enter steer · Tab switch run · Esc close")));
|
|
272
525
|
|
|
273
526
|
// Frame.
|
|
274
527
|
return [
|