@opencode-cockpit/subagents 0.7.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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +107 -0
  3. package/dist/agent/plugin.js +134 -0
  4. package/dist/cli/preview.js +99 -0
  5. package/dist/core/adapt/v1.js +341 -0
  6. package/dist/core/adapt/v2.js +379 -0
  7. package/dist/core/model/changes.js +17 -0
  8. package/dist/core/model/model.js +301 -0
  9. package/dist/core/sample.js +261 -0
  10. package/dist/core/view/markdown.js +211 -0
  11. package/dist/core/view/report.js +58 -0
  12. package/dist/core/view/rows.js +146 -0
  13. package/dist/core/view/screen.js +767 -0
  14. package/dist/core/view/sidebar.js +151 -0
  15. package/dist/server.js +2 -0
  16. package/dist/tui/index.js +991 -0
  17. package/dist/tui/render.js +103 -0
  18. package/dist/tui/source.js +228 -0
  19. package/dist/tui/view/overlay.js +87 -0
  20. package/dist/tui/view/sidebar.js +78 -0
  21. package/package.json +64 -0
  22. package/server.js +6 -0
  23. package/tui.js +6 -0
  24. package/types/agent/plugin.d.ts +32 -0
  25. package/types/cli/preview.d.ts +10 -0
  26. package/types/core/adapt/v1.d.ts +33 -0
  27. package/types/core/adapt/v2.d.ts +22 -0
  28. package/types/core/model/changes.d.ts +98 -0
  29. package/types/core/model/model.d.ts +99 -0
  30. package/types/core/sample.d.ts +8 -0
  31. package/types/core/view/markdown.d.ts +22 -0
  32. package/types/core/view/report.d.ts +15 -0
  33. package/types/core/view/rows.d.ts +43 -0
  34. package/types/core/view/screen.d.ts +101 -0
  35. package/types/core/view/sidebar.d.ts +30 -0
  36. package/types/server.d.ts +2 -0
  37. package/types/tui/index.d.ts +26 -0
  38. package/types/tui/render.d.ts +27 -0
  39. package/types/tui/source.d.ts +45 -0
  40. package/types/tui/view/overlay.d.ts +30 -0
  41. package/types/tui/view/sidebar.d.ts +22 -0
@@ -0,0 +1,767 @@
1
+ /**
2
+ * One subagent, in the pane: its run as a timeline you can move through and open.
3
+ *
4
+ * ▌⠙ EXPLORE Scan architecture opportunities running 3m32s
5
+ * space-bunny-free · background · launched by build · 108 calls · 14 steps · 2.2M tok
6
+ * ‹ 2/3 › general Review diff explore Scan arch… general Verify
7
+ *
8
+ * ▎ Task from build
9
+ * ▎ Inspect the codebase architecture and docs; identify one or two…
10
+ *
11
+ * ◇ Thinking Need inspect sizes to identify. Glob source…
12
+ * › read docs/building/a-new-bay.md
13
+ * ⌄ grep "loadConfig" *.ts 9 matches · 1.4s
14
+ * │ pattern loadConfig
15
+ * │ include *.ts
16
+ * │ ─
17
+ * │ packages/shell/src/core/config.ts:80: export function loadConfig(
18
+ *
19
+ * ## Findings … (the answer, as markdown)
20
+ *
21
+ * Every item — a call, a block of thinking, a message you sent — is selectable (`j`/`k`) and opens
22
+ * or folds (`enter`, a click). Every row is exactly `width`; there are exactly `height` rows; only
23
+ * the body scrolls. Pure, like everything in `core/`.
24
+ */
25
+
26
+ import { toolTarget } from "../model/model.js";
27
+ import { markdownRows } from "./markdown.js";
28
+ import { compact, cut, elapsed, fit, spin, spread, widthOf, wrap } from "./rows.js";
29
+
30
+ /**
31
+ * Each item's rows from the paint before, and what they were drawn from. A paint redraws only the
32
+ * items that changed — a scroll, a spinner tick or a new call no longer lays out every call, every
33
+ * block of thinking and the whole answer again (32 ms a paint on a 200-call run, measured).
34
+ */
35
+
36
+ export const createScreenCache = () => ({
37
+ items: new Map()
38
+ });
39
+ const PAD = " ";
40
+ /** A row without the padding `fit` put at its end. */
41
+ function trimEnd(row) {
42
+ const out = [...row];
43
+ while (out.length > 1 && out.at(-1).text.trim() === "") out.pop();
44
+ const last = out.at(-1);
45
+ if (last) out[out.length - 1] = {
46
+ ...last,
47
+ text: last.text.trimEnd()
48
+ };
49
+ return out;
50
+ }
51
+
52
+ /** A body row, and the item it belongs to. */
53
+
54
+ export const itemKey = entry => entry.kind === "tool" ? `tool:${entry.call}` : `${entry.kind}:${entry.key}`;
55
+ const running = s => s.status === "running" || s.status === "starting" || s.status === "waiting";
56
+ function timeOf(call, now) {
57
+ if (call.state === "running" || call.state === "pending") return `running ${elapsed(now - call.at)}`;
58
+ const ms = (call.ended ?? call.at) - call.at;
59
+ /** A 7 ms read says nothing; a time is shown when it is worth reading. */
60
+ if (ms < 1000) return "";
61
+ return ms < 10_000 ? `${(ms / 1000).toFixed(1)}s` : elapsed(ms);
62
+ }
63
+
64
+ /**
65
+ * Calls drawn as OpenCode draws them. A shell command or a file change is a box — its command, then
66
+ * what it printed; everything else (reads, searches, fetches) is one quiet line, and a list of them
67
+ * reads as one list. Opening a line turns it into a box too, with its arguments.
68
+ */
69
+ const BOXED = new Set(["bash", "shell", "edit", "write", "patch", "apply_patch", "multiedit"]);
70
+
71
+ /** The glyph and the name OpenCode puts before a call's target. */
72
+ function labelOf(name) {
73
+ switch (name) {
74
+ case "read":
75
+ return {
76
+ icon: "→",
77
+ title: "Read"
78
+ };
79
+ case "list":
80
+ case "ls":
81
+ return {
82
+ icon: "→",
83
+ title: "List"
84
+ };
85
+ case "glob":
86
+ return {
87
+ icon: "✱",
88
+ title: "Glob"
89
+ };
90
+ case "grep":
91
+ return {
92
+ icon: "✱",
93
+ title: "Grep"
94
+ };
95
+ case "webfetch":
96
+ return {
97
+ icon: "%",
98
+ title: "WebFetch"
99
+ };
100
+ case "websearch":
101
+ return {
102
+ icon: "◈",
103
+ title: "WebSearch"
104
+ };
105
+ case "edit":
106
+ case "multiedit":
107
+ return {
108
+ icon: "←",
109
+ title: "Edit"
110
+ };
111
+ case "write":
112
+ return {
113
+ icon: "←",
114
+ title: "Write"
115
+ };
116
+ case "patch":
117
+ case "apply_patch":
118
+ return {
119
+ icon: "←",
120
+ title: "Patch"
121
+ };
122
+ case "task":
123
+ case "subagent":
124
+ return {
125
+ icon: "◉",
126
+ title: "Task"
127
+ };
128
+ case "todowrite":
129
+ case "todoread":
130
+ return {
131
+ icon: "☐",
132
+ title: "Todos"
133
+ };
134
+ default:
135
+ return {
136
+ icon: "⚙",
137
+ title: name
138
+ };
139
+ }
140
+ }
141
+
142
+ /** Lines of output a folded box shows, and the most an open one will. */
143
+ /**
144
+ * Lines of output a box shows: folded, open, and shown whole (`a`). An open read of a 2,000-line
145
+ * file was 400 rows to scroll through; open now shows enough to see what came back, and all of it is
146
+ * one more key away — capped even then, so one call cannot take over the pane.
147
+ */
148
+ const PREVIEW = 10;
149
+ const OPEN = 60;
150
+ const MOST = 2000;
151
+ function inlineLines(call, width, now, frame) {
152
+ const key = itemKey(call);
153
+ const live = call.state === "running" || call.state === "pending";
154
+ const {
155
+ icon,
156
+ title
157
+ } = labelOf(call.name);
158
+ const right = [call.summary ?? "", timeOf(call, now)].filter(Boolean).join(" · ");
159
+ const lines = [{
160
+ item: key,
161
+ row: spread([{
162
+ text: PAD
163
+ }, live ? {
164
+ text: spin(frame),
165
+ tone: "accent"
166
+ } : {
167
+ text: icon,
168
+ tone: call.state === "failed" ? "error" : "muted"
169
+ }, {
170
+ text: ` ${title} `,
171
+ tone: "text"
172
+ }, {
173
+ text: toolTarget(call.name, call.input),
174
+ tone: "muted"
175
+ }], right ? [{
176
+ text: `${right} `,
177
+ tone: live ? "accent" : "muted"
178
+ }] : [], width)
179
+ }];
180
+ if (call.state === "failed" && call.error) {
181
+ lines.push({
182
+ item: key,
183
+ row: fit([{
184
+ text: `${PAD} `
185
+ }, {
186
+ text: call.error.split("\n")[0] ?? "",
187
+ tone: "error"
188
+ }], width)
189
+ });
190
+ }
191
+ return lines;
192
+ }
193
+
194
+ /**
195
+ * A call as a box: a coloured edge, its own background, the command or the arguments, then the
196
+ * output — ten lines folded, the rest a click away.
197
+ */
198
+ function boxLines(call, width, now, frame, isOpen, whole) {
199
+ const key = itemKey(call);
200
+ const live = call.state === "running" || call.state === "pending";
201
+ const failed = call.state === "failed";
202
+ const edge = {
203
+ text: `${PAD.slice(1)}▎`,
204
+ tone: failed ? "error" : live ? "accent" : "border",
205
+ fill: "block"
206
+ };
207
+ const inner = width - widthOf(edge.text) - 3;
208
+ const row = runs => ({
209
+ item: key,
210
+ row: fit([edge, {
211
+ text: " ",
212
+ fill: "block"
213
+ }, ...runs.map(run => ({
214
+ ...run,
215
+ fill: "block"
216
+ }))], width)
217
+ });
218
+ const blank = () => row([]);
219
+ const shell = call.name === "bash" || call.name === "shell";
220
+ const {
221
+ icon,
222
+ title
223
+ } = labelOf(call.name);
224
+ const right = [call.summary ?? "", timeOf(call, now)].filter(Boolean).join(" · ");
225
+ const heading = shell ? [{
226
+ text: "$ ",
227
+ tone: "muted"
228
+ }, {
229
+ text: toolTarget(call.name, call.input),
230
+ tone: "text",
231
+ bold: true
232
+ }] : [{
233
+ text: `${icon} `,
234
+ tone: "muted"
235
+ }, {
236
+ text: `${title} `,
237
+ tone: "text",
238
+ bold: true
239
+ }, {
240
+ text: toolTarget(call.name, call.input),
241
+ tone: "text"
242
+ }];
243
+ const lines = [blank()];
244
+ const tail = live ? [{
245
+ text: `${spin(frame)} ${right} `,
246
+ tone: "accent"
247
+ }] : right ? [{
248
+ text: `${right} `,
249
+ tone: failed ? "error" : "muted"
250
+ }] : [];
251
+ lines.push({
252
+ item: key,
253
+ row: spread([edge, {
254
+ text: " ",
255
+ fill: "block"
256
+ }, ...heading.map(run => ({
257
+ ...run,
258
+ fill: "block"
259
+ }))], tail.map(run => ({
260
+ ...run,
261
+ fill: "block"
262
+ })), width)
263
+ });
264
+
265
+ /** A box that is not a shell command shows what it was called with, the way it was called. */
266
+ if (!shell && (isOpen || !BOXED.has(call.name))) {
267
+ const names = Object.keys(call.input);
268
+ const pad = Math.min(14, Math.max(0, ...names.map(name => name.length)));
269
+ if (names.length > 0) lines.push(blank());
270
+ for (const name of names) {
271
+ const raw = call.input[name];
272
+ const value = typeof raw === "string" ? raw : JSON.stringify(raw);
273
+ wrap(value ?? "", Math.max(8, inner - pad - 2)).slice(0, isOpen ? 20 : 3).forEach((text, i) => {
274
+ lines.push(row([{
275
+ text: `${i === 0 ? name.padEnd(pad) : " ".repeat(pad)} `,
276
+ tone: "muted"
277
+ }, {
278
+ text,
279
+ tone: "text"
280
+ }]));
281
+ });
282
+ }
283
+ }
284
+ const output = (call.error ?? call.output ?? "").replace(/\s+$/, "");
285
+ const all = output ? output.split("\n") : [];
286
+ const limit = isOpen ? whole ? MOST : OPEN : PREVIEW;
287
+ if (all.length > 0) {
288
+ lines.push(blank());
289
+ const shown = live ? all.slice(-limit) : all.slice(0, limit);
290
+ /** Cut to the pane before anything measures it: a minified file is one enormous line. */
291
+ for (const text of shown) lines.push(row([{
292
+ text: text.slice(0, width * 2),
293
+ tone: call.error ? "error" : "text"
294
+ }]));
295
+ const more = all.length - limit;
296
+ if (more > 0) lines.push(row([{
297
+ text: live ? `… ${more.toLocaleString("en")} lines above` : `… ${more.toLocaleString("en")} more line${more === 1 ? "" : "s"}`,
298
+ tone: "muted"
299
+ }]));
300
+ }
301
+ if (all.length > PREVIEW && !live) {
302
+ const hint = !isOpen ? "Click to expand" : all.length > OPEN && !whole ? "Click to collapse · [a] show all" : "Click to collapse";
303
+ lines.push(blank(), row([{
304
+ text: hint,
305
+ tone: "muted"
306
+ }]));
307
+ }
308
+ lines.push(blank());
309
+ return lines;
310
+ }
311
+
312
+ /** Whether a call draws as a box, given whether it is open. */
313
+ const boxed = (call, isOpen) => BOXED.has(call.name) || isOpen;
314
+ function toolLines(call, width, now, frame, isOpen, whole = false) {
315
+ return boxed(call, isOpen) ? boxLines(call, width, now, frame, isOpen, whole) : inlineLines(call, width, now, frame);
316
+ }
317
+
318
+ /**
319
+ * Thinking the way OpenCode shows its own: "Thought · 1.2s", then the words, muted. Folded, the
320
+ * words follow on the same line and are cut there.
321
+ */
322
+ function thinkingLines(entry, width, isOpen, took) {
323
+ const key = itemKey(entry);
324
+ const text = entry.text.replace(/\s+/g, " ").trim();
325
+ const label = entry.done ? `Thought${took !== undefined && took >= 100 ? ` · ${duration(took)}` : ""}` : "Thinking…";
326
+ if (!isOpen) {
327
+ return [{
328
+ item: key,
329
+ row: fit([{
330
+ text: `${PAD}◇ ${label} `,
331
+ tone: "warning"
332
+ }, {
333
+ text,
334
+ tone: "muted",
335
+ faint: true
336
+ }], width)
337
+ }];
338
+ }
339
+ const lines = [{
340
+ item: key,
341
+ row: fit([{
342
+ text: `${PAD}◆ ${label}`,
343
+ tone: "warning"
344
+ }], width)
345
+ }];
346
+ for (const line of wrap(entry.text.trim() || "…", width - PAD.length * 2 - 2)) {
347
+ lines.push({
348
+ item: key,
349
+ row: fit([{
350
+ text: `${PAD} `
351
+ }, {
352
+ text: line,
353
+ tone: "muted",
354
+ faint: true
355
+ }], width)
356
+ });
357
+ }
358
+ return lines;
359
+ }
360
+
361
+ /** `590ms`, `1.2s`, `2m04s`. */
362
+ function duration(ms) {
363
+ if (ms < 1000) return `${Math.round(ms)}ms`;
364
+ return ms < 10_000 ? `${(ms / 1000).toFixed(1)}s` : elapsed(ms);
365
+ }
366
+ function cardLines(label, text, width, tone, fill, item) {
367
+ const bar = {
368
+ text: `${PAD}▎ `,
369
+ tone,
370
+ fill
371
+ };
372
+ const lines = [{
373
+ ...(item ? {
374
+ item
375
+ } : {}),
376
+ row: fit([bar, {
377
+ text: label,
378
+ tone,
379
+ bold: true,
380
+ fill
381
+ }], width)
382
+ }];
383
+ for (const line of wrap(text, width - PAD.length - 3)) {
384
+ lines.push({
385
+ ...(item ? {
386
+ item
387
+ } : {}),
388
+ row: fit([bar, {
389
+ text: line,
390
+ tone: "text",
391
+ fill
392
+ }], width)
393
+ });
394
+ }
395
+ return lines;
396
+ }
397
+ function bodyLines(input, width, opened) {
398
+ const {
399
+ session,
400
+ now,
401
+ frame
402
+ } = input;
403
+ const lines = [];
404
+ const blank = () => {
405
+ if (lines.length > 0 && !lines.at(-1)?.row.every(run => run.text.trim() === "")) lines.push({
406
+ row: fit([], width)
407
+ });
408
+ };
409
+ lines.push(...cardLines(`Task from ${input.launcher ?? "the main agent"}`, session.task ?? session.title ?? "", width, "accent", "card"));
410
+
411
+ /**
412
+ * Room between items, as OpenCode leaves it: a blank line between any two — except a run of calls,
413
+ * which reads as one list, unless one of them is open.
414
+ */
415
+ let last;
416
+ let round = 1;
417
+ const seen = new Set();
418
+ /** An item's rows from the cache when what they are drawn from has not changed. */
419
+ const drawn = (key, sig, draw) => {
420
+ seen.add(key);
421
+ const hit = input.cache?.items.get(key);
422
+ if (hit && hit.sig === sig) return hit.lines;
423
+ const lines = draw();
424
+ input.cache?.items.set(key, {
425
+ sig,
426
+ lines
427
+ });
428
+ return lines;
429
+ };
430
+ session.entries.forEach((entry, index) => {
431
+ if (entry.kind === "prompt" && entry.first) return;
432
+ const key = itemKey(entry);
433
+ switch (entry.kind) {
434
+ case "prompt":
435
+ {
436
+ /**
437
+ * Another round: the main agent continued this subagent, or you wrote to it. A rule says where
438
+ * the round starts; the card says who started it.
439
+ */
440
+ round += 1;
441
+ const mine = input.yours?.(entry) ?? false;
442
+ const from = mine ? "You" : `${input.launcher ?? "The main agent"} continued it`;
443
+ blank();
444
+ lines.push(...drawn(key, `${width}|${entry.text.length}|${mine}|${round}|${input.launcher}`, () => [{
445
+ row: fit([{
446
+ text: `${PAD}── Round ${round} `,
447
+ tone: "muted"
448
+ }, {
449
+ text: "─".repeat(width),
450
+ tone: "border"
451
+ }], width)
452
+ }, {
453
+ row: fit([], width)
454
+ }, ...cardLines(from, entry.text, width, mine ? "info" : "accent", "card", key)]));
455
+ last = {
456
+ kind: "prompt",
457
+ open: false
458
+ };
459
+ return;
460
+ }
461
+ case "thinking":
462
+ {
463
+ blank();
464
+ const isOpen = (input.thinking || input.open.has(key)) && !input.closed.has(key);
465
+ if (isOpen) opened.push(key);
466
+ const next = session.entries[index + 1];
467
+ const took = entry.done && next ? next.at - entry.at : undefined;
468
+ lines.push(...drawn(key, `${width}|${isOpen}|${entry.text.length}|${entry.done}|${took}`, () => thinkingLines(entry, width, isOpen, took)));
469
+ last = {
470
+ kind: "thinking",
471
+ open: isOpen
472
+ };
473
+ return;
474
+ }
475
+ case "tool":
476
+ {
477
+ const live = entry.state === "running" || entry.state === "pending";
478
+ const isOpen = input.open.has(key) && !input.closed.has(key);
479
+ const box = boxed(entry, isOpen);
480
+ if (last?.kind !== "tool" || last.open || box) blank();
481
+ if (isOpen) opened.push(key);
482
+ /** A running call's spinner and clock change every tick; a finished one never again. */
483
+ const clock = live ? `|${frame}|${Math.floor((now - entry.at) / 1000)}` : "";
484
+ const sig = `${width}|${isOpen}|${entry.state}|${entry.output.length}|${entry.error?.length}|${entry.summary}|${entry.ended}|${Object.keys(entry.input).length}${clock}`;
485
+ const whole = isOpen && Boolean(input.whole?.has(key));
486
+ lines.push(...drawn(key, `${sig}|${whole}`, () => toolLines(entry, width, now, frame, isOpen, whole)));
487
+ last = {
488
+ kind: "tool",
489
+ open: box
490
+ };
491
+ return;
492
+ }
493
+ case "reply":
494
+ {
495
+ blank();
496
+ lines.push(...drawn(key, `${width}|${entry.text.length}|${entry.done}`, () => replyLines(entry, width)));
497
+ last = {
498
+ kind: "reply",
499
+ open: false
500
+ };
501
+ return;
502
+ }
503
+ }
504
+ });
505
+ /** Items gone from the run (another subagent opened) leave the cache with it. */
506
+ if (input.cache) for (const key of input.cache.items.keys()) if (!seen.has(key)) input.cache.items.delete(key);
507
+ if (session.status === "failed" && session.error) {
508
+ blank();
509
+ for (const line of wrap(`Failed: ${session.error}`, width - PAD.length * 2)) {
510
+ lines.push({
511
+ row: fit([{
512
+ text: PAD
513
+ }, {
514
+ text: line,
515
+ tone: "error"
516
+ }], width)
517
+ });
518
+ }
519
+ }
520
+ return lines;
521
+ }
522
+
523
+ /** The answer, drawn as markdown, with a cursor while it is still being written. */
524
+ function replyLines(entry, width) {
525
+ const rows = markdownRows(entry.text || "…", width - PAD.length, {
526
+ indent: PAD.length
527
+ });
528
+ /** Still writing: a cursor where the words end, or under them when the line is full. */
529
+ const end = rows.at(-1);
530
+ if (!entry.done && end) {
531
+ const used = widthOf(end.map(run => run.text).join("").trimEnd());
532
+ if (used < width - 1) rows[rows.length - 1] = [...trimEnd(end), {
533
+ text: "▍",
534
+ tone: "accent"
535
+ }];else rows.push([{
536
+ text: `${PAD}▍`,
537
+ tone: "accent"
538
+ }]);
539
+ }
540
+ /** Not an item: an answer does not open or fold. */
541
+ return rows.map(row => ({
542
+ row: fit(row, width)
543
+ }));
544
+ }
545
+ function detailLines(input, width) {
546
+ const {
547
+ session
548
+ } = input;
549
+ const tools = session.entries.filter(entry => entry.kind === "tool");
550
+ const byName = new Map();
551
+ for (const call of tools) byName.set(call.name, (byName.get(call.name) ?? 0) + 1);
552
+ const calls = [...byName.entries()].sort((a, b) => b[1] - a[1]).map(([name, n]) => `${n} ${name}`);
553
+ const pairs = [["Agent", `${session.agent} — launched by ${input.launcher ?? "the main agent"}${session.background ? ", in the background" : ""}`], ["Model", session.model ?? "not reported"], ["Denied", session.denied.length > 0 ? session.denied.join(", ") : "nothing denied outright"], ["Calls", tools.length > 0 ? `${tools.length} — ${calls.join(" · ")}` : "none"], ["Steps", session.steps ? `${session.steps}` : "—"], ["Tokens", session.tokens ? compact(session.tokens) : "—"], ["Cost", `$${session.cost.toFixed(3)}`], ["Session", session.id]];
554
+ const lines = [];
555
+ for (const [label, value] of pairs) {
556
+ const head = [{
557
+ text: PAD
558
+ }, {
559
+ text: label.padEnd(10),
560
+ tone: "muted"
561
+ }];
562
+ wrap(value, width - PAD.length - 10).forEach((text, i) => {
563
+ lines.push({
564
+ row: fit([...(i === 0 ? head : [{
565
+ text: `${PAD}${" ".repeat(10)}`
566
+ }]), {
567
+ text,
568
+ tone: "text"
569
+ }], width)
570
+ });
571
+ });
572
+ }
573
+ return lines;
574
+ }
575
+ function header(input, width) {
576
+ const {
577
+ session,
578
+ now,
579
+ frame,
580
+ nodes
581
+ } = input;
582
+ const glyph = session.status === "done" ? {
583
+ text: "●",
584
+ tone: "success",
585
+ fill: "band"
586
+ } : session.status === "failed" ? {
587
+ text: "●",
588
+ tone: "error",
589
+ fill: "band"
590
+ } : {
591
+ text: spin(frame),
592
+ tone: "accent",
593
+ fill: "band"
594
+ };
595
+ const state = running(session) ? session.status === "waiting" ? `waiting ${elapsed(now - session.since)}` : `running ${elapsed(now - session.started)}` : session.status === "failed" ? `${/abort|interrupt|cancel/i.test(session.error ?? "") ? "cancelled" : "failed"} after ${elapsed((session.ended ?? now) - session.started)}` : `done in ${elapsed((session.ended ?? now) - session.started)}`;
596
+ const tools = session.entries.filter(entry => entry.kind === "tool").length;
597
+ const meta = [session.model ?? "", session.background ? "background" : "", input.launcher ? `launched by ${input.launcher}` : "", `${tools} call${tools === 1 ? "" : "s"}`, session.steps ? `${session.steps} step${session.steps === 1 ? "" : "s"}` : "", session.tokens ? `${compact(session.tokens)} tok` : ""].filter(Boolean).join(" · ");
598
+ const rows = [spread([{
599
+ text: "▌",
600
+ tone: "accent",
601
+ fill: "band"
602
+ }, glyph, {
603
+ text: ` ${session.agent.toUpperCase()} `,
604
+ tone: "info",
605
+ bold: true,
606
+ fill: "band"
607
+ }, {
608
+ text: ` ${session.title || "subagent"}`,
609
+ tone: "text",
610
+ bold: true,
611
+ fill: "band"
612
+ }], [{
613
+ text: `${state} `,
614
+ tone: running(session) ? "accent" : session.status === "failed" ? "error" : "muted",
615
+ fill: "band"
616
+ }], width), fit([{
617
+ text: `${PAD}${meta}`,
618
+ tone: "muted",
619
+ fill: "band"
620
+ }], width)];
621
+ if (nodes.length > 1) {
622
+ const at = nodes.findIndex(node => node.session.id === session.id);
623
+ const runs = [{
624
+ text: `${PAD}‹ ${at + 1}/${nodes.length} › `,
625
+ tone: "muted",
626
+ fill: "band"
627
+ }];
628
+ const each = Math.max(12, Math.floor((width - 14) / nodes.length) - 3);
629
+ for (const node of nodes) {
630
+ const current = node.session.id === session.id;
631
+ const label = cut(`${node.session.agent} ${node.session.title}`, each);
632
+ runs.push({
633
+ text: label,
634
+ tone: current ? "accent" : "muted",
635
+ bold: current,
636
+ fill: "band"
637
+ }, {
638
+ text: " ",
639
+ fill: "band"
640
+ });
641
+ }
642
+ rows.push(fit(runs, width));
643
+ }
644
+ return rows;
645
+ }
646
+ function footer(input, width) {
647
+ const {
648
+ session
649
+ } = input;
650
+ if (input.input) {
651
+ const hint = input.input.busy ? "it picks this up in its current run" : "it answers, and the main agent hears it";
652
+ return [fit([{
653
+ text: `${PAD}┃ `,
654
+ tone: "accent",
655
+ fill: "block"
656
+ }, {
657
+ text: input.input.draft,
658
+ tone: "text",
659
+ fill: "block"
660
+ }, {
661
+ text: "▍",
662
+ tone: "accent",
663
+ fill: "block"
664
+ }], width), spread([{
665
+ text: `${PAD} to ${session.agent} · ${hint}`,
666
+ tone: "muted"
667
+ }], [{
668
+ text: "enter",
669
+ tone: "accent"
670
+ }, {
671
+ text: " send ",
672
+ tone: "muted"
673
+ }, {
674
+ text: "esc",
675
+ tone: "accent"
676
+ }, {
677
+ text: " cancel ",
678
+ tone: "muted"
679
+ }], width)];
680
+ }
681
+ /** In the order drawn, each with its rank: at half width the lowest-ranked go first. */
682
+ const all = [["j/k", "Select", 5], ["enter", "Open", 1], ["m", "Message", 2], ["x", running(session) ? "Stop" : "Remove", 3], ...(running(session) && !session.background ? [["b", "Background", 4]] : []), ["t", input.thinking ? "Hide thinking" : "Show thinking", 7], ["i", input.details ? "Timeline" : "Details", 4], ["w", "Width", 6], ["esc", "Back", 8]];
683
+ const cost = ([k, what]) => k.length + what.length + 5;
684
+ let shown = all;
685
+ while (shown.length > 1 && PAD.length + shown.reduce((sum, each) => sum + cost(each), 0) > width) {
686
+ const lowest = Math.max(...shown.map(each => each[2]));
687
+ shown = shown.filter(each => each[2] !== lowest);
688
+ }
689
+ const keys = [{
690
+ text: PAD
691
+ }, ...shown.flatMap(([k, what]) => [{
692
+ text: `[${k}]`,
693
+ tone: "accent"
694
+ }, {
695
+ text: ` ${what} `,
696
+ tone: "text"
697
+ }])];
698
+ const note = input.notice ?? (session.status === "done" ? "Finished — message it; the main agent hears the answer." : "");
699
+ return [fit(keys, width), fit([{
700
+ text: `${PAD}${note}`,
701
+ tone: "muted"
702
+ }], width)];
703
+ }
704
+
705
+ /** The selected item's rows, marked: a coloured edge and the selection fill. */
706
+ function mark(lines, selected, width) {
707
+ if (!selected) return lines;
708
+ return lines.map(line => {
709
+ if (line.item !== selected) return line;
710
+ const [first, ...rest] = line.row;
711
+ const edge = {
712
+ text: "▌",
713
+ tone: "accent",
714
+ fill: "selected"
715
+ };
716
+ const head = {
717
+ ...first,
718
+ text: first.text.slice(1),
719
+ fill: "selected"
720
+ };
721
+ return {
722
+ ...line,
723
+ row: fit([edge, head, ...rest.map(run => ({
724
+ ...run,
725
+ fill: run.fill === "none" || !run.fill ? "selected" : run.fill
726
+ }))], width)
727
+ };
728
+ });
729
+ }
730
+ export function screenRows(input) {
731
+ const width = Math.max(24, input.width);
732
+ const height = Math.max(8, input.height);
733
+ const top = header(input, width);
734
+ const bottom = footer(input, width);
735
+ const room = Math.max(1, height - top.length - bottom.length - 2);
736
+ const opened = [];
737
+ const body = input.details ? detailLines(input, width) : mark(bodyLines(input, width, opened), input.selected, width);
738
+ const keys = input.details ? [] : [...new Set(body.map(line => line.item).filter(item => Boolean(item)))];
739
+ const most = Math.max(0, body.length - room);
740
+ let first = input.top === undefined ? most : Math.min(Math.max(0, input.top), most);
741
+ /**
742
+ * The cursor moved onto an item: bring it into view. Only then — pinned on every paint, a selected
743
+ * item longer than the pane snapped back to its first line whenever you scrolled into it.
744
+ */
745
+ if (input.reveal && input.selected && input.top !== undefined) {
746
+ const at = body.findIndex(line => line.item === input.selected);
747
+ if (at >= 0 && at < first) first = at;
748
+ if (at >= first + room) first = Math.min(most, at - room + 3);
749
+ }
750
+ const shown = body.slice(first, first + room);
751
+ while (shown.length < room) shown.push({
752
+ row: fit([], width)
753
+ });
754
+ const blank = fit([], width);
755
+ return {
756
+ rows: [...top, blank, ...shown.map(line => line.row), blank, ...bottom],
757
+ items: [...top.map(() => undefined), undefined, ...shown.map(line => line.item), undefined, ...bottom.map(() => undefined)],
758
+ keys,
759
+ opened,
760
+ top: first,
761
+ most,
762
+ bodyAt: top.length + 1
763
+ };
764
+ }
765
+
766
+ /** For tests and the preview: the columns a row takes. */
767
+ export const rowWidth = row => widthOf(row.map(run => run.text).join(""));