@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,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());
|