@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.
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/agent/plugin.js +134 -0
- package/dist/cli/preview.js +99 -0
- package/dist/core/adapt/v1.js +341 -0
- package/dist/core/adapt/v2.js +379 -0
- package/dist/core/model/changes.js +17 -0
- package/dist/core/model/model.js +301 -0
- package/dist/core/sample.js +261 -0
- package/dist/core/view/markdown.js +211 -0
- package/dist/core/view/report.js +58 -0
- package/dist/core/view/rows.js +146 -0
- package/dist/core/view/screen.js +767 -0
- package/dist/core/view/sidebar.js +151 -0
- package/dist/server.js +2 -0
- package/dist/tui/index.js +991 -0
- package/dist/tui/render.js +103 -0
- package/dist/tui/source.js +228 -0
- package/dist/tui/view/overlay.js +87 -0
- package/dist/tui/view/sidebar.js +78 -0
- package/package.json +64 -0
- package/server.js +6 -0
- package/tui.js +6 -0
- package/types/agent/plugin.d.ts +32 -0
- package/types/cli/preview.d.ts +10 -0
- package/types/core/adapt/v1.d.ts +33 -0
- package/types/core/adapt/v2.d.ts +22 -0
- package/types/core/model/changes.d.ts +98 -0
- package/types/core/model/model.d.ts +99 -0
- package/types/core/sample.d.ts +8 -0
- package/types/core/view/markdown.d.ts +22 -0
- package/types/core/view/report.d.ts +15 -0
- package/types/core/view/rows.d.ts +43 -0
- package/types/core/view/screen.d.ts +101 -0
- package/types/core/view/sidebar.d.ts +30 -0
- package/types/server.d.ts +2 -0
- package/types/tui/index.d.ts +26 -0
- package/types/tui/render.d.ts +27 -0
- package/types/tui/source.d.ts +45 -0
- package/types/tui/view/overlay.d.ts +30 -0
- 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(""));
|