@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/v2-tui.ts ADDED
@@ -0,0 +1,360 @@
1
+ /**
2
+ * OpenCode v2 TUI plugin implementation: builds a `TuiHostApi` adapter over
3
+ * the v2 TUI plugin context so the unchanged explorer wizard (wizard.tsx)
4
+ * runs on both hosts, and registers the palette/slash command.
5
+ *
6
+ * Dialog bridging: the v1 api exposes dialog components (DialogSelect, …)
7
+ * that RETURN JSX rendered inside an enclosing `dialog.replace(render)`
8
+ * entry. The v2 api only offers promise-based dialogs that open their own
9
+ * host entry. The bridge reconciles the two: `replace()` invokes the render
10
+ * thunk synchronously — if it returns renderable content the v2 dialog shows
11
+ * it as one entry with the caller's onClose; if it returned nothing (because
12
+ * a DialogX bridge already opened the host's own dialog), the caller's
13
+ * onClose is parked and invoked when that nested dialog is dismissed without
14
+ * a result (esc), which is exactly when v1 would have fired it.
15
+ */
16
+
17
+ import { createSignal } from "solid-js"
18
+ import { declarePaletteCategory, schedulePaletteReconcile } from "./palette-category.js"
19
+ import type { TuiHostApi, TuiHostKeymapLayer } from "./tui-host.js"
20
+ import { mainMenu } from "./wizard.js"
21
+ import type { V2KeymapCommand, V2KeymapLayer, V2TuiContext, V2TuiSetup } from "./v2-types.js"
22
+
23
+ function pickThemeToken(source: unknown, ...path: Array<string | number>): unknown {
24
+ let current: unknown = source
25
+ for (const key of path) {
26
+ if (!current || typeof current !== "object") return undefined
27
+ current = (current as Record<string, unknown>)[key]
28
+ }
29
+ return current
30
+ }
31
+
32
+ /**
33
+ * The wire `location` query uses `workspace`; the plugin-facing LocationRef
34
+ * uses `workspaceID`. Translate at the boundary.
35
+ */
36
+ function wireLocation(ref: { directory: string; workspaceID?: string } | undefined): Record<string, string> | undefined {
37
+ if (!ref) return undefined
38
+ return ref.workspaceID !== undefined ? { directory: ref.directory, workspace: ref.workspaceID } : { directory: ref.directory }
39
+ }
40
+
41
+ /** Wraps the v2 client so session.* calls carry the current location query
42
+ * (v2 generated methods are flat; absent location keys default to the TUI's
43
+ * own location, which is what the explorer wants). */
44
+ function withLocationQualifiedSessions(client: unknown, location: () => Record<string, string> | undefined): unknown {
45
+ if (!client || typeof client !== "object") return client
46
+ const session = (client as { session?: Record<string, unknown> }).session
47
+ if (!session || typeof session !== "object") return client
48
+ const wrapped: Record<string, unknown> = {}
49
+ for (const method of ["list", "active", "status", "messages", "delete", "remove", "get", "wait"]) {
50
+ const fn = (session as Record<string, unknown>)[method]
51
+ if (typeof fn !== "function") continue
52
+ const bound = fn.bind(session)
53
+ wrapped[method] = (input: unknown) => {
54
+ const base = input && typeof input === "object" ? (input as Record<string, unknown>) : {}
55
+ const loc = location()
56
+ const merged = { ...loc, ...base }
57
+ return bound(merged)
58
+ }
59
+ }
60
+ return { ...((client as Record<string, unknown>) ?? {}), session: { ...session, ...wrapped } }
61
+ }
62
+
63
+ function buildV2Theme(context: V2TuiContext) {
64
+ // Read theme/mode live inside the getter so mid-session theme switches
65
+ // reach the wizard (the host resolves a fresh token set per change).
66
+ return {
67
+ get current() {
68
+ const theme = context.theme
69
+ const dark = context.themeMode === "dark"
70
+ const text = pickThemeToken(theme, "text", "default")
71
+ return {
72
+ text,
73
+ textMuted: pickThemeToken(theme, "text", "subdued") ?? text,
74
+ background: pickThemeToken(theme, "background", "default"),
75
+ backgroundPanel: pickThemeToken(theme, "background", "surface", "offset") ?? pickThemeToken(theme, "background", "default"),
76
+ primary: pickThemeToken(theme, "text", "action", "primary", "selected") ?? text,
77
+ secondary: pickThemeToken(theme, "hue", dark ? "interactive" : "neutral", dark ? 300 : 700) ?? text,
78
+ accent: pickThemeToken(theme, "hue", "accent", dark ? 200 : 800) ?? text,
79
+ success: pickThemeToken(theme, "text", "feedback", "success", "default") ?? text,
80
+ warning: pickThemeToken(theme, "text", "feedback", "warning", "default") ?? text,
81
+ error: pickThemeToken(theme, "text", "feedback", "error", "default") ?? text,
82
+ info: pickThemeToken(theme, "text", "feedback", "info", "default") ?? text,
83
+ }
84
+ },
85
+ }
86
+ }
87
+
88
+ function convertKeymapLayer(layer: TuiHostKeymapLayer): V2KeymapLayer {
89
+ const bindingsByName = new Map<string, string[]>()
90
+ for (const binding of layer.bindings ?? []) {
91
+ if (!binding?.cmd) continue
92
+ const keys = bindingsByName.get(binding.cmd) ?? []
93
+ keys.push(binding.key)
94
+ bindingsByName.set(binding.cmd, keys)
95
+ }
96
+ const commands: V2KeymapCommand[] = (layer.commands ?? []).map((command) => ({
97
+ id: command.name,
98
+ title: command.title ?? command.name,
99
+ description: command.desc,
100
+ ...(bindingsByName.get(command.name)?.length ? { bind: bindingsByName.get(command.name)!.join(",") } : {}),
101
+ run: (_input?: string, event?: { preventDefault?: () => void; stopPropagation?: () => void }) =>
102
+ command.run({ event: event ? { preventDefault: () => event.preventDefault?.(), stopPropagation: () => event.stopPropagation?.() } : undefined }),
103
+ }))
104
+ return { mode: "global", priority: layer.priority, commands }
105
+ }
106
+
107
+ /** Builds the v1-shaped `TuiHostApi` facade over a v2 TUI plugin context. */
108
+ export function buildV2HostApi(context: V2TuiContext): TuiHostApi {
109
+ const disposeFns: Array<() => void> = []
110
+ const locationRef = () => {
111
+ try {
112
+ return context.location
113
+ ? (context.location as { directory: string; workspaceID?: string })
114
+ : context.data.location.default()
115
+ } catch {
116
+ return undefined
117
+ }
118
+ }
119
+ const wireLoc = () => wireLocation(locationRef())
120
+
121
+ // --- kv: the explorer has no persisted settings; in-memory stub ---
122
+ const kvMemory: Record<string, unknown> = {}
123
+ const kv = {
124
+ get<Value = unknown>(key: string, fallback?: Value): Value {
125
+ return (kvMemory[key] === undefined ? fallback : kvMemory[key]) as Value
126
+ },
127
+ set(key: string, value: unknown) {
128
+ kvMemory[key] = value
129
+ },
130
+ ready: true,
131
+ }
132
+
133
+ // --- v1-shaped live state over the v2 data stores ---
134
+ const state = {
135
+ get config() {
136
+ // Hidden agents stay IN the record (flagged): the explorer shows
137
+ // hidden/background subagent sessions, unlike selection UIs.
138
+ const agents = context.data.location.agent.list() ?? []
139
+ const agentRecord: Record<string, any> = {}
140
+ for (const agent of agents) {
141
+ agentRecord[agent.id] = {
142
+ name: agent.name,
143
+ mode: agent.mode,
144
+ hidden: agent.hidden,
145
+ color: agent.color,
146
+ }
147
+ }
148
+ return { agent: agentRecord }
149
+ },
150
+ get provider() {
151
+ return []
152
+ },
153
+ get path() {
154
+ const dir = locationRef()?.directory ?? process.cwd()
155
+ return { config: dir, directory: dir, worktree: dir }
156
+ },
157
+ }
158
+
159
+ // --- dialog bridging (see module doc comment) ---
160
+ const dialog = context.ui.dialog
161
+ let nestedOnClose: (() => void) | undefined
162
+ const settleNested = (dismissed: boolean) => {
163
+ const onClose = nestedOnClose
164
+ nestedOnClose = undefined
165
+ if (dismissed) onClose?.()
166
+ }
167
+
168
+ const api: TuiHostApi = {
169
+ hostVersion: 2,
170
+ app: { version: context.app?.version },
171
+ client: withLocationQualifiedSessions(context.client, wireLoc),
172
+ kv,
173
+ lifecycle: {
174
+ onDispose(fn: () => void) {
175
+ disposeFns.push(fn)
176
+ },
177
+ },
178
+ mode: {
179
+ push(mode: string) {
180
+ try {
181
+ return context.keymap.mode.push(mode)
182
+ } catch {
183
+ return () => undefined
184
+ }
185
+ },
186
+ },
187
+ renderer: {
188
+ get root() {
189
+ return (context.renderer as { root?: unknown } | undefined)?.root
190
+ },
191
+ },
192
+ state,
193
+ theme: buildV2Theme(context),
194
+ ui: {
195
+ toast: (input) => {
196
+ try {
197
+ context.ui.toast.show({ title: input.title, message: input.message, variant: input.variant, duration: input.duration })
198
+ } catch {
199
+ /* toasts must never crash the explorer */
200
+ }
201
+ },
202
+ dialog: {
203
+ replace(renderer: unknown, onClose?: () => void) {
204
+ nestedOnClose = undefined
205
+ let content: unknown
206
+ try {
207
+ content = typeof renderer === "function" ? (renderer as () => unknown)() : renderer
208
+ } catch {
209
+ content = undefined
210
+ }
211
+ if (content === undefined || content === null) {
212
+ // A DialogX bridge opened the host's own dialog; esc/dismiss
213
+ // paths settle through it instead of a dialog entry here.
214
+ nestedOnClose = onClose
215
+ return
216
+ }
217
+ const element = content
218
+ dialog.show(() => element, onClose)
219
+ },
220
+ clear() {
221
+ nestedOnClose = undefined
222
+ dialog.clear()
223
+ },
224
+ setSize(size: string) {
225
+ try {
226
+ dialog.set({ size })
227
+ } catch {
228
+ /* size hints are best-effort */
229
+ }
230
+ },
231
+ },
232
+ DialogSelect(props) {
233
+ void dialog
234
+ .select({
235
+ title: props.title,
236
+ placeholder: props.placeholder,
237
+ current: props.current,
238
+ options: props.options.map((option) => ({
239
+ title: option.title,
240
+ value: option.value,
241
+ description: option.description,
242
+ category: option.category,
243
+ disabled: option.disabled,
244
+ })),
245
+ })
246
+ .then((value) => {
247
+ if (value === undefined) {
248
+ // v1 fires the enclosing dialog's onClose on esc.
249
+ settleNested(true)
250
+ return
251
+ }
252
+ settleNested(false)
253
+ // v1's onSelect receives the full option object; the v2 dialog
254
+ // resolves with the value, so recover the option it picked.
255
+ const option = props.options.find((item) => item.value === value)
256
+ if (option) props.onSelect(option)
257
+ })
258
+ .catch(() => settleNested(true))
259
+ },
260
+ DialogPrompt(props) {
261
+ void dialog
262
+ .prompt({ title: props.title, placeholder: props.placeholder, value: props.value })
263
+ .then((value) => {
264
+ settleNested(value === undefined)
265
+ if (value === undefined) props.onCancel?.()
266
+ else props.onConfirm(value)
267
+ })
268
+ .catch(() => {
269
+ settleNested(true)
270
+ props.onCancel?.()
271
+ })
272
+ },
273
+ DialogConfirm(props) {
274
+ void dialog
275
+ .confirm({ title: props.title, message: props.message, label: { confirm: props.confirmLabel } })
276
+ .then((result) => {
277
+ settleNested(result !== true)
278
+ if (result === true) props.onConfirm()
279
+ else props.onCancel?.()
280
+ })
281
+ .catch(() => {
282
+ settleNested(true)
283
+ props.onCancel?.()
284
+ })
285
+ },
286
+ DialogAlert(props) {
287
+ void dialog
288
+ .alert({ title: props.title, message: props.message })
289
+ .then(() => {
290
+ settleNested(false)
291
+ props.onConfirm?.()
292
+ })
293
+ .catch(() => {
294
+ settleNested(true)
295
+ props.onConfirm?.()
296
+ })
297
+ },
298
+ },
299
+ keymap: {
300
+ registerLayer(layer: TuiHostKeymapLayer) {
301
+ // v2 layers are reactive: the input function re-evaluates when the
302
+ // signal flips, so "unregistering" disables the layer. (The layer
303
+ // computation itself lives until the host disposes the plugin — v2
304
+ // has no per-layer unregister — but a disabled layer is inert.)
305
+ const [active, setActive] = createSignal(true)
306
+ try {
307
+ context.keymap.layer(() =>
308
+ active()
309
+ ? convertKeymapLayer(layer)
310
+ : { mode: "global", enabled: false, commands: [], bindings: [] },
311
+ )
312
+ } catch {
313
+ return () => undefined
314
+ }
315
+ return () => {
316
+ setActive(false)
317
+ }
318
+ },
319
+ },
320
+ }
321
+
322
+ return api
323
+ }
324
+
325
+ /**
326
+ * v2 TUI plugin setup: registers the palette/slash command (one reactive
327
+ * keymap layer).
328
+ */
329
+ export function createV2TuiSetup(): V2TuiSetup {
330
+ return async (context: V2TuiContext) => {
331
+ const api = buildV2HostApi(context)
332
+
333
+ const command = {
334
+ id: "subagent-explorer.open",
335
+ title: "Subagent Explorer: Open",
336
+ description: "Explore and delete subagent sessions",
337
+ category: "",
338
+ group: "",
339
+ palette: true as const,
340
+ slash: { name: "subagent-explorer" },
341
+ run: () => void mainMenu(api),
342
+ }
343
+ command.category = command.group = declarePaletteCategory("Subagent Explorer", command)
344
+ schedulePaletteReconcile()
345
+ const [paletteActive, setPaletteActive] = createSignal(true)
346
+ try {
347
+ context.keymap.layer(() =>
348
+ paletteActive()
349
+ ? { mode: "global", commands: [command as V2KeymapCommand] }
350
+ : { mode: "global", enabled: false, commands: [] },
351
+ )
352
+ } catch {
353
+ /* command registration failure must not break the plugin */
354
+ }
355
+
356
+ return () => {
357
+ setPaletteActive(false)
358
+ }
359
+ }
360
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Minimal structural types for the OpenCode v2 plugin context subset used by
3
+ * this plugin. Hand-written on purpose: the v2 plugin SDK is still beta, the
4
+ * published `@opencode-ai/plugin` npm package resolves to the v1 shape, and a
5
+ * v2 plugin module must not import the plugin SDK at runtime anyway
6
+ * (`Plugin.define` is an identity function — a plain `{ id, setup }` default
7
+ * export is the whole contract). Keep these in sync with the beta repo
8
+ * (packages/plugin/src/promise + src/tui) when the API settles.
9
+ */
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Server context
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export type V2ModelRef = { providerID: string; id: string; variant?: string }
16
+
17
+ export type V2AgentInfo = {
18
+ id: string
19
+ name: string
20
+ model?: V2ModelRef
21
+ request: { settings: Record<string, unknown>; headers: Record<string, string>; body: Record<string, unknown> }
22
+ system?: string | undefined
23
+ description?: string | undefined
24
+ mode: "subagent" | "primary" | "all"
25
+ hidden: boolean
26
+ color?: string | undefined
27
+ steps?: number | undefined
28
+ permissions: unknown[]
29
+ }
30
+
31
+ export type V2AgentEditor = {
32
+ list(): readonly V2AgentInfo[]
33
+ get(id: string): V2AgentInfo | undefined
34
+ default(id: string | undefined): void
35
+ update(id: string, update: (agent: V2AgentInfo) => void): void
36
+ remove(id: string): void
37
+ }
38
+
39
+ export type V2Registration = { dispose: () => Promise<void> | void }
40
+
41
+ export type V2Transform<Input> = (callback: (input: Input) => void) => Promise<V2Registration>
42
+
43
+ export type V2ExecuteBeforeEvent = {
44
+ tool: string
45
+ sessionID: string
46
+ agent: string
47
+ messageID: string
48
+ id: string
49
+ input: unknown
50
+ }
51
+
52
+ export type V2ExecuteAfterEvent = {
53
+ tool: string
54
+ sessionID: string
55
+ agent: string
56
+ messageID: string
57
+ id: string
58
+ input: unknown
59
+ } & (
60
+ | { status: "completed"; result: { output?: unknown; content?: unknown; metadata?: Record<string, unknown> } }
61
+ | { status: "error"; error: unknown }
62
+ )
63
+
64
+ export type V2SystemPart = { type: string; text?: string }
65
+
66
+ export type V2SessionContextEvent = {
67
+ sessionID: string
68
+ agent: string
69
+ model: V2ModelRef
70
+ system: V2SystemPart[]
71
+ messages: unknown[]
72
+ tools: Record<string, unknown>
73
+ generation: Record<string, unknown>
74
+ providerOptions: Record<string, unknown>
75
+ }
76
+
77
+ export type V2SessionInfo = {
78
+ id?: string
79
+ parentID?: string
80
+ agent?: string
81
+ model?: V2ModelRef
82
+ }
83
+
84
+ export type V2PluginContext = {
85
+ agent: {
86
+ transform: V2Transform<V2AgentEditor>
87
+ reload(): Promise<void> | void
88
+ }
89
+ tool: {
90
+ hook(
91
+ name: "execute.before",
92
+ callback: (event: V2ExecuteBeforeEvent) => Promise<void> | void,
93
+ ): Promise<V2Registration>
94
+ hook(
95
+ name: "execute.after",
96
+ callback: (event: V2ExecuteAfterEvent) => Promise<void> | void,
97
+ ): Promise<V2Registration>
98
+ }
99
+ session: {
100
+ hook(
101
+ name: "context",
102
+ callback: (event: V2SessionContextEvent) => Promise<void> | void,
103
+ ): Promise<V2Registration>
104
+ get(input: { sessionID: string }): Promise<unknown>
105
+ }
106
+ app?: { name?: string; version?: string; channel?: string }
107
+ }
108
+
109
+ export type V2ServerSetup = (context: V2PluginContext) => Promise<(() => void) | void> | (() => void) | void
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // TUI context
113
+ // ---------------------------------------------------------------------------
114
+
115
+ export type V2TuiKeyEvent = {
116
+ preventDefault?(): void
117
+ stopPropagation?(): void
118
+ [key: string]: unknown
119
+ }
120
+
121
+ export type V2KeymapCommand = {
122
+ id?: string
123
+ title?: string
124
+ description?: string
125
+ group?: string
126
+ enabled?: boolean | (() => boolean)
127
+ bind?: false | string
128
+ palette?: true
129
+ slash?: { name: string; aliases?: string[]; arguments?: true }
130
+ suggested?: boolean | (() => boolean)
131
+ run: (input?: string, event?: V2TuiKeyEvent) => void | false | Promise<void>
132
+ }
133
+
134
+ export type V2KeymapLayer = {
135
+ mode?: string
136
+ enabled?: boolean | (() => boolean)
137
+ priority?: number
138
+ commands?: readonly V2KeymapCommand[]
139
+ bindings?: readonly string[]
140
+ }
141
+
142
+ export type V2DialogSelectOption<Value> = {
143
+ title: string
144
+ value: Value
145
+ description?: string
146
+ category?: string
147
+ disabled?: boolean
148
+ }
149
+
150
+ export type V2LocationRef = { directory: string; workspaceID?: string }
151
+
152
+ export type V2AgentListEntry = V2AgentInfo
153
+
154
+ export type V2ProviderListEntry = {
155
+ id: string
156
+ name: string
157
+ activation?: string
158
+ canonical?: string
159
+ }
160
+
161
+ export type V2ModelListEntry = {
162
+ id?: string
163
+ modelID?: string
164
+ providerID: string
165
+ name?: string
166
+ variants?: Array<{ id: string } | string>
167
+ enabled?: boolean
168
+ status?: string
169
+ }
170
+
171
+ export type V2TuiContext = {
172
+ options: Readonly<Record<string, unknown>>
173
+ location?: unknown
174
+ app: { version?: string; channel?: string }
175
+ renderer: { root?: unknown }
176
+ client: any
177
+ data: {
178
+ location: {
179
+ default(): V2LocationRef
180
+ sync(location?: V2LocationRef): Promise<void>
181
+ invalidate(location?: V2LocationRef): void
182
+ agent: V2LocationCollection<V2AgentListEntry>
183
+ provider: V2LocationCollection<V2ProviderListEntry>
184
+ model: V2LocationCollection<V2ModelListEntry>
185
+ }
186
+ }
187
+ keymap: {
188
+ layer(input: () => V2KeymapLayer): void
189
+ mode: { current(): string; push(mode: string): () => void }
190
+ }
191
+ storage: {
192
+ store<Value extends object>(
193
+ key: string,
194
+ options: { initial: Value },
195
+ ): readonly [Value, (mutation: (draft: Value) => void) => Promise<void>]
196
+ }
197
+ theme: V2ResolvedTheme
198
+ themeMode: "dark" | "light"
199
+ ui: {
200
+ dialog: {
201
+ show(render: () => unknown, onClose?: () => void): void
202
+ set(options: { size?: string; centered?: boolean }): void
203
+ clear(): void
204
+ alert(options: { title: string; message: string }): Promise<void>
205
+ confirm(options: {
206
+ title: string
207
+ message: string
208
+ label?: { confirm?: string; cancel?: string }
209
+ }): Promise<boolean | undefined>
210
+ prompt(options: { title: string; description?: string; placeholder?: string; value?: string }): Promise<string | undefined>
211
+ select<Value>(options: {
212
+ title: string
213
+ placeholder?: string
214
+ current?: Value
215
+ options: readonly V2DialogSelectOption<Value>[]
216
+ }): Promise<Value | undefined>
217
+ }
218
+ toast: { show(options: { title?: string; message: string; variant?: string; duration?: number }): void }
219
+ }
220
+ }
221
+
222
+ export type V2LocationCollection<Value> = {
223
+ list(location?: V2LocationRef): readonly Value[] | undefined
224
+ sync(location?: V2LocationRef): Promise<void>
225
+ invalidate(location?: V2LocationRef): void
226
+ }
227
+
228
+ export type V2ResolvedTheme = {
229
+ text: {
230
+ default: unknown
231
+ subdued: unknown
232
+ feedback: Record<string, { default: unknown }>
233
+ action: Record<string, Record<string, unknown>>
234
+ }
235
+ background: {
236
+ default: unknown
237
+ surface: { offset: unknown; overlay: unknown }
238
+ }
239
+ hue: Record<string, Record<string | number, unknown>>
240
+ categorical?: ReadonlyArray<unknown>
241
+ }
242
+
243
+ export type V2TuiSetup = (context: V2TuiContext) => Promise<(() => void) | void> | (() => void) | void