@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/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +20 -0
- package/dist/palette-category.d.ts +30 -0
- package/dist/palette-category.js +72 -0
- package/dist/selfwire.d.ts +57 -0
- package/dist/selfwire.js +321 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.js +16 -0
- package/dist/sessions.d.ts +78 -0
- package/dist/sessions.js +299 -0
- package/dist/tui-host.d.ts +161 -0
- package/dist/tui-host.js +9 -0
- package/dist/tui.d.ts +24 -0
- package/dist/tui.js +1962 -0
- package/dist/v2-server.d.ts +10 -0
- package/dist/v2-server.js +14 -0
- package/dist/v2-tui.d.ts +24 -0
- package/dist/v2-tui.js +357 -0
- package/dist/v2-types.d.ts +270 -0
- package/dist/v2-types.js +10 -0
- package/dist/wizard.d.ts +67 -0
- package/dist/wizard.js +1580 -0
- package/package.json +97 -0
- package/src/index.ts +23 -0
- package/src/palette-category.ts +83 -0
- package/src/selfwire.ts +311 -0
- package/src/server.ts +17 -0
- package/src/sessions.ts +318 -0
- package/src/tui-host.ts +154 -0
- package/src/tui.tsx +75 -0
- package/src/v2-server.ts +17 -0
- package/src/v2-tui.ts +360 -0
- package/src/v2-types.ts +243 -0
- package/src/wizard.tsx +844 -0
- package/tsconfig.json +13 -0
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
|
+
}
|