@mirrowel/opencode-subagent-explorer 0.1.0-dev.1

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/src/wizard.tsx ADDED
@@ -0,0 +1,844 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ /**
4
+ * Subagent Explorer wizard library.
5
+ *
6
+ * The whole explorer TUI: session tree browser, detail view, deletion flows
7
+ * and idle cleanup, for the current session's subagent tree (nested children
8
+ * included, hidden/background agents flagged).
9
+ *
10
+ * The thin plugin entry lives in tui.tsx; embedding hosts (Config Studio)
11
+ * import mainMenu from here and drive it with their own TuiHostApi instance.
12
+ */
13
+
14
+ import type { TuiHostApi as TuiPluginApi } from "./tui-host.js"
15
+ import type { ScrollBoxRenderable } from "@opentui/core"
16
+ import { useTerminalDimensions } from "@opentui/solid"
17
+ import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
18
+ import {
19
+ buildSessionTree,
20
+ cleanupTargets,
21
+ configAgentIDs,
22
+ currentSessionID,
23
+ deleteSession,
24
+ fetchActiveSessionIDs,
25
+ fetchSessionDetails,
26
+ fetchSessions,
27
+ formatAge,
28
+ hiddenAgentIDs,
29
+ isHiddenAgent,
30
+ parentCandidates,
31
+ type CleanupPlan,
32
+ type SessionLite,
33
+ type SessionTreeNode,
34
+ } from "./sessions.js"
35
+
36
+ export type ExplorerRow = {
37
+ session: SessionLite
38
+ depth: number
39
+ running: boolean
40
+ hidden: boolean
41
+ }
42
+
43
+ export type ExplorerData = {
44
+ sessions: SessionLite[]
45
+ activeIDs: Set<string> | undefined
46
+ activeKnown: boolean
47
+ hidden: Set<string>
48
+ configAgents: Set<string>
49
+ }
50
+
51
+ export type TreeAction =
52
+ | { type: "delete"; row: ExplorerRow }
53
+ | { type: "details"; row: ExplorerRow }
54
+ | { type: "cleanup" }
55
+ | { type: "refresh" }
56
+ | { type: "switch-root" }
57
+ | { type: "size" }
58
+ | { type: "exit" }
59
+
60
+ export async function loadExplorerData(api: TuiPluginApi, directory: string): Promise<ExplorerData | undefined> {
61
+ const sessions = await fetchSessions(api.client, directory)
62
+ if (!sessions) return undefined
63
+ const activeIDs = await fetchActiveSessionIDs(api.client)
64
+ return {
65
+ sessions,
66
+ activeIDs,
67
+ activeKnown: activeIDs !== undefined,
68
+ hidden: hiddenAgentIDs(api.state.config),
69
+ configAgents: configAgentIDs(api.state.config),
70
+ }
71
+ }
72
+
73
+ export function explorerRows(tree: SessionTreeNode[], data: ExplorerData): ExplorerRow[] {
74
+ const rows: ExplorerRow[] = []
75
+ const walk = (nodes: SessionTreeNode[], depth: number) => {
76
+ for (const node of nodes) {
77
+ rows.push({
78
+ session: node.session,
79
+ depth,
80
+ running: data.activeIDs?.has(node.session.id) ?? false,
81
+ hidden: isHiddenAgent(node.session.agent, data.hidden, data.configAgents),
82
+ })
83
+ walk(node.children, depth + 1)
84
+ }
85
+ }
86
+ walk(tree, 0)
87
+ return rows
88
+ }
89
+
90
+ function themeOf(api: TuiPluginApi) {
91
+ return api.theme.current
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Dialog sizing (same conventions as Config Studio / Agent Variants)
96
+ // ---------------------------------------------------------------------------
97
+
98
+ type DialogSize = "medium" | "large" | "xlarge"
99
+
100
+ const OWN_SIZE_KV = "subagent-explorer.ui-width"
101
+ const OWN_HEIGHT_PERCENT_KV = "subagent-explorer.ui-height-percent"
102
+ const HOST_SIZE_KV = "config-studio.ui-width"
103
+ const HOST_HEIGHT_PERCENT_KV = "config-studio.ui-height-percent"
104
+ const HEIGHT_PERCENT_MIN = 25
105
+ const HEIGHT_PERCENT_MAX = 100
106
+ const HEIGHT_PRESETS = [
107
+ { key: "1", label: "compact", value: 35 },
108
+ { key: "2", label: "normal", value: 50 },
109
+ { key: "3", label: "tall", value: 70 },
110
+ { key: "4", label: "max", value: 100 },
111
+ ] as const
112
+ const DIALOG_WIDTH_COLUMNS: Record<DialogSize, number> = { medium: 60, large: 88, xlarge: 116 }
113
+
114
+ function isEmbedded(api: TuiPluginApi): boolean {
115
+ return (api as TuiPluginApi & { dialogScope?: "standalone" | "embedded" }).dialogScope === "embedded"
116
+ }
117
+
118
+ function sizeKey(api: TuiPluginApi): string {
119
+ return isEmbedded(api) ? HOST_SIZE_KV : OWN_SIZE_KV
120
+ }
121
+
122
+ function heightKey(api: TuiPluginApi): string {
123
+ return isEmbedded(api) ? HOST_HEIGHT_PERCENT_KV : OWN_HEIGHT_PERCENT_KV
124
+ }
125
+
126
+ function dialogSizeOf(api: TuiPluginApi): DialogSize {
127
+ const value = api.kv.get<DialogSize>(sizeKey(api), "large")
128
+ if (value === "medium" || value === "large" || value === "xlarge") return value
129
+ return "large"
130
+ }
131
+
132
+ function setDialogSize(api: TuiPluginApi, size: DialogSize) {
133
+ api.kv.set(sizeKey(api), size)
134
+ api.ui.dialog.setSize(size)
135
+ }
136
+
137
+ function nextDialogSize(api: TuiPluginApi): DialogSize {
138
+ const current = dialogSizeOf(api)
139
+ if (current === "medium") return "large"
140
+ if (current === "large") return "xlarge"
141
+ return "medium"
142
+ }
143
+
144
+ function clampHeightPercent(value: number) {
145
+ return Math.max(HEIGHT_PERCENT_MIN, Math.min(HEIGHT_PERCENT_MAX, Math.round(value)))
146
+ }
147
+
148
+ function dialogHeightPercent(api: TuiPluginApi): number {
149
+ const value = api.kv.get<number>(heightKey(api), 50)
150
+ return typeof value === "number" && Number.isFinite(value) ? clampHeightPercent(value) : 50
151
+ }
152
+
153
+ function setDialogHeightPercent(api: TuiPluginApi, value: number) {
154
+ api.kv.set(heightKey(api), clampHeightPercent(value))
155
+ }
156
+
157
+ function useDialogSize(api: TuiPluginApi) {
158
+ createEffect(() => api.ui.dialog.setSize(dialogSizeOf(api)))
159
+ }
160
+
161
+ /**
162
+ * Pure dialog-height budget (same math as Config Studio's computeDialogRows):
163
+ * OpenCode's dialog backdrop anchors at terminalHeight / 4, so a panel
164
+ * taller than 75% of the terminal overflows the bottom edge. The percent
165
+ * drives the request; the backdrop budget caps it.
166
+ */
167
+ export function computeDialogRows(percent: number, terminalHeight: number, chromeRows: number, minRows: number) {
168
+ const clampedPercent = Math.min(100, Math.max(10, Math.max(1, Math.round(percent)) || 1))
169
+ const backdropBudget = Math.max(minRows + chromeRows, Math.floor(terminalHeight * 0.75) - 1)
170
+ const available = Math.max(minRows, backdropBudget - chromeRows)
171
+ const requested = Math.max(minRows, Math.floor((terminalHeight * clampedPercent) / 100) - chromeRows)
172
+ return { availableRows: available, targetRows: Math.min(requested, available) }
173
+ }
174
+
175
+ function listRows(api: TuiPluginApi, terminalHeight: number, chromeRows = 8, minRows = 3): number {
176
+ return computeDialogRows(dialogHeightPercent(api), terminalHeight, chromeRows, minRows).targetRows
177
+ }
178
+
179
+ /** Content width for width-aware column truncation. */
180
+ function contentWidth(api: TuiPluginApi): number {
181
+ return DIALOG_WIDTH_COLUMNS[dialogSizeOf(api)] - 8
182
+ }
183
+
184
+ function cappedHeight(count: number, max: number, min = 1): number {
185
+ return Math.max(min, Math.min(count, max))
186
+ }
187
+
188
+ function truncate(text: string, width: number): string {
189
+ return text.length <= width ? text : `${text.slice(0, Math.max(1, width - 1))}…`
190
+ }
191
+
192
+ export function rowTitle(row: ExplorerRow): string {
193
+ return row.session.title ?? row.session.id
194
+ }
195
+
196
+ function showTreeOnce(api: TuiPluginApi, props: { rows: ExplorerRow[]; rootTitle: string; rootID: string; activeKnown: boolean; idleCount: number }): Promise<TreeAction | undefined> {
197
+ return new Promise((resolve) => {
198
+ let settled = false
199
+ const done = (value: TreeAction | undefined, clear = true) => {
200
+ if (settled) return
201
+ settled = true
202
+ resolve(value)
203
+ if (clear) api.ui.dialog.clear()
204
+ }
205
+ api.ui.dialog.replace(
206
+ () => <SubagentTreeDialog api={api} rows={props.rows} rootTitle={props.rootTitle} rootID={props.rootID} activeKnown={props.activeKnown} idleCount={props.idleCount} onDone={done} />,
207
+ () => done({ type: "exit" }, false),
208
+ )
209
+ })
210
+ }
211
+
212
+ export function SubagentTreeDialog(props: {
213
+ api: TuiPluginApi
214
+ rows: ExplorerRow[]
215
+ rootTitle: string
216
+ rootID: string
217
+ activeKnown: boolean
218
+ idleCount: number
219
+ onDone: (value: TreeAction | undefined) => void
220
+ }) {
221
+ const theme = () => themeOf(props.api)
222
+ const dimensions = useTerminalDimensions()
223
+ useDialogSize(props.api)
224
+ const popMode = props.api.mode.push("subagent-explorer.dialog")
225
+ const [selected, setSelected] = createSignal(0)
226
+ const current = createMemo(() => props.rows[selected()])
227
+ let scroll: ScrollBoxRenderable | undefined
228
+ const listHeight = createMemo(() => cappedHeight(props.rows.length, listRows(props.api, dimensions().height)))
229
+ const agentWidth = 16
230
+ const badgeWidth = 9
231
+ const ageWidth = 9
232
+ const titleWidth = createMemo(() => Math.max(16, contentWidth(props.api) - agentWidth - badgeWidth - ageWidth - 4))
233
+ const move = (delta: number) =>
234
+ setSelected((value) => {
235
+ const next = Math.max(0, Math.min(props.rows.length - 1, value + delta))
236
+ scroll?.scrollTo(Math.max(0, next - 2))
237
+ return next
238
+ })
239
+ const choose = () => {
240
+ const row = current()
241
+ if (row) props.onDone({ type: "delete", row })
242
+ }
243
+ const details = () => {
244
+ const row = current()
245
+ if (row) props.onDone({ type: "details", row })
246
+ }
247
+ const commandPrefix = `subagent-explorer.tree.${Math.random().toString(36).slice(2)}`
248
+ const shield = (ctx: { event?: { preventDefault?: () => void; stopPropagation?: () => void } }) => {
249
+ ctx.event?.preventDefault?.()
250
+ ctx.event?.stopPropagation?.()
251
+ }
252
+ const commands = [
253
+ { name: `${commandPrefix}.up`, title: "Previous", run: (ctx: Parameters<typeof shield>[0]) => { shield(ctx); move(-1) } },
254
+ { name: `${commandPrefix}.down`, title: "Next", run: (ctx: Parameters<typeof shield>[0]) => { shield(ctx); move(1) } },
255
+ { name: `${commandPrefix}.select`, title: "Delete session", run: () => choose() },
256
+ { name: `${commandPrefix}.details`, title: "Session details", run: () => details() },
257
+ { name: `${commandPrefix}.cleanup`, title: "Clean up idle sessions", run: () => props.onDone({ type: "cleanup" }) },
258
+ { name: `${commandPrefix}.refresh`, title: "Refresh", run: () => props.onDone({ type: "refresh" }) },
259
+ { name: `${commandPrefix}.switch`, title: "Switch root session", run: () => props.onDone({ type: "switch-root" }) },
260
+ ...(isEmbedded(props.api) ? [] : [{ name: `${commandPrefix}.size`, title: "Dialog size", run: () => props.onDone({ type: "size" } as TreeAction) }]),
261
+ { name: `${commandPrefix}.back`, title: "Exit", run: () => props.onDone({ type: "exit" }) },
262
+ ]
263
+ const unregister = props.api.keymap.registerLayer({
264
+ priority: 10000,
265
+ commands,
266
+ bindings: [
267
+ { key: "up", cmd: `${commandPrefix}.up`, desc: "Previous" },
268
+ { key: "ctrl+p", cmd: `${commandPrefix}.up`, desc: "Previous" },
269
+ { key: "down", cmd: `${commandPrefix}.down`, desc: "Next" },
270
+ { key: "ctrl+n", cmd: `${commandPrefix}.down`, desc: "Next" },
271
+ { key: "enter", cmd: `${commandPrefix}.select`, desc: "Delete" },
272
+ { key: "i", cmd: `${commandPrefix}.details`, desc: "Details" },
273
+ { key: "c", cmd: `${commandPrefix}.cleanup`, desc: "Cleanup idle" },
274
+ { key: "r", cmd: `${commandPrefix}.refresh`, desc: "Refresh" },
275
+ { key: "s", cmd: `${commandPrefix}.switch`, desc: "Switch root" },
276
+ ...(isEmbedded(props.api) ? [] : [{ key: "o", cmd: `${commandPrefix}.size`, desc: "Dialog size" }]),
277
+ { key: "escape", cmd: `${commandPrefix}.back`, desc: "Exit" },
278
+ ],
279
+ })
280
+ onCleanup(() => {
281
+ unregister()
282
+ popMode()
283
+ })
284
+
285
+ const runningCount = props.rows.filter((row) => row.running).length
286
+ const hiddenCount = props.rows.filter((row) => row.hidden).length
287
+ const statusLabel = props.activeKnown ? (runningCount > 0 ? `${runningCount} running` : "none running") : "status unknown"
288
+
289
+ return (
290
+ <box flexDirection="column" width="100%" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
291
+ <box flexDirection="row" justifyContent="space-between" width="100%" marginBottom={1}>
292
+ <text fg={theme().text}><b>{`Subagent sessions — ${truncate(props.rootTitle, 40)}`}</b></text>
293
+ <text fg={theme().textMuted} onMouseUp={() => props.onDone({ type: "exit" })}>esc exit</text>
294
+ </box>
295
+ <box flexDirection="row" gap={3} marginBottom={1}>
296
+ <text fg={theme().textMuted}>{`${props.rows.length} session(s)`}</text>
297
+ <text fg={runningCount > 0 ? theme().success : theme().textMuted}>{statusLabel}</text>
298
+ <Show when={hiddenCount > 0}>
299
+ <text fg={theme().warning}>{`${hiddenCount} hidden`}</text>
300
+ </Show>
301
+ <text fg={theme().textMuted}>{`${props.idleCount} idle`}</text>
302
+ </box>
303
+ <scrollbox maxHeight={listHeight()} ref={(element: ScrollBoxRenderable) => (scroll = element)}>
304
+ <box flexDirection="column" gap={0}>
305
+ <Show
306
+ when={props.rows.length > 0}
307
+ fallback={
308
+ <box flexDirection="row" paddingLeft={1} paddingTop={1} paddingBottom={1}>
309
+ <text fg={theme().textMuted}>{"No subagent sessions in this session's tree."}</text>
310
+ </box>
311
+ }
312
+ >
313
+ <For each={props.rows}>
314
+ {(row, index) => {
315
+ const active = createMemo(() => selected() === index())
316
+ const titleFg = createMemo(() => (active() ? theme().background : row.running ? theme().success : row.hidden ? theme().textMuted : theme().text))
317
+ const badgeFg = createMemo(() => (active() ? theme().background : theme().warning))
318
+ return (
319
+ <box
320
+ flexDirection="row"
321
+ width="100%"
322
+ gap={1}
323
+ paddingLeft={1 + row.depth * 2}
324
+ paddingRight={1}
325
+ backgroundColor={active() ? theme().primary : theme().backgroundPanel}
326
+ onMouseOver={() => setSelected(index())}
327
+ onMouseUp={() => props.onDone({ type: "delete", row })}
328
+ >
329
+ <text fg={titleFg()} flexShrink={0}>{row.running ? "▶" : "·"}</text>
330
+ <text width={agentWidth} flexShrink={0} fg={active() ? theme().background : theme().textMuted} wrapMode="none" overflow="hidden"><b>{truncate(row.session.agent ?? "agent", agentWidth)}</b></text>
331
+ <Show when={row.hidden}>
332
+ <text width={badgeWidth} flexShrink={0} fg={badgeFg()} wrapMode="none" overflow="hidden">{"[hidden]"}</text>
333
+ </Show>
334
+ <text width={titleWidth()} flexGrow={1} fg={titleFg()} wrapMode="none" overflow="hidden">{truncate(rowTitle(row), titleWidth())}</text>
335
+ <text width={ageWidth} flexShrink={0} fg={active() ? theme().background : theme().textMuted} wrapMode="none" overflow="hidden">{formatAge(row.session.updated ?? row.session.created)}</text>
336
+ </box>
337
+ )
338
+ }}
339
+ </For>
340
+ </Show>
341
+ </box>
342
+ </scrollbox>
343
+ <box flexDirection="row" gap={3} marginTop={1}>
344
+ <text fg={theme().textMuted}>enter delete</text>
345
+ <text fg={theme().textMuted}>i details</text>
346
+ <text fg={theme().textMuted}>c cleanup idle</text>
347
+ <text fg={theme().textMuted}>r refresh</text>
348
+ <text fg={theme().textMuted}>s switch session</text>
349
+ <Show when={!isEmbedded(props.api)}>
350
+ <text fg={theme().textMuted}>o size</text>
351
+ </Show>
352
+ </box>
353
+ </box>
354
+ )
355
+ }
356
+
357
+ function showDangerConfirm(api: TuiPluginApi, props: { title: string; message: string; confirmLabel: string }): Promise<boolean> {
358
+ return new Promise((resolve) => {
359
+ let settled = false
360
+ const done = (value: boolean) => {
361
+ if (settled) return
362
+ settled = true
363
+ resolve(value)
364
+ api.ui.dialog.clear()
365
+ }
366
+ api.ui.dialog.replace(
367
+ () => <DangerConfirmDialog api={api} title={props.title} message={props.message} confirmLabel={props.confirmLabel} onDone={done} />,
368
+ () => done(false),
369
+ )
370
+ })
371
+ }
372
+
373
+ function DangerConfirmDialog(props: { api: TuiPluginApi; title: string; message: string; confirmLabel: string; onDone: (value: boolean) => void }) {
374
+ const theme = () => themeOf(props.api)
375
+ useDialogSize(props.api)
376
+ const popMode = props.api.mode.push("subagent-explorer.confirm")
377
+ const commandPrefix = `subagent-explorer.confirm.${Math.random().toString(36).slice(2)}`
378
+ const unregister = props.api.keymap.registerLayer({
379
+ priority: 10000,
380
+ commands: [
381
+ { name: `${commandPrefix}.confirm`, title: "Confirm", run: () => props.onDone(true) },
382
+ { name: `${commandPrefix}.cancel`, title: "Cancel", run: () => props.onDone(false) },
383
+ ],
384
+ bindings: [
385
+ { key: "enter", cmd: `${commandPrefix}.confirm`, desc: "Confirm" },
386
+ { key: "escape", cmd: `${commandPrefix}.cancel`, desc: "Cancel" },
387
+ ],
388
+ })
389
+ onCleanup(() => {
390
+ unregister()
391
+ popMode()
392
+ })
393
+ return (
394
+ <box flexDirection="column" width="100%" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} gap={1}>
395
+ <text fg={theme().error}><b>{props.title}</b></text>
396
+ <text fg={theme().text}>{props.message}</text>
397
+ <box flexDirection="row" gap={4} marginTop={1}>
398
+ <box paddingLeft={1} paddingRight={1} backgroundColor={theme().error} onMouseUp={() => props.onDone(true)}>
399
+ <text fg={theme().background}><b>{props.confirmLabel}</b></text>
400
+ </box>
401
+ <box paddingLeft={1} paddingRight={1} onMouseUp={() => props.onDone(false)}>
402
+ <text fg={theme().textMuted}>{"Cancel (esc)"}</text>
403
+ </box>
404
+ </box>
405
+ </box>
406
+ )
407
+ }
408
+
409
+ function showInfo(api: TuiPluginApi, props: { title: string; message: string }): Promise<void> {
410
+ return new Promise((resolve) => {
411
+ let settled = false
412
+ const done = () => {
413
+ if (settled) return
414
+ settled = true
415
+ resolve()
416
+ api.ui.dialog.clear()
417
+ }
418
+ api.ui.dialog.replace(() => <InfoDialog api={api} title={props.title} message={props.message} onDone={done} />, done)
419
+ })
420
+ }
421
+
422
+ function InfoDialog(props: { api: TuiPluginApi; title: string; message: string; onDone: () => void }) {
423
+ const theme = () => themeOf(props.api)
424
+ const dimensions = useTerminalDimensions()
425
+ useDialogSize(props.api)
426
+ const popMode = props.api.mode.push("subagent-explorer.info")
427
+ const commandPrefix = `subagent-explorer.info.${Math.random().toString(36).slice(2)}`
428
+ const unregister = props.api.keymap.registerLayer({
429
+ priority: 10000,
430
+ commands: [{ name: `${commandPrefix}.done`, title: "Close", run: () => props.onDone() }],
431
+ bindings: [
432
+ { key: "enter", cmd: `${commandPrefix}.done`, desc: "Close" },
433
+ { key: "escape", cmd: `${commandPrefix}.done`, desc: "Close" },
434
+ ],
435
+ })
436
+ onCleanup(() => {
437
+ unregister()
438
+ popMode()
439
+ })
440
+ const lines = props.message.split("\n")
441
+ const maxHeight = cappedHeight(lines.length, listRows(props.api, dimensions().height, 6, 3))
442
+ return (
443
+ <box flexDirection="column" width="100%" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
444
+ <text fg={theme().text}><b>{props.title}</b></text>
445
+ <scrollbox maxHeight={maxHeight} marginTop={1}>
446
+ <box flexDirection="column" gap={0}>
447
+ <For each={lines}>
448
+ {(line) => <text fg={line.startsWith("!!") ? theme().error : theme().text}>{line.replace(/^!!/, "")}</text>}
449
+ </For>
450
+ </box>
451
+ </scrollbox>
452
+ <box flexDirection="row" gap={3} marginTop={1}>
453
+ <text fg={theme().textMuted}>enter/esc close</text>
454
+ </box>
455
+ </box>
456
+ )
457
+ }
458
+
459
+ function showConfirm(api: TuiPluginApi, props: { title: string; message: string }): Promise<boolean> {
460
+ return new Promise((resolve) => {
461
+ let settled = false
462
+ const done = (value: boolean) => {
463
+ if (settled) return
464
+ settled = true
465
+ resolve(value)
466
+ api.ui.dialog.clear()
467
+ }
468
+ api.ui.dialog.replace(
469
+ () =>
470
+ api.ui.DialogConfirm({
471
+ title: props.title,
472
+ message: props.message,
473
+ onConfirm: () => done(true),
474
+ onCancel: () => done(false),
475
+ }),
476
+ () => done(false),
477
+ )
478
+ })
479
+ }
480
+
481
+ async function deleteOne(api: TuiPluginApi, row: ExplorerRow): Promise<boolean> {
482
+ const label = `${rowTitle(row)}${row.hidden ? " [hidden agent]" : ""}`
483
+ const summary = `${label}\nagent: ${row.session.agent ?? "unknown"}\nlast activity: ${formatAge(row.session.updated ?? row.session.created)}\n\nDeleting a session permanently removes it, its messages, and its history. The parent session keeps the task result text; only the jump target is lost.`
484
+ const confirmed = row.running
485
+ ? await showDangerConfirm(api, {
486
+ title: "Delete RUNNING subagent session?",
487
+ message: `${summary}\n\n!!This session is running right now. Deleting it interrupts the work in progress.`,
488
+ confirmLabel: "Delete anyway",
489
+ })
490
+ : await showConfirm(api, { title: "Delete subagent session?", message: summary })
491
+ if (!confirmed) return false
492
+ const ok = await deleteSession(api.client, row.session.id)
493
+ api.ui.toast({
494
+ variant: ok ? "default" : "error",
495
+ title: ok ? "Session deleted" : "Delete failed",
496
+ message: ok ? label : `Could not delete ${label} — the host client exposes no working session delete.`,
497
+ })
498
+ return ok
499
+ }
500
+
501
+ async function runCleanup(api: TuiPluginApi, plan: CleanupPlan, rootTitle: string): Promise<number> {
502
+ if (plan.deletable.length === 0) {
503
+ await showInfo(api, {
504
+ title: "Nothing to clean up",
505
+ message: plan.sparedActive.length > 0 ? `All ${plan.sparedActive.length} subagent session(s) are running right now.` : "There are no subagent sessions in this session's tree.",
506
+ })
507
+ return 0
508
+ }
509
+ const runningNote = plan.sparedActive.length > 0 ? `\n\n${plan.sparedActive.length} running session(s) will be spared.` : ""
510
+ const confirmed = await showDangerConfirm(api, {
511
+ title: `Delete ${plan.deletable.length} idle subagent session(s)?`,
512
+ message: `Permanently deletes every idle subagent session in "${truncate(rootTitle, 40)}"'s tree (nested children included), with all messages and history.${runningNote}`,
513
+ confirmLabel: "Delete idle sessions",
514
+ })
515
+ if (!confirmed) return 0
516
+ let deleted = 0
517
+ for (const session of plan.deletable) {
518
+ if (await deleteSession(api.client, session.id)) deleted += 1
519
+ }
520
+ const failed = plan.deletable.length - deleted
521
+ api.ui.toast({
522
+ variant: failed > 0 ? "warning" : "default",
523
+ title: "Cleanup finished",
524
+ message: failed > 0 ? `Deleted ${deleted}, failed ${failed}.` : `Deleted ${deleted} session(s).`,
525
+ })
526
+ return deleted
527
+ }
528
+
529
+ function showRootPicker(api: TuiPluginApi, sessions: SessionLite[], currentRoot: string | undefined): Promise<string | undefined> {
530
+ return new Promise((resolve) => {
531
+ let settled = false
532
+ const done = (value: string | undefined) => {
533
+ if (settled) return
534
+ settled = true
535
+ resolve(value)
536
+ api.ui.dialog.clear()
537
+ }
538
+ const options = parentCandidates(sessions)
539
+ .slice(0, 200)
540
+ .map((session) => ({
541
+ title: truncate(session.title ?? session.id, 48),
542
+ value: session.id,
543
+ description: `${session.agent ?? "agent"} · ${formatAge(session.updated ?? session.created)}${session.id === currentRoot ? " · current" : ""}`,
544
+ }))
545
+ api.ui.dialog.replace(
546
+ () =>
547
+ api.ui.DialogSelect({
548
+ title: "Explore which session's subagents?",
549
+ placeholder: "Sessions that have subagents",
550
+ options,
551
+ current: currentRoot,
552
+ onSelect: (option) => done(option.value),
553
+ }),
554
+ () => done(undefined),
555
+ )
556
+ })
557
+ }
558
+
559
+ async function showDetails(api: TuiPluginApi, row: ExplorerRow): Promise<void> {
560
+ const details = await fetchSessionDetails(api.client, row.session.id)
561
+ const lines = [
562
+ `title: ${rowTitle(row)}`,
563
+ `agent: ${row.session.agent ?? "unknown"}${row.hidden ? " (hidden/background)" : ""}`,
564
+ `session: ${row.session.id}`,
565
+ `status: ${row.running ? "running" : "idle"}`,
566
+ `created: ${formatAge(row.session.created)}`,
567
+ `last activity: ${formatAge(row.session.updated ?? row.session.created)}`,
568
+ ]
569
+ if (details) {
570
+ lines.push(`messages: ${details.messageCount}`)
571
+ if (details.model) lines.push(`last model: ${details.model}`)
572
+ } else {
573
+ lines.push("messages: unknown (host client does not expose message listing)")
574
+ }
575
+ if (details?.firstPrompt) {
576
+ lines.push("")
577
+ lines.push("first prompt:")
578
+ lines.push(details.firstPrompt)
579
+ }
580
+ await showInfo(api, { title: "Subagent session", message: lines.join("\n") })
581
+ }
582
+
583
+ // ---------------------------------------------------------------------------
584
+ // Dialog size picker (same picker as Config Studio / Agent Variants)
585
+ // ---------------------------------------------------------------------------
586
+
587
+ type SizeSliderChoice = { action: "save" | "custom-height"; height: number }
588
+
589
+ function showPrompt(api: TuiPluginApi, props: { title: string; placeholder?: string; value?: string }): Promise<string | undefined> {
590
+ return new Promise((resolve) => {
591
+ let settled = false
592
+ const done = (value: string | undefined) => {
593
+ if (settled) return
594
+ settled = true
595
+ resolve(value)
596
+ api.ui.dialog.clear()
597
+ }
598
+ api.ui.dialog.replace(
599
+ () =>
600
+ api.ui.DialogPrompt({
601
+ title: props.title,
602
+ placeholder: props.placeholder,
603
+ value: props.value,
604
+ onConfirm: (value) => done(value),
605
+ onCancel: () => done(undefined),
606
+ }),
607
+ () => done(undefined),
608
+ )
609
+ })
610
+ }
611
+
612
+ async function showSizeSlider(api: TuiPluginApi): Promise<SizeSliderChoice | undefined> {
613
+ let current = dialogHeightPercent(api)
614
+ while (true) {
615
+ const choice = await showSizeSliderOnce(api, current)
616
+ if (!choice) return undefined
617
+ current = choice.height
618
+ if (choice.action === "save") return choice
619
+
620
+ const input = await showPrompt(api, {
621
+ title: "Dialog height percent",
622
+ placeholder: `${HEIGHT_PERCENT_MIN}-${HEIGHT_PERCENT_MAX}`,
623
+ value: String(current),
624
+ })
625
+ if (input === undefined) continue
626
+ const value = Number(input)
627
+ if (!Number.isFinite(value)) {
628
+ await showInfo(api, { title: "Invalid height", message: `Enter a number from ${HEIGHT_PERCENT_MIN} to ${HEIGHT_PERCENT_MAX}.` })
629
+ continue
630
+ }
631
+ current = clampHeightPercent(value)
632
+ }
633
+ }
634
+
635
+ function showSizeSliderOnce(api: TuiPluginApi, current: number): Promise<SizeSliderChoice | undefined> {
636
+ return new Promise((resolve) => {
637
+ let settled = false
638
+ const done = (value: SizeSliderChoice | undefined, clear = true) => {
639
+ if (settled) return
640
+ settled = true
641
+ resolve(value)
642
+ if (clear) api.ui.dialog.clear()
643
+ }
644
+ api.ui.dialog.replace(
645
+ () => <SizeSliderDialog api={api} current={current} onDone={done} />,
646
+ () => done(undefined, false),
647
+ )
648
+ })
649
+ }
650
+
651
+ function SizeSliderDialog(props: { api: TuiPluginApi; current: number; onDone: (value: SizeSliderChoice | undefined) => void }) {
652
+ const theme = () => themeOf(props.api)
653
+ const dimensions = useTerminalDimensions()
654
+ useDialogSize(props.api)
655
+ const [height, setHeight] = createSignal(clampHeightPercent(props.current))
656
+ const popMode = props.api.mode.push("subagent-explorer.size")
657
+ const commandPrefix = `subagent-explorer.size.${Math.random().toString(36).slice(2)}`
658
+
659
+ const cycleWidth = () => setDialogSize(props.api, nextDialogSize(props.api))
660
+ const setPreset = (preset: string) => {
661
+ const found = HEIGHT_PRESETS.find((item) => item.label === preset)
662
+ if (found) setHeight(found.value)
663
+ }
664
+ const move = (delta: number) => setHeight((value) => clampHeightPercent(value + delta))
665
+
666
+ const sliderWidth = createMemo(() => (dialogSizeOf(props.api) === "xlarge" ? 64 : dialogSizeOf(props.api) === "large" ? 48 : 34))
667
+ const sliderCells = createMemo(() => {
668
+ const width = sliderWidth()
669
+ const selected = Math.round(((height() - HEIGHT_PERCENT_MIN) / (HEIGHT_PERCENT_MAX - HEIGHT_PERCENT_MIN)) * (width - 1))
670
+ const presetPositions = new Map(HEIGHT_PRESETS.map((preset) => [Math.round(((preset.value - HEIGHT_PERCENT_MIN) / (HEIGHT_PERCENT_MAX - HEIGHT_PERCENT_MIN)) * (width - 1)), preset.label]))
671
+ return Array.from({ length: width }, (_, index) => {
672
+ const isCurrent = index === selected
673
+ const preset = presetPositions.get(index)
674
+ return {
675
+ char: isCurrent ? "●" : preset ? "│" : index < selected ? "━" : "─",
676
+ color: isCurrent ? theme().primary : preset ? theme().accent : index < selected ? theme().success : theme().textMuted,
677
+ }
678
+ })
679
+ })
680
+
681
+ /** Live mini preview: a mock dialog box scaled to the current settings.
682
+ * Uses computeDialogRows so the preview shows the TRUE capped height. */
683
+ const preview = createMemo(() => {
684
+ const widthColumns = DIALOG_WIDTH_COLUMNS[dialogSizeOf(props.api)]
685
+ const previewWidth = Math.max(10, Math.min(sliderWidth() + 4, 72))
686
+ const scale = previewWidth / widthColumns
687
+ const metrics = computeDialogRows(height(), dimensions().height, 6, 4)
688
+ const effective = metrics.targetRows
689
+ const requested = Math.floor((dimensions().height * Math.min(100, Math.max(25, height()))) / 100)
690
+ const rows = Math.max(4, Math.round(effective * scale))
691
+ const fill = (text: string, width: number) => {
692
+ const inner = width - 2
693
+ const slice = text.length > inner ? text.slice(0, inner - 1) + "…" : text
694
+ return `│${slice}${" ".repeat(Math.max(0, inner - slice.length))}│`
695
+ }
696
+ const lines: string[] = []
697
+ lines.push(`┌${"─".repeat(previewWidth - 2)}┐`)
698
+ lines.push(fill(`Subagent Explorer (preview)${effective < requested ? " [capped]" : ""}`, previewWidth))
699
+ for (let index = 0; index < rows - 3; index++) lines.push(fill("", previewWidth))
700
+ lines.push(`└${"─".repeat(previewWidth - 2)}┘`)
701
+ return lines
702
+ })
703
+
704
+ const shield = (ctx: { event?: { preventDefault?: () => void; stopPropagation?: () => void } }) => {
705
+ ctx.event?.preventDefault?.()
706
+ ctx.event?.stopPropagation?.()
707
+ }
708
+ const unregister = props.api.keymap.registerLayer({
709
+ priority: 10000,
710
+ commands: [
711
+ { name: `${commandPrefix}.width`, title: "Cycle width", run: (ctx: Parameters<typeof shield>[0]) => { shield(ctx); cycleWidth() } },
712
+ { name: `${commandPrefix}.left`, title: "Lower height", run: (ctx: Parameters<typeof shield>[0]) => { shield(ctx); move(-1) } },
713
+ { name: `${commandPrefix}.right`, title: "Raise height", run: (ctx: Parameters<typeof shield>[0]) => { shield(ctx); move(1) } },
714
+ { name: `${commandPrefix}.down`, title: "Lower height faster", run: (ctx: Parameters<typeof shield>[0]) => { shield(ctx); move(-5) } },
715
+ { name: `${commandPrefix}.up`, title: "Raise height faster", run: (ctx: Parameters<typeof shield>[0]) => { shield(ctx); move(5) } },
716
+ { name: `${commandPrefix}.compact`, title: "Compact preset", run: (ctx: Parameters<typeof shield>[0]) => { shield(ctx); setPreset("compact") } },
717
+ { name: `${commandPrefix}.normal`, title: "Normal preset", run: (ctx: Parameters<typeof shield>[0]) => { shield(ctx); setPreset("normal") } },
718
+ { name: `${commandPrefix}.tall`, title: "Tall preset", run: (ctx: Parameters<typeof shield>[0]) => { shield(ctx); setPreset("tall") } },
719
+ { name: `${commandPrefix}.max`, title: "Max preset", run: (ctx: Parameters<typeof shield>[0]) => { shield(ctx); setPreset("max") } },
720
+ { name: `${commandPrefix}.custom`, title: "Custom percent", run: () => props.onDone({ action: "custom-height", height: height() }) },
721
+ { name: `${commandPrefix}.save`, title: "Save", run: () => { setDialogHeightPercent(props.api, height()); props.onDone({ action: "save", height: height() }) } },
722
+ { name: `${commandPrefix}.back`, title: "Back", run: () => props.onDone(undefined) },
723
+ ],
724
+ bindings: [
725
+ { key: "w", cmd: `${commandPrefix}.width`, desc: "Cycle width" },
726
+ { key: "left", cmd: `${commandPrefix}.left`, desc: "Lower height" },
727
+ { key: "right", cmd: `${commandPrefix}.right`, desc: "Raise height" },
728
+ { key: "down", cmd: `${commandPrefix}.down`, desc: "Lower height faster" },
729
+ { key: "up", cmd: `${commandPrefix}.up`, desc: "Raise height faster" },
730
+ { key: "1", cmd: `${commandPrefix}.compact`, desc: "Compact preset" },
731
+ { key: "2", cmd: `${commandPrefix}.normal`, desc: "Normal preset" },
732
+ { key: "3", cmd: `${commandPrefix}.tall`, desc: "Tall preset" },
733
+ { key: "4", cmd: `${commandPrefix}.max`, desc: "Max preset" },
734
+ { key: "c", cmd: `${commandPrefix}.custom`, desc: "Custom percent" },
735
+ { key: "enter", cmd: `${commandPrefix}.save`, desc: "Save" },
736
+ { key: "escape", cmd: `${commandPrefix}.back`, desc: "Back" },
737
+ ],
738
+ })
739
+ onCleanup(() => {
740
+ unregister()
741
+ popMode()
742
+ })
743
+
744
+ return (
745
+ <box flexDirection="column" width="100%" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
746
+ <box flexDirection="row" justifyContent="space-between" width="100%" marginBottom={1}>
747
+ <text fg={theme().accent}><b>Dialog size</b></text>
748
+ <text fg={theme().textMuted} onMouseUp={() => props.onDone(undefined)}>esc</text>
749
+ </box>
750
+ <box flexDirection="row" gap={0} width="100%" marginBottom={1}>
751
+ <text fg={theme().textMuted}>Width: </text>
752
+ <text fg={theme().primary}><b>{dialogSizeOf(props.api)}</b></text>
753
+ <text fg={theme().textMuted}> ({DIALOG_WIDTH_COLUMNS[dialogSizeOf(props.api)]} cols, w to cycle) Height: </text>
754
+ <text fg={theme().primary}><b>{height()}%</b></text>
755
+ </box>
756
+ <box flexDirection="row" width="100%" marginBottom={1}>
757
+ <text fg={theme().textMuted}>{HEIGHT_PERCENT_MIN}% </text>
758
+ <For each={sliderCells()}>{(cell) => <text fg={cell.color}>{cell.char}</text>}</For>
759
+ <text fg={theme().textMuted}> {HEIGHT_PERCENT_MAX}%</text>
760
+ </box>
761
+ <box flexDirection="row" gap={2} marginBottom={1}>
762
+ <box flexDirection="column" gap={0}>
763
+ <For each={HEIGHT_PRESETS}>
764
+ {(preset) => <text fg={height() === preset.value ? theme().primary : theme().textMuted}>{preset.key} {preset.label}: {preset.value}%</text>}
765
+ </For>
766
+ <text fg={theme().textMuted}> </text>
767
+ <text fg={theme().textMuted}>left/right 1%</text>
768
+ <text fg={theme().textMuted}>up/down 5%</text>
769
+ <text fg={theme().textMuted}>c custom</text>
770
+ </box>
771
+ <box flexDirection="column" gap={0}>
772
+ <For each={preview()}>
773
+ {(line) => <text fg={theme().textMuted}>{line}</text>}
774
+ </For>
775
+ </box>
776
+ </box>
777
+ <box flexDirection="row" justifyContent="space-between" width="100%">
778
+ <text fg={theme().textMuted}>enter save</text>
779
+ <box paddingLeft={3} paddingRight={3} backgroundColor={theme().primary} onMouseUp={() => { setDialogHeightPercent(props.api, height()); props.onDone({ action: "save", height: height() }) }}>
780
+ <text fg={theme().background}><b>save</b></text>
781
+ </box>
782
+ </box>
783
+ </box>
784
+ )
785
+ }
786
+
787
+ /** The explorer entry point: runs the tree browser loop until the user exits. */
788
+ export async function mainMenu(api: TuiPluginApi): Promise<void> {
789
+ const directory = api.state.path?.directory ?? api.state.path?.worktree ?? ""
790
+ if (!directory) {
791
+ await showInfo(api, { title: "Subagent Explorer", message: "No project directory is available from the host — cannot list sessions." })
792
+ return
793
+ }
794
+ let rootOverride: string | undefined
795
+ while (true) {
796
+ const data = await loadExplorerData(api, directory)
797
+ if (!data) {
798
+ await showInfo(api, {
799
+ title: "Subagent Explorer",
800
+ message: "Could not load the session list from the host.\n\nThe host client does not expose session.list for this directory.",
801
+ })
802
+ return
803
+ }
804
+ const rootID = currentSessionID(rootOverride ?? api.currentSessionID?.(), data.sessions)
805
+ if (!rootID) {
806
+ await showInfo(api, { title: "Subagent Explorer", message: "No sessions found in this directory." })
807
+ return
808
+ }
809
+ const tree = buildSessionTree(data.sessions, rootID)
810
+ const rows = explorerRows(tree, data)
811
+ const idleCount = rows.filter((row) => !row.running).length
812
+ const rootSession = data.sessions.find((session) => session.id === rootID)
813
+ const action = await showTreeOnce(api, {
814
+ rows,
815
+ rootTitle: rootSession?.title ?? rootID,
816
+ rootID,
817
+ activeKnown: data.activeKnown,
818
+ idleCount,
819
+ })
820
+ if (!action || action.type === "exit") return
821
+ if (action.type === "refresh") continue
822
+ if (action.type === "size") {
823
+ await showSizeSlider(api)
824
+ continue
825
+ }
826
+ if (action.type === "switch-root") {
827
+ const next = await showRootPicker(api, data.sessions, rootID)
828
+ if (next) rootOverride = next
829
+ continue
830
+ }
831
+ if (action.type === "delete") {
832
+ await deleteOne(api, action.row)
833
+ continue
834
+ }
835
+ if (action.type === "details") {
836
+ await showDetails(api, action.row)
837
+ continue
838
+ }
839
+ if (action.type === "cleanup") {
840
+ await runCleanup(api, cleanupTargets(tree, data.activeIDs), rootSession?.title ?? rootID)
841
+ continue
842
+ }
843
+ }
844
+ }