@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/sessions.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core subagent-session data layer. Pure tree/classification logic plus
|
|
3
|
+
* fail-soft client seams. Every client call is attached to its namespace
|
|
4
|
+
* object (v2-gen class methods throw when detached), feature-detected,
|
|
5
|
+
* timeout-bounded, and envelope-unwrapping ({data} vs raw) so one code path
|
|
6
|
+
* serves the v1 and v2 hosts and the studio embed alike.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type SessionLite = {
|
|
10
|
+
id: string
|
|
11
|
+
parentID?: string
|
|
12
|
+
title?: string
|
|
13
|
+
agent?: string
|
|
14
|
+
directory?: string
|
|
15
|
+
created?: number
|
|
16
|
+
updated?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type SessionTreeNode = {
|
|
20
|
+
session: SessionLite
|
|
21
|
+
depth: number
|
|
22
|
+
children: SessionTreeNode[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type CleanupPlan = {
|
|
26
|
+
/** Idle sessions, ordered leaf-first so children die before parents. */
|
|
27
|
+
deletable: SessionLite[]
|
|
28
|
+
/** Sessions spared because they are running right now. */
|
|
29
|
+
sparedActive: SessionLite[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const CALL_TIMEOUT_MS = 5000
|
|
33
|
+
|
|
34
|
+
/** OpenCode's built-in agent ids (v1): anything else that is not in the
|
|
35
|
+
* user's config was registered at runtime (plugins) and is treated as
|
|
36
|
+
* hidden/background for display purposes. */
|
|
37
|
+
export const BUILTIN_AGENTS = new Set(["build", "plan"])
|
|
38
|
+
|
|
39
|
+
export function withTimeout<T>(fn: () => Promise<T>, ms = CALL_TIMEOUT_MS): Promise<T | undefined> {
|
|
40
|
+
return Promise.race([Promise.resolve().then(fn).catch(() => undefined), new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), ms))])
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function unwrap<T>(value: T | { data?: T } | undefined): T | undefined {
|
|
44
|
+
if (value && typeof value === "object" && "data" in (value as Record<string, unknown>)) return (value as { data?: T }).data
|
|
45
|
+
return value as T | undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toMillis(value: unknown): number | undefined {
|
|
49
|
+
if (typeof value === "number") return value
|
|
50
|
+
if (typeof value === "string") {
|
|
51
|
+
const parsed = Date.parse(value)
|
|
52
|
+
return Number.isNaN(parsed) ? undefined : parsed
|
|
53
|
+
}
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizeSession(raw: unknown): SessionLite | undefined {
|
|
58
|
+
if (!raw || typeof raw !== "object") return undefined
|
|
59
|
+
const record = raw as Record<string, any>
|
|
60
|
+
const id = typeof record.id === "string" ? record.id : undefined
|
|
61
|
+
if (!id) return undefined
|
|
62
|
+
const agentRaw = record.agent
|
|
63
|
+
const agent =
|
|
64
|
+
typeof agentRaw === "string"
|
|
65
|
+
? agentRaw
|
|
66
|
+
: typeof agentRaw?.id === "string"
|
|
67
|
+
? agentRaw.id
|
|
68
|
+
: typeof agentRaw?.agentID === "string"
|
|
69
|
+
? agentRaw.agentID
|
|
70
|
+
: undefined
|
|
71
|
+
const time = record.time && typeof record.time === "object" ? record.time : {}
|
|
72
|
+
return {
|
|
73
|
+
id,
|
|
74
|
+
parentID: typeof record.parentID === "string" ? record.parentID : undefined,
|
|
75
|
+
title: typeof record.title === "string" && record.title !== "" ? record.title : undefined,
|
|
76
|
+
agent,
|
|
77
|
+
directory: typeof record.directory === "string" ? record.directory : undefined,
|
|
78
|
+
created: toMillis(time.created ?? record.time_created),
|
|
79
|
+
updated: toMillis(time.updated ?? record.time_updated),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Lists sessions for a directory. Tries the v2 client's flat params first,
|
|
84
|
+
* then the v1 `{query}` shape; unwraps both envelope styles. Fail-soft. */
|
|
85
|
+
export async function fetchSessions(client: unknown, directory: string): Promise<SessionLite[] | undefined> {
|
|
86
|
+
const namespace = (client as { session?: Record<string, any> } | undefined)?.session
|
|
87
|
+
if (!namespace?.list) return undefined
|
|
88
|
+
const calls: (() => Promise<unknown>)[] = [
|
|
89
|
+
() => namespace.list({ directory }),
|
|
90
|
+
() => namespace.list({ query: { directory } }),
|
|
91
|
+
]
|
|
92
|
+
for (const call of calls) {
|
|
93
|
+
const result = unwrap(await withTimeout(call))
|
|
94
|
+
if (Array.isArray(result)) return result.map(normalizeSession).filter((s): s is SessionLite => !!s)
|
|
95
|
+
}
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Running session ids via `session.active()` with the `session.status()`
|
|
100
|
+
* fallback (busy/retry = running). `undefined` when neither exists or both
|
|
101
|
+
* fail — callers must treat that as "unknown", never "idle". */
|
|
102
|
+
export async function fetchActiveSessionIDs(client: unknown): Promise<Set<string> | undefined> {
|
|
103
|
+
const namespace = (client as { session?: Record<string, any> } | undefined)?.session
|
|
104
|
+
if (!namespace) return undefined
|
|
105
|
+
if (typeof namespace.active === "function") {
|
|
106
|
+
const active = unwrap(await withTimeout(() => namespace.active()))
|
|
107
|
+
if (active && typeof active === "object") {
|
|
108
|
+
const ids = new Set<string>()
|
|
109
|
+
for (const [id, value] of Object.entries(active as Record<string, unknown>)) {
|
|
110
|
+
if (value) ids.add(id)
|
|
111
|
+
}
|
|
112
|
+
return ids
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (typeof namespace.status === "function") {
|
|
116
|
+
const status = unwrap(await withTimeout(() => namespace.status()))
|
|
117
|
+
if (status && typeof status === "object") {
|
|
118
|
+
const ids = new Set<string>()
|
|
119
|
+
for (const [id, value] of Object.entries(status as Record<string, any>)) {
|
|
120
|
+
const state = value?.status ?? value
|
|
121
|
+
if (state === "busy" || state === "retry") ids.add(id)
|
|
122
|
+
}
|
|
123
|
+
return ids
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Builds the recursive subagent tree for `rootID`. Unknown-parent sessions
|
|
130
|
+
* are ignored (not part of this session's tree). Guarded against cycles. */
|
|
131
|
+
export function buildSessionTree(sessions: SessionLite[], rootID: string): SessionTreeNode[] {
|
|
132
|
+
const byParent = new Map<string, SessionLite[]>()
|
|
133
|
+
for (const session of sessions) {
|
|
134
|
+
if (!session.parentID) continue
|
|
135
|
+
const list = byParent.get(session.parentID) ?? []
|
|
136
|
+
list.push(session)
|
|
137
|
+
byParent.set(session.parentID, list)
|
|
138
|
+
}
|
|
139
|
+
const createdOf = (session: SessionLite) => session.created ?? 0
|
|
140
|
+
const sortSiblings = (list: SessionLite[]) => [...list].sort((a, b) => createdOf(a) - createdOf(b))
|
|
141
|
+
const visited = new Set<string>([rootID])
|
|
142
|
+
const build = (list: SessionLite[], depth: number): SessionTreeNode[] =>
|
|
143
|
+
sortSiblings(list).map((session) => {
|
|
144
|
+
if (visited.has(session.id)) return { session, depth, children: [] }
|
|
145
|
+
visited.add(session.id)
|
|
146
|
+
const children = build(byParent.get(session.id) ?? [], depth + 1)
|
|
147
|
+
return { session, depth, children }
|
|
148
|
+
})
|
|
149
|
+
return build(byParent.get(rootID) ?? [], 0)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Flattens a tree depth-first, parents before their children. */
|
|
153
|
+
export function collectSubtree(nodes: SessionTreeNode[]): SessionLite[] {
|
|
154
|
+
const out: SessionLite[] = []
|
|
155
|
+
const walk = (list: SessionTreeNode[]) => {
|
|
156
|
+
for (const node of list) {
|
|
157
|
+
out.push(node.session)
|
|
158
|
+
walk(node.children)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
walk(nodes)
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Splits a tree into deletable (idle) and spared (running) sessions.
|
|
166
|
+
* Deletable order is leaf-first: children are removed before the parents
|
|
167
|
+
* they hang off, so no cascade can leave dangling rows. */
|
|
168
|
+
export function cleanupTargets(nodes: SessionTreeNode[], activeIDs: Set<string> | undefined): CleanupPlan {
|
|
169
|
+
const deletable: SessionLite[] = []
|
|
170
|
+
const sparedActive: SessionLite[] = []
|
|
171
|
+
const walk = (list: SessionTreeNode[]) => {
|
|
172
|
+
for (const node of list) {
|
|
173
|
+
walk(node.children)
|
|
174
|
+
if (activeIDs?.has(node.session.id)) sparedActive.push(node.session)
|
|
175
|
+
else deletable.push(node.session)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
walk(nodes)
|
|
179
|
+
return { deletable, sparedActive }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The current session: the router's session id when the host provides one,
|
|
183
|
+
* else the most recently updated session in the directory. */
|
|
184
|
+
export function currentSessionID(explicit: string | undefined, sessions: SessionLite[]): string | undefined {
|
|
185
|
+
if (explicit) return explicit
|
|
186
|
+
let newest: SessionLite | undefined
|
|
187
|
+
for (const session of sessions) {
|
|
188
|
+
const stamp = session.updated ?? session.created ?? 0
|
|
189
|
+
const newestStamp = newest?.updated ?? newest?.created ?? 0
|
|
190
|
+
if (!newest || stamp > newestStamp) newest = session
|
|
191
|
+
}
|
|
192
|
+
return newest?.id
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Hidden/background classification: config agents flagged hidden, plus
|
|
196
|
+
* runtime-registered agents OpenCode does not expose in the config. */
|
|
197
|
+
export function hiddenAgentIDs(config: unknown): Set<string> {
|
|
198
|
+
const agents = (config as { agent?: Record<string, any> } | undefined)?.agent
|
|
199
|
+
const hidden = new Set<string>()
|
|
200
|
+
if (!agents || typeof agents !== "object") return hidden
|
|
201
|
+
for (const [id, entry] of Object.entries(agents)) {
|
|
202
|
+
if (entry && typeof entry === "object" && (entry as Record<string, unknown>).hidden === true) hidden.add(id)
|
|
203
|
+
}
|
|
204
|
+
return hidden
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** All agent ids the host knows about (config-declared). Anything absent
|
|
208
|
+
* from this set and from the built-ins was registered at runtime. */
|
|
209
|
+
export function configAgentIDs(config: unknown): Set<string> {
|
|
210
|
+
const agents = (config as { agent?: Record<string, any> } | undefined)?.agent
|
|
211
|
+
if (!agents || typeof agents !== "object") return new Set<string>()
|
|
212
|
+
return new Set(Object.keys(agents))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A session counts as hidden/background when its agent is flagged hidden
|
|
216
|
+
* in the config, or the agent was registered at runtime (not config-known,
|
|
217
|
+
* not built-in, not an agent-variants clone). */
|
|
218
|
+
export function isHiddenAgent(agent: string | undefined, hidden: Set<string>, configAgents: Set<string>): boolean {
|
|
219
|
+
if (!agent) return false
|
|
220
|
+
if (hidden.has(agent)) return true
|
|
221
|
+
return !configAgents.has(agent) && !BUILTIN_AGENTS.has(agent) && !agent.startsWith("av:")
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Deletes one session through whichever delete method the host client
|
|
225
|
+
* exposes (`session.delete`, `session.remove`). Returns false when no
|
|
226
|
+
* method exists or the call failed. */
|
|
227
|
+
export async function deleteSession(client: unknown, sessionID: string): Promise<boolean> {
|
|
228
|
+
const namespace = (client as { session?: Record<string, any> } | undefined)?.session
|
|
229
|
+
if (!namespace) return false
|
|
230
|
+
const shapes: (() => Promise<unknown>)[] = []
|
|
231
|
+
if (typeof namespace.delete === "function") {
|
|
232
|
+
shapes.push(() => namespace.delete({ sessionID }), () => namespace.delete({ path: { sessionID } }), () => namespace.delete({ path: { id: sessionID } }), () => namespace.delete({ id: sessionID }))
|
|
233
|
+
}
|
|
234
|
+
if (typeof namespace.remove === "function") {
|
|
235
|
+
shapes.push(() => namespace.remove({ sessionID }), () => namespace.remove({ path: { sessionID } }))
|
|
236
|
+
}
|
|
237
|
+
for (const call of shapes) {
|
|
238
|
+
const result = await withTimeout(call)
|
|
239
|
+
if (result !== undefined) return true
|
|
240
|
+
}
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export type SessionDetails = {
|
|
245
|
+
messageCount: number
|
|
246
|
+
model?: string
|
|
247
|
+
firstPrompt?: string
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Cheap detail read for the [i] view: last assistant model + first user
|
|
251
|
+
* prompt, from a bounded tail of the session's messages. */
|
|
252
|
+
export async function fetchSessionDetails(client: unknown, sessionID: string): Promise<SessionDetails | undefined> {
|
|
253
|
+
const namespace = (client as { session?: Record<string, any> } | undefined)?.session
|
|
254
|
+
if (!namespace?.messages) return undefined
|
|
255
|
+
const calls: (() => Promise<unknown>)[] = [
|
|
256
|
+
() => namespace.messages({ sessionID, limit: 50 }),
|
|
257
|
+
() => namespace.messages({ path: { id: sessionID }, query: { limit: 50 } }),
|
|
258
|
+
]
|
|
259
|
+
for (const call of calls) {
|
|
260
|
+
const result = unwrap(await withTimeout(call, 4000))
|
|
261
|
+
const messages = Array.isArray(result) ? result : undefined
|
|
262
|
+
if (!messages) continue
|
|
263
|
+
let model: string | undefined
|
|
264
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
265
|
+
const info = (messages[i] as any)?.info ?? messages[i]
|
|
266
|
+
const modelRef = info?.model
|
|
267
|
+
if (modelRef?.providerID && modelRef?.modelID) {
|
|
268
|
+
model = `${modelRef.providerID}/${modelRef.modelID}${modelRef.variantID ? ` @${modelRef.variantID}` : ""}`
|
|
269
|
+
break
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
let firstPrompt: string | undefined
|
|
273
|
+
for (const message of messages) {
|
|
274
|
+
const info = (message as any)?.info ?? message
|
|
275
|
+
if (info?.role !== "user") continue
|
|
276
|
+
const text = firstTextOf((message as any).parts)
|
|
277
|
+
if (text) {
|
|
278
|
+
firstPrompt = text.length > 400 ? `${text.slice(0, 400)}…` : text
|
|
279
|
+
break
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return { messageCount: messages.length, model, firstPrompt }
|
|
283
|
+
}
|
|
284
|
+
return undefined
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function firstTextOf(parts: unknown): string | undefined {
|
|
288
|
+
if (!Array.isArray(parts)) return undefined
|
|
289
|
+
for (const part of parts) {
|
|
290
|
+
const text = (part as any)?.text ?? (part as any)?.part?.text
|
|
291
|
+
if (typeof text === "string" && text.trim() !== "") return text.trim()
|
|
292
|
+
}
|
|
293
|
+
return undefined
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function formatAge(timestamp: number | undefined, now = Date.now()): string {
|
|
297
|
+
if (!timestamp) return "—"
|
|
298
|
+
const delta = Math.max(0, now - timestamp)
|
|
299
|
+
const minutes = Math.floor(delta / 60_000)
|
|
300
|
+
if (minutes < 1) return "just now"
|
|
301
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
302
|
+
const hours = Math.floor(minutes / 60)
|
|
303
|
+
if (hours < 24) return `${hours}h ago`
|
|
304
|
+
const days = Math.floor(hours / 24)
|
|
305
|
+
return `${days}d ago`
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Groups sessions for the session picker: every session in the directory
|
|
309
|
+
* that has children is a potential explorer root. */
|
|
310
|
+
export function parentCandidates(sessions: SessionLite[]): SessionLite[] {
|
|
311
|
+
const parents = new Set<string>()
|
|
312
|
+
for (const session of sessions) {
|
|
313
|
+
if (session.parentID) parents.add(session.parentID)
|
|
314
|
+
}
|
|
315
|
+
return sessions
|
|
316
|
+
.filter((session) => parents.has(session.id))
|
|
317
|
+
.sort((a, b) => (b.updated ?? b.created ?? 0) - (a.updated ?? a.created ?? 0))
|
|
318
|
+
}
|
package/src/tui-host.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-agnostic TUI API surface consumed by the wizard (wizard.tsx) and by
|
|
3
|
+
* embedding hosts (Config Studio). This is the exact bounded subset of the
|
|
4
|
+
* OpenCode v1 `TuiPluginApi` the wizard uses; a v1 api instance satisfies it
|
|
5
|
+
* structurally, and `v2-tui.ts` implements it over the OpenCode v2 TUI
|
|
6
|
+
* plugin context. The wizard never touches anything outside this interface,
|
|
7
|
+
* which is what lets one compiled wizard bundle run on both hosts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type TuiDialogSelectOption<Value = string> = {
|
|
11
|
+
title: string
|
|
12
|
+
value: Value
|
|
13
|
+
description?: string
|
|
14
|
+
category?: string
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type TuiDialogSelectProps<Value = string> = {
|
|
19
|
+
title: string
|
|
20
|
+
placeholder?: string
|
|
21
|
+
options: TuiDialogSelectOption<Value>[]
|
|
22
|
+
current?: Value
|
|
23
|
+
flat?: boolean
|
|
24
|
+
onSelect: (option: TuiDialogSelectOption<Value>) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type TuiDialogPromptProps = {
|
|
28
|
+
title: string
|
|
29
|
+
placeholder?: string
|
|
30
|
+
value?: string
|
|
31
|
+
onConfirm: (value: string) => void
|
|
32
|
+
onCancel?: () => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type TuiDialogConfirmProps = {
|
|
36
|
+
title: string
|
|
37
|
+
message: string
|
|
38
|
+
confirmLabel?: string
|
|
39
|
+
onConfirm: () => void
|
|
40
|
+
onCancel?: () => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type TuiDialogAlertProps = {
|
|
44
|
+
title: string
|
|
45
|
+
message: string
|
|
46
|
+
onConfirm?: () => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type TuiHostTheme = {
|
|
50
|
+
text: any
|
|
51
|
+
textMuted: any
|
|
52
|
+
background: any
|
|
53
|
+
backgroundPanel: any
|
|
54
|
+
primary: any
|
|
55
|
+
secondary: any
|
|
56
|
+
accent: any
|
|
57
|
+
success: any
|
|
58
|
+
warning: any
|
|
59
|
+
error: any
|
|
60
|
+
info: any
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type TuiHostKeyContext = {
|
|
64
|
+
event?: {
|
|
65
|
+
preventDefault?: () => void
|
|
66
|
+
stopPropagation?: () => void
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type TuiHostLayerCommand = {
|
|
71
|
+
name: string
|
|
72
|
+
title?: string
|
|
73
|
+
desc?: string
|
|
74
|
+
run: (ctx: TuiHostKeyContext) => void
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type TuiHostKeymapLayer = {
|
|
78
|
+
mode?: string
|
|
79
|
+
priority?: number
|
|
80
|
+
commands: readonly TuiHostLayerCommand[]
|
|
81
|
+
bindings: readonly { key: string; cmd: string; desc?: string }[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Command descriptor shared with the palette-category registry. Carries both the v1 (`category`) and v2 (`group`) grouping fields. */
|
|
85
|
+
export type TuiHostPaletteCommand = {
|
|
86
|
+
name: string
|
|
87
|
+
title: string
|
|
88
|
+
desc?: string
|
|
89
|
+
category?: string
|
|
90
|
+
group?: string
|
|
91
|
+
run: () => void | Promise<void>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Minimal provider/model shape the wizard reads; the v1 SDK's richer Provider remains structurally assignable. */
|
|
95
|
+
export type TuiHostProvider = {
|
|
96
|
+
id: string
|
|
97
|
+
name: string
|
|
98
|
+
models: Record<string, { id: string; name: string; variants?: Record<string, unknown> }>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type TuiHostApi = {
|
|
102
|
+
/** Host version marker (v2 adapter sets 2; v1 leaves undefined). */
|
|
103
|
+
hostVersion?: 1 | 2
|
|
104
|
+
app?: { version?: string }
|
|
105
|
+
/** Raw host client (v1 SDK / v2 OpenCodeClient). Embedders feature-detect. */
|
|
106
|
+
client?: unknown
|
|
107
|
+
kv: {
|
|
108
|
+
get<Value = unknown>(key: string, fallback?: Value): Value
|
|
109
|
+
set(key: string, value: unknown): void
|
|
110
|
+
readonly ready: boolean
|
|
111
|
+
}
|
|
112
|
+
lifecycle: {
|
|
113
|
+
onDispose(fn: () => void): void
|
|
114
|
+
}
|
|
115
|
+
mode: {
|
|
116
|
+
push(mode: string): () => void
|
|
117
|
+
}
|
|
118
|
+
renderer: {
|
|
119
|
+
root?: any
|
|
120
|
+
}
|
|
121
|
+
state: {
|
|
122
|
+
config: any
|
|
123
|
+
provider: readonly TuiHostProvider[]
|
|
124
|
+
path?: { config: string; directory: string; worktree: string }
|
|
125
|
+
}
|
|
126
|
+
/** Router's active session id (v1: route.current walk; v2 adapter maps its
|
|
127
|
+
* own notion). Optional — the explorer falls back to the newest session. */
|
|
128
|
+
currentSessionID?: () => string | undefined
|
|
129
|
+
/**
|
|
130
|
+
* Dialog sizing scope. "standalone" (default): the wizard uses its OWN
|
|
131
|
+
* size kv keys and offers the size picker. "embedded": the wizard reads
|
|
132
|
+
* the embedding host's keys (Config Studio's ui-width/ui-height-percent)
|
|
133
|
+
* and hides the picker — the host's dialog settings rule.
|
|
134
|
+
*/
|
|
135
|
+
dialogScope?: "standalone" | "embedded"
|
|
136
|
+
theme: {
|
|
137
|
+
readonly current: TuiHostTheme
|
|
138
|
+
}
|
|
139
|
+
ui: {
|
|
140
|
+
toast(input: { variant?: string; title?: string; message: string; duration?: number }): void
|
|
141
|
+
dialog: {
|
|
142
|
+
replace(renderer: unknown, onClose?: () => void): void
|
|
143
|
+
clear(): void
|
|
144
|
+
setSize(size: string): void
|
|
145
|
+
}
|
|
146
|
+
DialogSelect<Value = string>(props: TuiDialogSelectProps<Value>): void
|
|
147
|
+
DialogPrompt(props: TuiDialogPromptProps): void
|
|
148
|
+
DialogConfirm(props: TuiDialogConfirmProps): void
|
|
149
|
+
DialogAlert(props: TuiDialogAlertProps): void
|
|
150
|
+
}
|
|
151
|
+
keymap: {
|
|
152
|
+
registerLayer(layer: TuiHostKeymapLayer): () => void
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Subagent Explorer TUI plugin entry.
|
|
5
|
+
*
|
|
6
|
+
* Thin wrapper: registers the palette/slash command and bootstraps the
|
|
7
|
+
* explorer wizard library (wizard.tsx). Config Studio imports the wizard
|
|
8
|
+
* library directly when the module is embedded.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { TuiPlugin } from "@opencode-ai/plugin/tui"
|
|
12
|
+
import type { TuiHostApi } from "./tui-host.js"
|
|
13
|
+
import { createV2TuiSetup } from "./v2-tui.js"
|
|
14
|
+
import { mainMenu } from "./wizard.js"
|
|
15
|
+
import { currentPaletteCategory, declarePaletteCategory, schedulePaletteReconcile } from "./palette-category.js"
|
|
16
|
+
|
|
17
|
+
function registerExplorerCommand(api: import("@opencode-ai/plugin/tui").TuiPluginApi, run: () => Promise<void>) {
|
|
18
|
+
const command = {
|
|
19
|
+
namespace: "palette",
|
|
20
|
+
name: "subagent-explorer.open",
|
|
21
|
+
title: "Subagent Explorer: Open",
|
|
22
|
+
desc: "Explore and delete subagent sessions",
|
|
23
|
+
category: "",
|
|
24
|
+
slashName: "subagent-explorer",
|
|
25
|
+
run,
|
|
26
|
+
}
|
|
27
|
+
command.category = declarePaletteCategory("Subagent Explorer", command)
|
|
28
|
+
schedulePaletteReconcile()
|
|
29
|
+
const apiWithKeymap = api as import("@opencode-ai/plugin/tui").TuiPluginApi & {
|
|
30
|
+
keymap?: {
|
|
31
|
+
registerLayer?: (layer: { commands: Array<typeof command>; bindings: unknown[] }) => () => void
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (typeof apiWithKeymap.keymap?.registerLayer === "function") {
|
|
35
|
+
return apiWithKeymap.keymap.registerLayer({ commands: [command], bindings: [] })
|
|
36
|
+
}
|
|
37
|
+
return api.command?.register(() => [
|
|
38
|
+
{
|
|
39
|
+
title: "Subagent Explorer: Open",
|
|
40
|
+
value: "subagent-explorer.open",
|
|
41
|
+
description: "Explore and delete subagent sessions",
|
|
42
|
+
category: currentPaletteCategory(),
|
|
43
|
+
slash: {
|
|
44
|
+
name: "subagent-explorer",
|
|
45
|
+
},
|
|
46
|
+
onSelect: run,
|
|
47
|
+
},
|
|
48
|
+
])
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const tui: TuiPlugin = async (api) => {
|
|
52
|
+
const unregister = registerExplorerCommand(api, async () => {
|
|
53
|
+
// The v1 api satisfies TuiHostApi structurally; enrich it with the
|
|
54
|
+
// router's current-session signal and the standalone sizing scope.
|
|
55
|
+
const host = api as unknown as TuiHostApi & { route?: { current?: { name: string; params?: { sessionID?: string } } } }
|
|
56
|
+
host.currentSessionID = () => (host.route?.current?.name === "session" ? host.route.current.params?.sessionID : undefined)
|
|
57
|
+
host.dialogScope = "standalone"
|
|
58
|
+
await mainMenu(host)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
api.lifecycle.onDispose(() => {
|
|
62
|
+
unregister?.()
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Dual-target TUI entry.
|
|
68
|
+
*
|
|
69
|
+
* - OpenCode v1 (strict loader) requires `default.tui` to be a function;
|
|
70
|
+
* excess keys are ignored.
|
|
71
|
+
* - OpenCode v2 requires `default` to be `{ id, setup }`; its hand-written
|
|
72
|
+
* plugin check only inspects `id` + `setup`, so the legacy `tui` factory
|
|
73
|
+
* rides along harmlessly.
|
|
74
|
+
*/
|
|
75
|
+
export default { id: "subagent-explorer", tui, setup: createV2TuiSetup() }
|
package/src/v2-server.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode v2 server setup for subagent-explorer.
|
|
3
|
+
*
|
|
4
|
+
* Dormant by design: the explorer is TUI-driven and needs no server-side
|
|
5
|
+
* hooks. v2 auto-discovers the ./tui export from server-declared plugins,
|
|
6
|
+
* so this setup only exists to make the plugin loadable from the v2
|
|
7
|
+
* `plugins` config (and to keep the dual-target entry shape uniform).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { V2PluginContext, V2ServerSetup } from "./v2-types.js"
|
|
11
|
+
|
|
12
|
+
export function createV2ServerSetup(): V2ServerSetup {
|
|
13
|
+
return async (_context: V2PluginContext) => {
|
|
14
|
+
// Intentionally empty: no transforms, no hooks, no registrations.
|
|
15
|
+
return () => undefined
|
|
16
|
+
}
|
|
17
|
+
}
|