@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,991 @@
1
+ import { createComponent as _$createComponent } from "@opentui/solid";
2
+ /** @jsxImportSource @opentui/solid */
3
+
4
+ import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client/feature";
5
+ import { bindingLookup, dualTui, onPaste } from "@opencode-cockpit/client/host";
6
+ import { sidebarOrder } from "@opencode-cockpit/client/sidebar";
7
+ import { createSignal } from "solid-js";
8
+ import { applyAll, emptyModel, rootOf, subagentsOf } from "../core/model/model.js";
9
+ import { createScreenCache, screenRows } from "../core/view/screen.js";
10
+ import { sidebarLines } from "../core/view/sidebar.js";
11
+ import { createRowPool, solidSurface } from "./render.js";
12
+ import { createSource } from "./source.js";
13
+ import { Overlay } from "./view/overlay.js";
14
+ import { SidebarBlock } from "./view/sidebar.js";
15
+ const SUBAGENTS_PACKAGE = "@opencode-cockpit/subagents";
16
+ const DEFAULT_KEYS = {
17
+ "cockpit.subagents.open": "<leader>w"
18
+ };
19
+
20
+ /** The pane's state: which subagent, and how it is being looked at. */
21
+
22
+ const FULL_KEY = "cockpit.subagents.full";
23
+ /** Messages you sent, as `<session>:<text>` — so the pane can tell yours from the main agent's. */
24
+ const YOURS_KEY = "cockpit.subagents.yours";
25
+ const YOURS_MAX = 200;
26
+ /** How much of an answer is relayed to the main agent. */
27
+ const RELAY_MAX = 4000;
28
+ const clip = (text, most) => text.length > most ? `${text.slice(0, most - 1)}…` : text;
29
+ /** Thinking shown or folded — shown until you say otherwise, then as you left it. */
30
+ const THINKING_KEY = "cockpit.subagents.thinking";
31
+ /** Subagents removed from the list, by id — kept across restarts, the newest few hundred. */
32
+ const HIDDEN_KEY = "cockpit.subagents.hidden";
33
+ const HIDDEN_MAX = 300;
34
+ /** How long a run may say nothing before the host is asked whether it is still going. */
35
+ const QUIET_MS = 20_000;
36
+ /** How long the second `x` that confirms a stop is waited for. */
37
+ const CONFIRM_MS = 4_000;
38
+
39
+ /** Subagents' TUI half as a factory, so the `opencode-cockpit` bundle can include it. */
40
+ export function createSubagentsTui({
41
+ source = SUBAGENTS_PACKAGE
42
+ } = {}) {
43
+ return async (api, rawOptions) => {
44
+ const log = api.log.child("subagents");
45
+ const claim = claimFeature(api.renderer, "subagents", source);
46
+ if (!claim.active) {
47
+ log.warn("configured twice", {
48
+ owner: claim.owner,
49
+ skipped: source
50
+ });
51
+ api.ui.toast({
52
+ variant: "warning",
53
+ title: "Subagents",
54
+ message: duplicateFeatureMessage("Subagents", claim.owner, source)
55
+ });
56
+ return;
57
+ }
58
+ api.lifecycle.onDispose(() => claim.release());
59
+ const options = rawOptions ?? {};
60
+ const keys = bindingLookup({
61
+ ...DEFAULT_KEYS,
62
+ ...options.keybinds
63
+ });
64
+ const model = emptyModel();
65
+ const yours = new Set(api.kv.get(YOURS_KEY, []));
66
+ const surface = {
67
+ opened: new Set(),
68
+ closed: new Set(),
69
+ whole: new Set(),
70
+ thinking: api.kv.get(THINKING_KEY, true),
71
+ details: false,
72
+ full: api.kv.get(FULL_KEY, false)
73
+ };
74
+ let frame = 0;
75
+ let root;
76
+ let backdrop;
77
+ let panel;
78
+ let pool;
79
+ let shown;
80
+ /** Each item's rows between paints: a scroll or a tick redraws only what changed. */
81
+ const cache = createScreenCache();
82
+ let disposeKeys;
83
+ const [lines, setLines] = createSignal([]);
84
+
85
+ /** The conversation on screen — or the one a subagent you are looking at belongs to. */
86
+ const current = () => {
87
+ const route = api.route.current;
88
+ const id = route.name === "session" ? route.params?.sessionID : undefined;
89
+ return id ? rootOf(model, id) : undefined;
90
+ };
91
+ const hidden = new Set(api.kv.get(HIDDEN_KEY, []));
92
+ const saveHidden = () => api.kv.set(HIDDEN_KEY, [...hidden].slice(-HIDDEN_MAX));
93
+ /** Removed, or under one that was: a removed subagent takes the ones it launched with it. */
94
+ const isHidden = id => {
95
+ for (let at = id, n = 0; at && n < 8; at = model.sessions.get(at)?.parentID, n++) if (hidden.has(at)) return true;
96
+ return false;
97
+ };
98
+ const nodes = () => root ? subagentsOf(model, root).filter(node => !isHidden(node.session.id)) : [];
99
+ const opened = () => surface.open ? model.sessions.get(surface.open) : undefined;
100
+ let block;
101
+ let drawnAt = 0;
102
+ let sidebarSaid = "";
103
+ /**
104
+ * The width the sidebar gave the block, once laid out; a guess before that. Guessed, the rows ran
105
+ * past the edge and were clipped: "3 done" drew as "3 d".
106
+ */
107
+ let told = 0;
108
+ const sidebarWidth = () => {
109
+ /**
110
+ * The container the host gave the block, not the block: rows wider than the sidebar stretch the
111
+ * block with them, so its own width only ever agreed with the guess.
112
+ */
113
+ const parent = block?.parent?.width ?? 0;
114
+ const own = block?.width ?? 0;
115
+ const measured = parent >= 12 ? Math.min(parent, own >= 12 ? own : parent) : own;
116
+ if (measured !== told) {
117
+ told = measured;
118
+ log.debug("sidebar width", {
119
+ measured,
120
+ own,
121
+ parent,
122
+ window: api.renderer.width
123
+ });
124
+ }
125
+ return measured >= 12 ? measured : Math.max(20, Math.min(40, Math.floor(api.renderer.width / 4) - 2));
126
+ };
127
+ const busy = session => session.status === "running" || session.status === "starting";
128
+ const working = () => nodes().some(({
129
+ session
130
+ }) => busy(session) || session.status === "waiting");
131
+ /** Half the window, but never so narrow a call's arguments cannot be read. */
132
+ const paneWidth = () => {
133
+ const width = api.renderer.width;
134
+ return surface.full ? width : Math.min(width, Math.max(72, Math.floor(width / 2)));
135
+ };
136
+
137
+ // --- painting ----------------------------------------------------------------------------------
138
+
139
+ const paint = () => {
140
+ const now = Date.now();
141
+ const list = nodes();
142
+ drawnAt = sidebarWidth();
143
+ const after = options.hideFinishedAfter;
144
+ const listed = typeof after === "number" && after >= 0 ? list.filter(({
145
+ session
146
+ }) => !(session.status === "done" || session.status === "failed") || now - (session.ended ?? now) < after * 60_000) : list;
147
+ const next = sidebarLines({
148
+ nodes: listed,
149
+ width: drawnAt,
150
+ now,
151
+ frame,
152
+ limit: options.sidebarRows ?? 6
153
+ });
154
+ /** Only when they changed: new rows rebuild every line of the block, and a scroll is many paints. */
155
+ const said = JSON.stringify(next);
156
+ if (said !== sidebarSaid) {
157
+ sidebarSaid = said;
158
+ setLines(next);
159
+ }
160
+ const session = opened();
161
+ if (backdrop && panel && pool) {
162
+ const show = Boolean(session);
163
+ const height = api.renderer.height;
164
+ backdrop.width = api.renderer.width;
165
+ backdrop.height = show ? height : 0;
166
+ backdrop.visible = show;
167
+ panel.backgroundColor = solidSurface(api.theme.current);
168
+ panel.width = paneWidth();
169
+ panel.height = show ? height : 0;
170
+ if (session && show) {
171
+ const launcher = session.parentID ? model.sessions.get(session.parentID)?.agent : undefined;
172
+ const started = performance.now();
173
+ const screen = screenRows({
174
+ cache,
175
+ session,
176
+ nodes: list,
177
+ ...(launcher ? {
178
+ launcher
179
+ } : {}),
180
+ width: paneWidth(),
181
+ height,
182
+ now,
183
+ frame,
184
+ ...(surface.top !== undefined ? {
185
+ top: surface.top
186
+ } : {}),
187
+ ...(surface.selected ? {
188
+ selected: surface.selected
189
+ } : {}),
190
+ open: surface.opened,
191
+ closed: surface.closed,
192
+ thinking: surface.thinking,
193
+ details: surface.details,
194
+ whole: surface.whole,
195
+ yours: entry => yours.has(`${session.id}:${entry.text}`),
196
+ reveal: surface.reveal === true,
197
+ ...(surface.draft !== undefined ? {
198
+ input: {
199
+ draft: surface.draft,
200
+ busy: busy(session)
201
+ }
202
+ } : {}),
203
+ ...(surface.notice ? {
204
+ notice: surface.notice
205
+ } : {})
206
+ });
207
+ /** Scrolled back to the end: follow the run again. */
208
+ /** Where the reveal left the view is where it stays. */
209
+ if (surface.reveal && surface.top !== undefined) surface.top = screen.top;
210
+ surface.reveal = false;
211
+ if (surface.top !== undefined && screen.top >= screen.most && !surface.selected) surface.top = undefined;
212
+ shown = screen;
213
+ pool.draw(screen.rows, api.theme.current);
214
+ const took = performance.now() - started;
215
+ /** A paint past a frame is worth knowing about; the run's size says why. */
216
+ if (took > 16) log.debug("slow paint", {
217
+ ms: Math.round(took),
218
+ entries: session.entries.length
219
+ });
220
+ /** The prompt's cursor would otherwise blink through the pane, as it did over Review. */
221
+ setTimeout(() => {
222
+ if (surface.open) api.renderer.setCursorPosition(0, 0, false);
223
+ }, 0);
224
+ } else {
225
+ shown = undefined;
226
+ pool.clear();
227
+ }
228
+ }
229
+ api.renderer.requestRender();
230
+ };
231
+ /** Several asks in one turn are one paint, of the state the turn ended on (Shell's painter). */
232
+ let scheduled = false;
233
+ const draw = () => {
234
+ if (scheduled) return;
235
+ scheduled = true;
236
+ setTimeout(() => {
237
+ scheduled = false;
238
+ try {
239
+ paint();
240
+ } catch (error) {
241
+ log.error("paint failed", {
242
+ error
243
+ });
244
+ }
245
+ }, 0);
246
+ };
247
+
248
+ // --- data --------------------------------------------------------------------------------------
249
+
250
+ const feed = createSource(api, log, changes => {
251
+ if (changes.length === 0) return;
252
+ applyAll(model, changes);
253
+ for (const change of changes) {
254
+ if (change.type === "status" && change.status !== "busy" && change.status !== "waiting") relay(change.id);
255
+ if (change.type === "session" && change.parentID) log.debug("subagent", {
256
+ id: change.id,
257
+ parent: change.parentID,
258
+ agent: change.agent
259
+ });
260
+ /** Removed, then started again (the main agent continued it): it belongs in the list again. */
261
+ if (change.type === "status" && change.status === "busy" && hidden.delete(change.id)) {
262
+ log.debug("unhidden: working again", {
263
+ id: change.id
264
+ });
265
+ saveHidden();
266
+ }
267
+ }
268
+ draw();
269
+ });
270
+
271
+ /** A conversation came on screen: load the subagents it already has. */
272
+ const follow = () => {
273
+ const next = current();
274
+ if (next === root) return;
275
+ root = next;
276
+ if (!next) return draw();
277
+ log.debug("conversation", {
278
+ root: next
279
+ });
280
+ feed.load(next).then(draw).catch(error => log.warn("load failed", {
281
+ root: next,
282
+ error
283
+ }));
284
+ };
285
+
286
+ /**
287
+ * A run that has gone quiet is asked about. An end the events never told us of — a missed event,
288
+ * a stop from elsewhere — otherwise left a subagent "running" until OpenCode restarted.
289
+ */
290
+ const reconcile = () => {
291
+ const now = Date.now();
292
+ for (const {
293
+ session
294
+ } of nodes()) {
295
+ if (session.status !== "running" && session.status !== "starting") continue;
296
+ if (now - session.seen < QUIET_MS) continue;
297
+ const changes = feed.check(session.id);
298
+ const said = changes[0];
299
+ if (said?.type === "status" && said.status !== "busy") {
300
+ log.info("quiet run ended", {
301
+ id: session.id,
302
+ status: said.status
303
+ });
304
+ applyAll(model, changes);
305
+ draw();
306
+ } else session.seen = now;
307
+ }
308
+ };
309
+
310
+ /** The clock: once a second for the sidebar's times, faster while a subagent is open and working. */
311
+ let slow = setInterval(() => {
312
+ follow();
313
+ reconcile();
314
+ /** The sidebar was laid out, or resized, since the rows were drawn. */
315
+ if (sidebarWidth() !== drawnAt) draw();
316
+ if (working()) {
317
+ frame++;
318
+ draw();
319
+ } else if (options.hideFinishedAfter !== undefined) draw(); // finished ones age out with nothing running
320
+ }, 1000);
321
+ let fast;
322
+
323
+ // --- the pane ----------------------------------------------------------------------------------
324
+
325
+ /** The pane's letters, taken only while it is up — a global layer; a targeted one never fires. */
326
+ const takeKeys = () => {
327
+ disposeKeys ??= api.keymap.registerLayer(layer());
328
+ };
329
+ const dropKeys = () => {
330
+ disposeKeys?.();
331
+ disposeKeys = undefined;
332
+ };
333
+ const close = () => {
334
+ if (!surface.open) return;
335
+ log.debug("close", {
336
+ id: surface.open
337
+ });
338
+ surface.open = undefined;
339
+ surface.draft = undefined;
340
+ dropKeys();
341
+ clearInterval(fast);
342
+ fast = undefined;
343
+ backdrop?.blur();
344
+ draw();
345
+ };
346
+ const open = id => {
347
+ if (!model.sessions.has(id)) return;
348
+ log.debug("open", {
349
+ id,
350
+ full: surface.full
351
+ });
352
+ const same = surface.open === id;
353
+ Object.assign(surface, {
354
+ open: id,
355
+ notice: undefined,
356
+ stopping: undefined,
357
+ draft: undefined,
358
+ ...(same ? {} : {
359
+ top: undefined,
360
+ selected: undefined,
361
+ details: false
362
+ })
363
+ });
364
+ takeKeys();
365
+ fast ??= setInterval(() => {
366
+ const session = opened();
367
+ if (session && busy(session)) {
368
+ frame++;
369
+ draw();
370
+ }
371
+ }, 150);
372
+ /** As Shell's full screen does: focus leaves the prompt, so its cursor stops blinking through. */
373
+ backdrop?.focus();
374
+ draw();
375
+ };
376
+ const step = by => {
377
+ const list = nodes();
378
+ const at = list.findIndex(node => node.session.id === surface.open);
379
+ const next = list[(at + by + list.length) % list.length];
380
+ if (next) open(next.session.id);
381
+ };
382
+ const scroll = by => {
383
+ const most = shown?.most ?? 0;
384
+ const top = Math.max(0, Math.min(most, (surface.top ?? most) + by));
385
+ surface.top = top >= most ? undefined : top;
386
+ draw();
387
+ };
388
+
389
+ /** The cursor onto the next or previous item; the first press lands on the last one in view. */
390
+ const select = by => {
391
+ const list = shown?.keys ?? [];
392
+ if (list.length === 0) return;
393
+ const at = surface.selected ? list.indexOf(surface.selected) : -1;
394
+ const next = at < 0 ? list.length - 1 : Math.max(0, Math.min(list.length - 1, at + by));
395
+ surface.selected = list[next];
396
+ /** Pinned where it is, so the pane scrolls to the cursor rather than the run. */
397
+ surface.top = shown?.top;
398
+ surface.reveal = true;
399
+ draw();
400
+ };
401
+
402
+ /** Opens the item, or folds it: whichever it is not now. */
403
+ const toggle = (key = surface.selected) => {
404
+ if (!key) return;
405
+ const isOpen = shown?.opened.includes(key) ?? false;
406
+ if (isOpen) {
407
+ surface.opened.delete(key);
408
+ surface.closed.add(key);
409
+ } else {
410
+ surface.closed.delete(key);
411
+ surface.opened.add(key);
412
+ }
413
+ if (isOpen) surface.whole.delete(key);
414
+ surface.selected = key;
415
+ surface.top = shown?.top;
416
+ /** Folding a long call you had scrolled into brings its first line back into view. */
417
+ surface.reveal = true;
418
+ log.debug("toggle", {
419
+ key,
420
+ open: !isOpen
421
+ });
422
+ draw();
423
+ };
424
+
425
+ /** `a`: the selected call's whole output, or back to its first lines. */
426
+ const showAll = () => {
427
+ const key = surface.selected;
428
+ if (!key?.startsWith("tool:")) return;
429
+ const all = !surface.whole.has(key);
430
+ if (all) {
431
+ surface.whole.add(key);
432
+ surface.closed.delete(key);
433
+ surface.opened.add(key);
434
+ } else surface.whole.delete(key);
435
+ surface.top = shown?.top;
436
+ surface.reveal = !all;
437
+ log.debug("show all", {
438
+ key,
439
+ all
440
+ });
441
+ draw();
442
+ };
443
+
444
+ /** Every call open, or every one folded. */
445
+ const expandAll = () => {
446
+ const calls = (shown?.keys ?? []).filter(key => key.startsWith("tool:"));
447
+ const all = calls.length > 0 && calls.every(key => shown?.opened.includes(key));
448
+ surface.opened = all ? new Set() : new Set(calls);
449
+ surface.closed = all ? new Set(calls) : new Set();
450
+ draw();
451
+ };
452
+ const click = y => {
453
+ if (!shown || surface.draft !== undefined) return;
454
+ const key = shown.items[y];
455
+ if (key) toggle(key);
456
+ };
457
+ const startMessage = () => {
458
+ if (!opened()) return;
459
+ surface.draft = "";
460
+ surface.notice = undefined;
461
+ log.debug("message: typing", {
462
+ id: surface.open
463
+ });
464
+ draw();
465
+ };
466
+
467
+ /**
468
+ * Messages you sent a finished subagent, until it answers. Its answer goes nowhere on its own — the
469
+ * main agent's task returned long ago — so once it is idle again, what you asked and what it said
470
+ * are added to the main conversation, quietly: no turn starts, and the main agent knows next time.
471
+ */
472
+ const asked = new Map();
473
+ /** How long a subagent that went idle is given to start a run of its own for a queued message. */
474
+ const SETTLE_MS = 3000;
475
+
476
+ /** The entries after your message: what the subagent did with it, if anything. */
477
+ const after = (id, question) => {
478
+ const entries = model.sessions.get(id)?.entries ?? [];
479
+ const at = entries.findLastIndex(entry => entry.kind === "prompt" && entry.text === question);
480
+ return at < 0 ? [] : entries.slice(at + 1);
481
+ };
482
+ const relay = id => {
483
+ if (!asked.has(id)) return;
484
+ /**
485
+ * Settled first: sent while it was busy, OpenCode 1 may queue the message and start a run for it
486
+ * straight after — or finish without reading it at all, which happened: the message sat in the
487
+ * run as "Round 3" and nothing answered it.
488
+ */
489
+ setTimeout(() => {
490
+ const pending = asked.get(id);
491
+ const session = model.sessions.get(id);
492
+ if (!pending || !session) return;
493
+ if (busy(session) || session.status === "waiting") {
494
+ /** A run of its own for the message: its answer goes nowhere unless it is relayed. */
495
+ pending.busy = false;
496
+ return;
497
+ }
498
+ asked.delete(id);
499
+ const followed = after(id, pending.question);
500
+ if (!followed.some(entry => entry.kind !== "prompt")) {
501
+ log.warn("message not answered", {
502
+ id
503
+ });
504
+ if (surface.open === id && surface.draft === undefined) set({
505
+ draft: pending.question,
506
+ notice: `${session.agent} finished without reading your message — enter sends it again, esc drops it.`
507
+ });
508
+ return;
509
+ }
510
+ /** Read inside a run the main agent was waiting on: its answer already went there. */
511
+ if (pending.busy || !session.parentID) return;
512
+ const answer = followed.filter(entry => entry.kind === "reply").map(entry => entry.text.trim()).filter(Boolean).join("\n\n");
513
+ const parent = model.sessions.get(session.parentID);
514
+ const how = api.v1 ? "task_id" : "sessionID";
515
+ const text = ["[Cockpit notification — information, not a request. Nothing to do unless the user asks.]", `The user messaged your ${session.agent} subagent "${session.title}" (${how} ${id}) directly.`, `They asked: ${pending.question}`, session.status === "failed" ? `It failed: ${session.error ?? "no reason given"}` : `It answered: ${answer ? clip(answer, RELAY_MAX) : "(nothing)"}`].join("\n");
516
+ feed.quiet(session.parentID, text, parent && parent.agent !== "agent" ? parent.agent : undefined).then(() => {
517
+ log.info("relayed to the main agent", {
518
+ id,
519
+ parent: session.parentID
520
+ });
521
+ if (surface.open === id) set({
522
+ notice: `The main agent now knows what ${session.agent} answered you.`
523
+ });
524
+ }).catch(error => log.warn("relay failed", {
525
+ id,
526
+ error
527
+ }));
528
+ }, SETTLE_MS);
529
+ };
530
+ const sendMessage = () => {
531
+ const session = opened();
532
+ const text = surface.draft?.trim();
533
+ surface.draft = undefined;
534
+ if (!session || !text) return draw();
535
+ /** Taken now: the model may learn something about the session before the call returns. */
536
+ const {
537
+ id,
538
+ agent
539
+ } = session;
540
+ const wasBusy = busy(session);
541
+ yours.add(`${id}:${text}`);
542
+ api.kv.set(YOURS_KEY, [...yours].slice(-YOURS_MAX));
543
+ /** Working, it answers the main agent itself; finished, its answer is relayed once it comes. */
544
+ /** Watched either way: answered in a run of its own, it is relayed; not answered, it comes back. */
545
+ asked.set(id, {
546
+ question: text,
547
+ busy: wasBusy
548
+ });
549
+ surface.notice = `Sending to ${agent}…`;
550
+ surface.top = undefined;
551
+ draw();
552
+ feed.send(id, text, wasBusy, agent).then(() => {
553
+ log.info("message sent", {
554
+ id,
555
+ agent,
556
+ busy: wasBusy
557
+ });
558
+ surface.notice = `Sent to ${agent}.`;
559
+ }).catch(error => {
560
+ log.error("message failed", {
561
+ id,
562
+ error
563
+ });
564
+ surface.notice = `Not sent: ${error instanceof Error ? error.message : String(error)}`;
565
+ }).finally(draw);
566
+ };
567
+
568
+ /** A paste while typing goes into the message — one line, as the field is. */
569
+ const offPaste = onPaste(api, text => {
570
+ if (!surface.open || surface.draft === undefined) return false;
571
+ surface.draft += text.replace(/\r?\n/g, " ");
572
+ log.debug("message: pasted", {
573
+ chars: text.length
574
+ });
575
+ draw();
576
+ return true;
577
+ });
578
+ api.lifecycle.onDispose(offPaste);
579
+
580
+ /** Typing a message takes every key before the layer, so `e`, `t`, `j`… go into the words. */
581
+ api.keymap.intercept(ctx => {
582
+ if (!surface.open || surface.draft === undefined) return;
583
+ const event = ctx.event;
584
+ ctx.consume({
585
+ preventDefault: true,
586
+ stopPropagation: true
587
+ });
588
+ if (event.name === "escape") {
589
+ surface.draft = undefined;
590
+ return draw();
591
+ }
592
+ if (event.name === "return" || event.name === "enter") return sendMessage();
593
+ if (event.name === "backspace") surface.draft = surface.draft.slice(0, -1);else if (event.sequence && !event.ctrl && !event.meta && event.sequence >= " ") surface.draft += event.sequence;
594
+ draw();
595
+ }, {
596
+ priority: 10_000
597
+ });
598
+ let confirmTimer;
599
+
600
+ /** Out of the list — the session itself stays in OpenCode, and comes back if it works again. */
601
+ const remove = ids => {
602
+ if (ids.length === 0) return;
603
+ for (const id of ids) {
604
+ hidden.delete(id);
605
+ hidden.add(id);
606
+ }
607
+ saveHidden();
608
+ log.info("removed", {
609
+ count: ids.length
610
+ });
611
+ if (surface.open && isHidden(surface.open)) {
612
+ const next = nodes().at(-1);
613
+ if (next) open(next.session.id);else close();
614
+ }
615
+ draw();
616
+ };
617
+ const finished = () => nodes().map(node => node.session).filter(session => session.status === "done" || session.status === "failed").map(session => session.id);
618
+
619
+ /** `x`: a working one stops — on a second `x`, since it cannot be undone; a finished one leaves the list. */
620
+ const stopOrRemove = () => {
621
+ const session = opened();
622
+ if (!session) return;
623
+ if (!busy(session) && session.status !== "waiting") return remove([session.id]);
624
+ if (surface.stopping !== session.id) {
625
+ surface.stopping = session.id;
626
+ surface.notice = `Press x again to stop ${session.agent}.`;
627
+ clearTimeout(confirmTimer);
628
+ confirmTimer = setTimeout(() => {
629
+ if (surface.stopping) set({
630
+ stopping: undefined,
631
+ notice: undefined
632
+ });
633
+ }, CONFIRM_MS);
634
+ return draw();
635
+ }
636
+ clearTimeout(confirmTimer);
637
+ const {
638
+ id,
639
+ agent,
640
+ title,
641
+ parentID
642
+ } = session;
643
+ surface.stopping = undefined;
644
+ surface.notice = `Stopping ${agent}…`;
645
+ draw();
646
+ const parent = parentID ? model.sessions.get(parentID) : undefined;
647
+ /**
648
+ * The main agent is told first, and why, so the stop reaches it with a reason. Measured on both:
649
+ * told, it says the subagent was stopped and waits; untold, it read a failure and relaunched.
650
+ */
651
+ const note = parentID ? feed.note(parentID, `[Cockpit] I stopped the ${agent} subagent${title ? ` "${title}"` : ""} on purpose. Don't start it again unless I ask.`, parent ? busy(parent) || parent.status === "waiting" : true, parent && parent.agent !== "agent" ? parent.agent : undefined).catch(error => log.warn("stop note failed", {
652
+ parentID,
653
+ error
654
+ })) : Promise.resolve();
655
+ note.then(() => feed.stop(id)).then(() => {
656
+ log.info("stopped", {
657
+ id,
658
+ agent,
659
+ told: Boolean(parentID)
660
+ });
661
+ surface.notice = `Stopped ${agent}. The main agent was told you stopped it.`;
662
+ }).catch(error => {
663
+ log.error("stop failed", {
664
+ id,
665
+ error
666
+ });
667
+ surface.notice = `Not stopped: ${error instanceof Error ? error.message : String(error)}`;
668
+ }).finally(draw);
669
+ };
670
+
671
+ /**
672
+ * `b`: the conversation stops waiting for this subagent — OpenCode's `ctrl+b`, from here. It moves
673
+ * every subagent that conversation is blocked on, which is what the host offers.
674
+ */
675
+ const toBackground = () => {
676
+ const session = opened();
677
+ if (!session?.parentID) return;
678
+ if (!busy(session)) return set({
679
+ notice: `${session.agent} is not running.`
680
+ });
681
+ if (session.background) return set({
682
+ notice: `${session.agent} already runs in the background.`
683
+ });
684
+ const {
685
+ id,
686
+ agent,
687
+ parentID
688
+ } = session;
689
+ surface.notice = `Moving ${agent} to the background…`;
690
+ draw();
691
+ feed.background(parentID).then(moved => {
692
+ log.info("background", {
693
+ id,
694
+ parentID,
695
+ moved
696
+ });
697
+ if (moved) applyAll(model, [{
698
+ type: "session",
699
+ id,
700
+ background: true,
701
+ at: Date.now()
702
+ }]);
703
+ surface.notice = moved ? `${agent} runs in the background; the main agent carries on and hears when it finishes.` : "OpenCode 1 does this only when started with OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true.";
704
+ }).catch(error => {
705
+ log.error("background failed", {
706
+ id,
707
+ error
708
+ });
709
+ surface.notice = `Not moved: ${error instanceof Error ? error.message : String(error)}`;
710
+ }).finally(draw);
711
+ };
712
+ const clearFinished = () => {
713
+ const ids = finished();
714
+ remove(ids);
715
+ if (ids.length === 0) api.ui.toast({
716
+ variant: "info",
717
+ title: "Subagents",
718
+ message: "No finished subagents to clear."
719
+ });
720
+ };
721
+ const restore = () => {
722
+ const count = hidden.size;
723
+ hidden.clear();
724
+ saveHidden();
725
+ log.info("restored", {
726
+ count
727
+ });
728
+ api.ui.toast({
729
+ variant: "info",
730
+ title: "Subagents",
731
+ message: count ? `${count} removed subagent${count === 1 ? "" : "s"} back in the list.` : "None removed."
732
+ });
733
+ draw();
734
+ };
735
+ const set = change => {
736
+ Object.assign(surface, change);
737
+ draw();
738
+ };
739
+ const width = () => {
740
+ surface.full = !surface.full;
741
+ api.kv.set(FULL_KEY, surface.full);
742
+ log.debug("width", {
743
+ full: surface.full
744
+ });
745
+ draw();
746
+ };
747
+ const layer = () => ({
748
+ priority: 100,
749
+ commands: [{
750
+ name: "cockpit.subagents.next",
751
+ title: "Next subagent",
752
+ run: () => step(1)
753
+ }, {
754
+ name: "cockpit.subagents.prev",
755
+ title: "Previous subagent",
756
+ run: () => step(-1)
757
+ }, {
758
+ name: "cockpit.subagents.message",
759
+ title: "Message this subagent",
760
+ run: () => startMessage()
761
+ }, {
762
+ name: "cockpit.subagents.stop",
763
+ title: "Stop, or remove from the list",
764
+ run: () => stopOrRemove()
765
+ }, {
766
+ name: "cockpit.subagents.clear",
767
+ title: "Remove every finished subagent",
768
+ run: () => clearFinished()
769
+ }, {
770
+ name: "cockpit.subagents.background",
771
+ title: "Move to the background",
772
+ run: () => toBackground()
773
+ }, {
774
+ name: "cockpit.subagents.all",
775
+ title: "Show a call's whole output",
776
+ run: () => showAll()
777
+ }, {
778
+ name: "cockpit.subagents.down",
779
+ title: "Next item",
780
+ run: () => select(1)
781
+ }, {
782
+ name: "cockpit.subagents.up",
783
+ title: "Previous item",
784
+ run: () => select(-1)
785
+ }, {
786
+ name: "cockpit.subagents.toggle",
787
+ title: "Open or fold",
788
+ run: () => toggle()
789
+ }, {
790
+ name: "cockpit.subagents.expand",
791
+ title: "Open every call",
792
+ run: () => expandAll()
793
+ }, {
794
+ name: "cockpit.subagents.thinking",
795
+ title: "Show or hide thinking",
796
+ run: () => {
797
+ api.kv.set(THINKING_KEY, !surface.thinking);
798
+ /** A block folded or opened by hand follows the new choice. */
799
+ surface.opened.clear();
800
+ surface.closed.clear();
801
+ set({
802
+ thinking: !surface.thinking
803
+ });
804
+ }
805
+ }, {
806
+ name: "cockpit.subagents.details",
807
+ title: "Details",
808
+ run: () => set({
809
+ details: !surface.details,
810
+ top: undefined
811
+ })
812
+ }, {
813
+ name: "cockpit.subagents.width",
814
+ title: "Half or full width",
815
+ run: () => width()
816
+ }, {
817
+ name: "cockpit.subagents.pageDown",
818
+ title: "Scroll down",
819
+ run: () => scroll(10)
820
+ }, {
821
+ name: "cockpit.subagents.pageUp",
822
+ title: "Scroll up",
823
+ run: () => scroll(-10)
824
+ }, {
825
+ name: "cockpit.subagents.follow",
826
+ title: "Follow the run",
827
+ run: () => set({
828
+ top: undefined,
829
+ selected: undefined
830
+ })
831
+ }, {
832
+ name: "cockpit.subagents.top",
833
+ title: "Scroll to the start",
834
+ run: () => set({
835
+ top: 0
836
+ })
837
+ }, {
838
+ name: "cockpit.subagents.close",
839
+ title: "Close",
840
+ /** Esc first lets go of the cursor, then closes. */
841
+ run: () => surface.selected ? set({
842
+ selected: undefined,
843
+ top: undefined
844
+ }) : close()
845
+ }],
846
+ bindings: [{
847
+ key: "],right",
848
+ cmd: "cockpit.subagents.next"
849
+ }, {
850
+ key: "[,left",
851
+ cmd: "cockpit.subagents.prev"
852
+ }, {
853
+ key: "m",
854
+ cmd: "cockpit.subagents.message"
855
+ }, {
856
+ key: "x",
857
+ cmd: "cockpit.subagents.stop"
858
+ }, {
859
+ key: "shift+x",
860
+ cmd: "cockpit.subagents.clear"
861
+ }, {
862
+ key: "b",
863
+ cmd: "cockpit.subagents.background"
864
+ }, {
865
+ key: "a",
866
+ cmd: "cockpit.subagents.all"
867
+ }, {
868
+ key: "j,down",
869
+ cmd: "cockpit.subagents.down"
870
+ }, {
871
+ key: "k,up",
872
+ cmd: "cockpit.subagents.up"
873
+ }, {
874
+ key: "return,space",
875
+ cmd: "cockpit.subagents.toggle"
876
+ }, {
877
+ key: "e",
878
+ cmd: "cockpit.subagents.expand"
879
+ }, {
880
+ key: "t",
881
+ cmd: "cockpit.subagents.thinking"
882
+ }, {
883
+ key: "i",
884
+ cmd: "cockpit.subagents.details"
885
+ }, {
886
+ key: "w",
887
+ cmd: "cockpit.subagents.width"
888
+ }, {
889
+ key: "d,pagedown",
890
+ cmd: "cockpit.subagents.pageDown"
891
+ }, {
892
+ key: "u,pageup",
893
+ cmd: "cockpit.subagents.pageUp"
894
+ }, {
895
+ key: "shift+g,end",
896
+ cmd: "cockpit.subagents.follow"
897
+ }, {
898
+ key: "g,home",
899
+ cmd: "cockpit.subagents.top"
900
+ }, {
901
+ key: "q,escape",
902
+ cmd: "cockpit.subagents.close"
903
+ }]
904
+ });
905
+
906
+ /** From anywhere: the working subagent, or the latest one, of the conversation on screen. */
907
+ const openLatest = () => {
908
+ follow();
909
+ const list = nodes();
910
+ const pick = list.find(({
911
+ session
912
+ }) => session.status === "running" || session.status === "waiting") ?? list.at(-1);
913
+ if (pick) open(pick.session.id);else api.ui.toast({
914
+ variant: "info",
915
+ title: "Subagents",
916
+ message: "No subagents in this conversation yet."
917
+ });
918
+ };
919
+ api.keymap.registerLayer({
920
+ commands: [{
921
+ name: "cockpit.subagents.open",
922
+ title: "Open subagents",
923
+ category: "Subagents",
924
+ namespace: "palette",
925
+ slashName: "subagents",
926
+ run: () => openLatest()
927
+ }, {
928
+ name: "cockpit.subagents.clearFinished",
929
+ title: "Clear finished subagents",
930
+ category: "Subagents",
931
+ namespace: "palette",
932
+ run: () => clearFinished()
933
+ }, {
934
+ name: "cockpit.subagents.restore",
935
+ title: "Show removed subagents again",
936
+ category: "Subagents",
937
+ namespace: "palette",
938
+ run: () => restore()
939
+ }],
940
+ bindings: keys.gather("cockpit", Object.keys(DEFAULT_KEYS))
941
+ });
942
+ api.slots.register({
943
+ /** Between the statusline (140) and the shells (170) by default; lower draws first. */
944
+ order: sidebarOrder("subagents", 150, options.sidebarOrder, {
945
+ directory: api.state.path.directory
946
+ }),
947
+ slots: {
948
+ sidebar_content: () => _$createComponent(SidebarBlock, {
949
+ api: api,
950
+ lines: lines,
951
+ onOpen: open,
952
+ onReady: box => {
953
+ block = box;
954
+ }
955
+ })
956
+ }
957
+ });
958
+ api.slots.register({
959
+ order: 160,
960
+ slots: {
961
+ app_bottom: () => _$createComponent(Overlay, {
962
+ api: api,
963
+ onReady: parts => {
964
+ backdrop = parts.backdrop;
965
+ panel = parts.panel;
966
+ pool = createRowPool(parts.lines);
967
+ draw();
968
+ },
969
+ onDismiss: () => {
970
+ if (!surface.full) close();
971
+ },
972
+ onClick: y => click(y),
973
+ onScroll: delta => scroll(delta)
974
+ })
975
+ }
976
+ });
977
+ follow();
978
+ api.lifecycle.onDispose(() => {
979
+ clearInterval(slow);
980
+ clearInterval(fast);
981
+ clearTimeout(confirmTimer);
982
+ slow = undefined;
983
+ fast = undefined;
984
+ dropKeys();
985
+ feed.dispose();
986
+ });
987
+ };
988
+ }
989
+
990
+ /** One entry for both OpenCodes: v1 calls `tui`, v2 calls `setup` (docs/opencode/v2.md). */
991
+ export default dualTui("opencode-cockpit.subagents", createSubagentsTui());