opencode-subagent-magazine 1.6.0-beta.2 → 1.6.0-beta.4

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/index.tsx CHANGED
@@ -1,455 +1,473 @@
1
- /** @jsxImportSource @opentui/solid */
2
-
3
- import type { JSX } from "@opentui/solid"
4
- import type {
5
- TuiPlugin,
6
- TuiPluginApi,
7
- TuiSlotContext,
8
- TuiSlotPlugin,
9
- TuiPluginModule,
10
- } from "@opencode-ai/plugin/tui"
11
- import { createSignal } from "solid-js"
12
- import { PLUGIN_VERSION } from "./_version"
13
- import { LANG_META, createT, detectLang } from "./i18n"
14
- import type { Lang, SortOrder, ScrollMode, SubStatus, SharedSignals } from "./core/types"
15
- import { KV_PREFIX } from "./core/kv"
16
- import type { PanelApi } from "./panel/panel-api"
17
- import { SubAgentPanel } from "./panel/SubAgentPanel"
18
- import { globalEntryCache, setClearTick } from "./panel/store"
19
-
20
- // Plugin entry
21
- // ===================================================================
22
-
23
- function createSidebarSlot(api: TuiPluginApi, panelApi: PanelApi, sig: SharedSignals): TuiSlotPlugin {
24
- return {
25
- order: 60,
26
- slots: {
27
- sidebar_content(ctx: TuiSlotContext, input: { session_id: string }): JSX.Element {
28
- sig.sessionId = input.session_id
29
- return (
30
- <SubAgentPanel
31
- api={panelApi}
32
- theme={ctx.theme.current as Record<string, unknown>}
33
- lang={sig.lang}
34
- maxEntries={sig.maxEntries}
35
- sortOrder={sig.sortOrder}
36
- scrollMode={sig.scrollMode}
37
- sessionId={input.session_id}
38
- />
39
- )
40
- },
41
- },
42
- }
43
- }
44
-
45
- const tui: TuiPlugin = async (api: TuiPluginApi) => {
46
- // ── language ──
47
- const stored = String(api.kv.get(`${KV_PREFIX}.lang`, ""))
48
- const initialLang: Lang =
49
- LANG_META.some((m) => m.code === stored) ? (stored as Lang) : detectLang()
50
- const [lang, setLang] = createSignal<Lang>(initialLang)
51
- const [maxEntries, setMaxEntries] = createSignal(
52
- parseInt(String(api.kv.get(`${KV_PREFIX}.max_entries`, "10")), 10) || 10
53
- )
54
- const [sortOrder, setSortOrder] = createSignal<SortOrder>(
55
- String(api.kv.get(`${KV_PREFIX}.order`, "desc")) === "asc" ? "asc" : "desc"
56
- )
57
- const [scrollMode, setScrollMode] = createSignal<ScrollMode>(
58
- String(api.kv.get(`${KV_PREFIX}.scroll_mode`, "wheel")) === "click" ? "click" : "wheel"
59
- )
60
-
61
- const signals: SharedSignals = { lang, setLang, maxEntries, setMaxEntries, sortOrder, setSortOrder, scrollMode, setScrollMode, sessionId: "" }
62
-
63
- // ── V1 PanelApi adapter: wraps the V1 host API into the shared panel contract ──
64
- const v1Api: PanelApi = {
65
- kv: api.kv as any,
66
- usage: {
67
- readSessionTokens: (sid: string): number | undefined => {
68
- if (!sid) return undefined
69
- try {
70
- const msgs = api.state.session.messages(sid)
71
- if (msgs) {
72
- for (let i = (msgs as any[]).length - 1; i >= 0; i--) {
73
- const m = (msgs as any[])[i]
74
- if (m.role !== "assistant") continue
75
- const t = m.tokens
76
- if (!t || !(Number(t.output) > 0)) continue
77
- const ctx =
78
- (Number(t.input) || 0) +
79
- (Number(t.output) || 0) +
80
- (Number(t.reasoning) || 0) +
81
- (Number(t.cache?.read) || 0) +
82
- (Number(t.cache?.write) || 0)
83
- if (ctx > 0) return ctx
84
- }
85
- }
86
- return undefined
87
- } catch {
88
- return undefined
89
- }
90
- },
91
- readSessionCost: (sid: string): number | undefined => {
92
- if (!sid) return undefined
93
- try {
94
- const session = api.state.session.get(sid)
95
- if (session?.cost != null && session.cost > 0) return session.cost
96
- const msgs = api.state.session.messages(sid)
97
- if (!msgs) return undefined
98
- let total = 0
99
- for (const m of msgs as any[]) {
100
- if (m.role === "assistant" && typeof m.cost === "number") total += m.cost
101
- }
102
- return total > 0 ? total : undefined
103
- } catch {
104
- return undefined
105
- }
106
- },
107
- readSessionModel: (sid: string): string | undefined => {
108
- if (!sid) return undefined
109
- try {
110
- const msgs = api.state.session.messages(sid)
111
- if (msgs) {
112
- for (let i = (msgs as any[]).length - 1; i >= 0; i--) {
113
- const m = (msgs as any[])[i]
114
- if (m.role === "assistant" && m.modelID) return String(m.modelID)
115
- }
116
- }
117
- return undefined
118
- } catch {
119
- return undefined
120
- }
121
- },
122
- readSessionTodo: (sid: string): { total: number; done: number } | undefined => {
123
- if (!sid) return undefined
124
- try {
125
- const todos = api.state.session.todo(sid)
126
- if (!todos || todos.length === 0) return undefined
127
- let done = 0
128
- for (const t of todos) {
129
- if (t.status === "completed" || t.status === "cancelled") done++
130
- }
131
- return { total: todos.length, done }
132
- } catch {
133
- return undefined
134
- }
135
- },
136
- },
137
- session: {
138
- get: (sid) => { try { return api.state.session.get(sid) as any } catch { return undefined } },
139
- status: (sid) => { try { return api.state.session.status(sid) as any } catch { return undefined } },
140
- messages: (sid) => { try { return api.state.session.messages(sid) as any[] } catch { return undefined } },
141
- part: (messageID) => { try { return api.state.part(messageID) as any[] } catch { return undefined } },
142
- },
143
- event: {
144
- on: (type, cb) => {
145
- switch (type) {
146
- case "part.updated":
147
- return api.event.on("message.part.updated", (e) =>
148
- cb({ type, payload: { part: (e as any).properties?.part } }))
149
- case "message.updated":
150
- return api.event.on("message.updated", () => cb({ type }))
151
- case "session.idle":
152
- return api.event.on("session.idle", (e) => cb({ type, payload: (e as any).properties }))
153
- case "session.error":
154
- return api.event.on("session.error", (e) => cb({ type, payload: (e as any).properties }))
155
- }
156
- },
157
- },
158
- client: {
159
- abort: (input) => api.client.session.abort(input).then(() => {}),
160
- },
161
- route: {
162
- navigateSession: (sessionID) => api.route.navigate("session", { sessionID }),
163
- },
164
- ui: {
165
- toast: (message, opts) => api.ui.toast({ ...opts, message } as any),
166
- },
167
- settings: {
168
- lang: () => lang(),
169
- maxEntries: () => maxEntries(),
170
- sortOrder: () => sortOrder(),
171
- scrollMode: () => scrollMode(),
172
- },
173
- }
174
-
175
- api.slots.register(createSidebarSlot(api, v1Api, signals))
176
-
177
- // ── slash command: /subagent-lang ──
178
- api.command?.register(() => [
179
- {
180
- title: "SubAgent Magazine: Language",
181
- value: "subagent-lang",
182
- description: "Switch display language (中文 / English)",
183
- slash: { name: "subagent-lang" },
184
- onSelect: (dialog) => {
185
- dialog?.replace(() => (
186
- <api.ui.DialogSelect
187
- title="Language / 语言"
188
- options={LANG_META.map((m) => ({ title: m.label, value: m.code }))}
189
- onSelect={(opt) => {
190
- const l = opt.value as Lang
191
- setLang(l)
192
- api.kv.set(`${KV_PREFIX}.lang`, l)
193
- api.ui.toast({
194
- message: "Language: " + (LANG_META.find((m) => m.code === l)?.label ?? l),
195
- })
196
- dialog?.clear()
197
- }}
198
- />
199
- ))
200
- },
201
- },
202
- {
203
- title: "SubAgent Magazine: Sort Order",
204
- value: "subagent-order",
205
- description: "Set sub-agent entry sort order (desc / asc)",
206
- slash: { name: "subagent-order" },
207
- onSelect: (dialog) => {
208
- const t = createT(() => lang())
209
- dialog?.replace(() => (
210
- <api.ui.DialogSelect
211
- title="Sort Order / 排序方式"
212
- options={[
213
- { title: t("order.desc"), value: "desc" },
214
- { title: t("order.asc"), value: "asc" },
215
- ]}
216
- onSelect={(opt) => {
217
- const o = opt.value as SortOrder
218
- setSortOrder(o)
219
- api.kv.set(`${KV_PREFIX}.order`, o)
220
- api.ui.toast({
221
- message: o === "desc" ? t("order.desc") : t("order.asc"),
222
- })
223
- dialog?.clear()
224
- }}
225
- />
226
- ))
227
- },
228
- },
229
- {
230
- title: "SubAgent Magazine: Scroll Mode",
231
- value: "subagent-scroll",
232
- description: "Set scroll mode (wheel / click)",
233
- slash: { name: "subagent-scroll" },
234
- onSelect: (dialog) => {
235
- const t = createT(() => lang())
236
- dialog?.replace(() => (
237
- <api.ui.DialogSelect
238
- title="Scroll Mode / 滚动模式"
239
- options={[
240
- { title: t("scroll.wheel"), value: "wheel" },
241
- { title: t("scroll.click"), value: "click" },
242
- ]}
243
- onSelect={(opt) => {
244
- const m = opt.value as ScrollMode
245
- setScrollMode(m)
246
- api.kv.set(`${KV_PREFIX}.scroll_mode`, m)
247
- api.ui.toast({
248
- message: m === "wheel" ? t("scroll.wheel") : t("scroll.click"),
249
- })
250
- dialog?.clear()
251
- }}
252
- />
253
- ))
254
- },
255
- },
256
- {
257
- title: "SubAgent Magazine: Max Entries",
258
- value: "subagent-max",
259
- description: "Set max visible sub-agent entries in sidebar",
260
- slash: { name: "subagent-max" },
261
- onSelect: (dialog) => {
262
- dialog?.replace(() => (
263
- <api.ui.DialogPrompt
264
- title="Max Visible Entries"
265
- description={() => (
266
- <text>Number of entries to show in the sidebar (1–50)</text>
267
- )}
268
- value={String(maxEntries())}
269
- onConfirm={(val) => {
270
- const n = Math.max(1, Math.min(50, parseInt(val, 10) || 10))
271
- setMaxEntries(n)
272
- api.kv.set(`${KV_PREFIX}.max_entries`, n)
273
- api.ui.toast({ message: `Max entries: ${n}` })
274
- dialog?.clear()
275
- }}
276
- />
277
- ))
278
- },
279
- },
280
- {
281
- title: "SubAgent Magazine: Version",
282
- value: "subagent-version",
283
- description: "Show plugin version",
284
- slash: { name: "subagent-version" },
285
- onSelect: (dialog) => {
286
- api.ui.toast({ message: `opencode-subagent-magazine v${PLUGIN_VERSION}` })
287
- dialog?.clear()
288
- },
289
- },
290
- {
291
- title: "SubAgent Magazine: Session",
292
- value: "subagent-session",
293
- description: "Show current session ID",
294
- slash: { name: "subagent-session" },
295
- onSelect: (dialog) => {
296
- api.ui.toast({ message: `Session: ${signals.sessionId}` })
297
- dialog?.clear()
298
- },
299
- },
300
- {
301
- title: "SubAgent Magazine: Clear Running",
302
- value: "subagent-clear-running",
303
- description: "Mark all running sub-agent entries as done (for stuck/zombie entries)",
304
- slash: { name: "subagent-clear-running" },
305
- onSelect: (dialog) => {
306
- const sid = signals.sessionId
307
- const entries = globalEntryCache.get(sid)
308
- if (!entries || entries.size === 0) {
309
- const msg = signals.lang() === "zh" ? "暂无子代理条目" : "No sub-agent entries found"
310
- api.ui.toast({ message: msg })
311
- dialog?.clear()
312
- return
313
- }
314
- let count = 0
315
- for (const [, entry] of entries) {
316
- if (entry.status === "running" || entry.status === "cancel_requested") {
317
- entry.status = "done" as SubStatus
318
- entry.endedAt = Date.now()
319
- count++
320
- }
321
- }
322
- if (count > 0) {
323
- // 立即写 KV
324
- try {
325
- const data = JSON.parse(String(api.kv.get(`${KV_PREFIX}.session_data`, "{}")))
326
- const sessionObj = api.state.session.get(sid)
327
- const parentID = (sessionObj as any)?.parentID as string | undefined
328
- if (parentID) {
329
- if (!data[parentID]) data[parentID] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
330
- if (!data[parentID].children) data[parentID].children = {}
331
- if (!data[parentID].children[sid]) data[parentID].children[sid] = { scroll: 0, expanded: "", entries: [] }
332
- data[parentID].children[sid] = { ...data[parentID].children[sid], entries: [...entries.values()] }
333
- } else {
334
- data[sid] = {
335
- ts: Date.now(),
336
- entries: [...entries.values()],
337
- scroll: data[sid]?.scroll ?? 0,
338
- expanded: data[sid]?.expanded ?? "",
339
- children: data[sid]?.children ?? {},
340
- }
341
- }
342
- api.kv.set(`${KV_PREFIX}.session_data`, JSON.stringify(data))
343
- } catch {}
344
- const msg = signals.lang() === "zh"
345
- ? `已标记 ${count} 个运行中的条目为完成`
346
- : `Marked ${count} running entries as done`
347
- api.ui.toast({ message: msg })
348
- } else {
349
- const msg = signals.lang() === "zh"
350
- ? "没有需要清理的运行中条目"
351
- : "No running entries to clear"
352
- api.ui.toast({ message: msg })
353
- }
354
- dialog?.clear()
355
- },
356
- },
357
- {
358
- title: "SubAgent Magazine: TTL",
359
- value: "subagent-ttl",
360
- description: "Set session data retention period (days before auto-cleanup)",
361
- slash: { name: "subagent-ttl" },
362
- onSelect: (dialog) => {
363
- const t = createT(() => signals.lang())
364
- const curRaw = parseInt(String(api.kv.get(`${KV_PREFIX}.ttl_days`, "3")), 10)
365
- const curDays = Number.isNaN(curRaw) ? 3 : curRaw
366
- const curLabel = curDays === 0 ? t("ttl.unlimited") : `${curDays}d`
367
- dialog?.replace(() => (
368
- <api.ui.DialogSelect
369
- title={`${t("ttl.label")} (${curLabel})`}
370
- options={[
371
- { title: t("ttl.3d"), value: "3" },
372
- { title: t("ttl.7d"), value: "7" },
373
- { title: t("ttl.14d"), value: "14" },
374
- { title: t("ttl.30d"), value: "30" },
375
- { title: t("ttl.unlimited"), value: "0" },
376
- ]}
377
- onSelect={(opt) => {
378
- const days = parseInt(opt.value, 10)
379
- api.kv.set(`${KV_PREFIX}.ttl_days`, String(days))
380
- const msg = days === 0 ? t("ttl.toast_unlimited") : t("ttl.toast", { n: days })
381
- api.ui.toast({ message: msg })
382
- dialog?.clear()
383
- }}
384
- />
385
- ))
386
- },
387
- },
388
- {
389
- title: "SubAgent Magazine: Clear Entries",
390
- value: "subagent-clear-entries",
391
- description: "Delete all sub-agent records for the current session (cannot be undone)",
392
- slash: { name: "subagent-clear-entries" },
393
- onSelect: (dialog) => {
394
- const t = createT(() => signals.lang())
395
- const sid = signals.sessionId
396
- const sessionObj = api.state.session.get(sid)
397
- const parentID = (sessionObj as any)?.parentID as string | undefined
398
- // 检查是否存在运行中的条目
399
- const cached = globalEntryCache.get(sid)
400
- let runningCount = 0
401
- if (cached) {
402
- for (const [, e] of cached) { if (e.status === "running") runningCount++ }
403
- }
404
- const msg = runningCount > 0
405
- ? t("clear.prompt_running", { n: runningCount })
406
- : t("clear.prompt")
407
- dialog?.replace(() => (
408
- <api.ui.DialogConfirm
409
- title={t("clear.title")}
410
- message={msg}
411
- onConfirm={() => {
412
- try {
413
- const data = JSON.parse(String(api.kv.get(`${KV_PREFIX}.session_data`, "{}")))
414
- let count = 0
415
- if (parentID) {
416
- if (data[parentID]?.children?.[sid]) {
417
- const child = data[parentID].children[sid]
418
- const ids = child.entries?.map((e: any) => e.id) ?? []
419
- count = ids.length
420
- child.entries = []
421
- child.scroll = 0
422
- child.expanded = ""
423
- child.clearedIds = [...new Set([...(child.clearedIds ?? []), ...ids])]
424
- }
425
- } else {
426
- count = data[sid]?.entries?.length ?? 0
427
- if (data[sid]) {
428
- const ids = data[sid].entries?.map((e: any) => e.id) ?? []
429
- data[sid].entries = []
430
- data[sid].scroll = 0
431
- data[sid].expanded = ""
432
- data[sid].clearedIds = [...new Set([...(data[sid].clearedIds ?? []), ...ids])]
433
- }
434
- }
435
- api.kv.set(`${KV_PREFIX}.session_data`, JSON.stringify(data))
436
- globalEntryCache.delete(sid)
437
- setClearTick((v) => v + 1)
438
- const msg = t("clear.done", { n: count })
439
- api.ui.toast({ message: msg })
440
- } catch {}
441
- dialog?.clear()
442
- }}
443
- />
444
- ))
445
- },
446
- },
447
- ])
448
- }
449
-
450
- const mod: TuiPluginModule & { id: string } = {
451
- id: "opencode-subagent-magazine",
452
- tui,
453
- }
454
-
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import type { JSX } from "@opentui/solid"
4
+ import type {
5
+ TuiPlugin,
6
+ TuiPluginApi,
7
+ TuiSlotContext,
8
+ TuiSlotPlugin,
9
+ TuiPluginModule,
10
+ } from "@opencode-ai/plugin/tui"
11
+ import { createSignal } from "solid-js"
12
+ import { PLUGIN_VERSION } from "./_version"
13
+ import { LANG_META, createT, detectLang } from "./i18n"
14
+ import type { Lang, SortOrder, ScrollMode, SubStatus, SharedSignals } from "./core/types"
15
+ import { KV_PREFIX } from "./core/kv"
16
+ import type { PanelApi } from "./panel/panel-api"
17
+ import { SubAgentPanel } from "./panel/SubAgentPanel"
18
+ import { globalEntryCache, setClearTick } from "./panel/store"
19
+
20
+ // Plugin entry
21
+ // ===================================================================
22
+
23
+ function createSidebarSlot(api: TuiPluginApi, panelApi: PanelApi, sig: SharedSignals): TuiSlotPlugin {
24
+ return {
25
+ order: 60,
26
+ slots: {
27
+ sidebar_content(ctx: TuiSlotContext, input: { session_id: string }): JSX.Element {
28
+ sig.sessionId = input.session_id
29
+ return (
30
+ <SubAgentPanel
31
+ api={panelApi}
32
+ theme={ctx.theme.current as Record<string, unknown>}
33
+ lang={sig.lang}
34
+ maxEntries={sig.maxEntries}
35
+ sortOrder={sig.sortOrder}
36
+ scrollMode={sig.scrollMode}
37
+ borderVisible={sig.borderVisible}
38
+ sessionId={input.session_id}
39
+ />
40
+ )
41
+ },
42
+ },
43
+ }
44
+ }
45
+
46
+ const tui: TuiPlugin = async (api: TuiPluginApi) => {
47
+ // ── language ──
48
+ const stored = String(api.kv.get(`${KV_PREFIX}.lang`, ""))
49
+ const initialLang: Lang =
50
+ LANG_META.some((m) => m.code === stored) ? (stored as Lang) : detectLang()
51
+ const [lang, setLang] = createSignal<Lang>(initialLang)
52
+ const [maxEntries, setMaxEntries] = createSignal(
53
+ parseInt(String(api.kv.get(`${KV_PREFIX}.max_entries`, "10")), 10) || 10
54
+ )
55
+ const [sortOrder, setSortOrder] = createSignal<SortOrder>(
56
+ String(api.kv.get(`${KV_PREFIX}.order`, "desc")) === "asc" ? "asc" : "desc"
57
+ )
58
+ const [scrollMode, setScrollMode] = createSignal<ScrollMode>(
59
+ String(api.kv.get(`${KV_PREFIX}.scroll_mode`, "wheel")) === "click" ? "click" : "wheel"
60
+ )
61
+ const [borderVisible, setBorderVisible] = createSignal<boolean>(
62
+ (api.kv.get(`${KV_PREFIX}.border`, false) as boolean) === true
63
+ )
64
+
65
+ const signals: SharedSignals = { lang, setLang, maxEntries, setMaxEntries, sortOrder, setSortOrder, scrollMode, setScrollMode, borderVisible, setBorderVisible, sessionId: "" }
66
+
67
+ // ── V1 PanelApi adapter: wraps the V1 host API into the shared panel contract ──
68
+ const v1Api: PanelApi = {
69
+ kv: api.kv as any,
70
+ usage: {
71
+ readSessionTokens: (sid: string): number | undefined => {
72
+ if (!sid) return undefined
73
+ try {
74
+ const msgs = api.state.session.messages(sid)
75
+ if (msgs) {
76
+ for (let i = (msgs as any[]).length - 1; i >= 0; i--) {
77
+ const m = (msgs as any[])[i]
78
+ if (m.role !== "assistant") continue
79
+ const t = m.tokens
80
+ if (!t || !(Number(t.output) > 0)) continue
81
+ const ctx =
82
+ (Number(t.input) || 0) +
83
+ (Number(t.output) || 0) +
84
+ (Number(t.reasoning) || 0) +
85
+ (Number(t.cache?.read) || 0) +
86
+ (Number(t.cache?.write) || 0)
87
+ if (ctx > 0) return ctx
88
+ }
89
+ }
90
+ return undefined
91
+ } catch {
92
+ return undefined
93
+ }
94
+ },
95
+ readSessionCost: (sid: string): number | undefined => {
96
+ if (!sid) return undefined
97
+ try {
98
+ const session = api.state.session.get(sid)
99
+ if (session?.cost != null && session.cost > 0) return session.cost
100
+ const msgs = api.state.session.messages(sid)
101
+ if (!msgs) return undefined
102
+ let total = 0
103
+ for (const m of msgs as any[]) {
104
+ if (m.role === "assistant" && typeof m.cost === "number") total += m.cost
105
+ }
106
+ return total > 0 ? total : undefined
107
+ } catch {
108
+ return undefined
109
+ }
110
+ },
111
+ readSessionModel: (sid: string): string | undefined => {
112
+ if (!sid) return undefined
113
+ try {
114
+ const msgs = api.state.session.messages(sid)
115
+ if (msgs) {
116
+ for (let i = (msgs as any[]).length - 1; i >= 0; i--) {
117
+ const m = (msgs as any[])[i]
118
+ if (m.role === "assistant" && m.modelID) return String(m.modelID)
119
+ }
120
+ }
121
+ return undefined
122
+ } catch {
123
+ return undefined
124
+ }
125
+ },
126
+ readSessionTodo: (sid: string): { total: number; done: number } | undefined => {
127
+ if (!sid) return undefined
128
+ try {
129
+ const todos = api.state.session.todo(sid)
130
+ if (!todos || todos.length === 0) return undefined
131
+ let done = 0
132
+ for (const t of todos) {
133
+ if (t.status === "completed" || t.status === "cancelled") done++
134
+ }
135
+ return { total: todos.length, done }
136
+ } catch {
137
+ return undefined
138
+ }
139
+ },
140
+ },
141
+ session: {
142
+ get: (sid) => { try { return api.state.session.get(sid) as any } catch { return undefined } },
143
+ status: (sid) => { try { return api.state.session.status(sid) as any } catch { return undefined } },
144
+ messages: (sid) => { try { return api.state.session.messages(sid) as any[] } catch { return undefined } },
145
+ part: (messageID) => { try { return api.state.part(messageID) as any[] } catch { return undefined } },
146
+ },
147
+ event: {
148
+ on: (type, cb) => {
149
+ switch (type) {
150
+ case "part.updated":
151
+ return api.event.on("message.part.updated", (e) =>
152
+ cb({ type, payload: { part: (e as any).properties?.part } }))
153
+ case "message.updated":
154
+ return api.event.on("message.updated", () => cb({ type }))
155
+ case "session.idle":
156
+ return api.event.on("session.idle", (e) => cb({ type, payload: (e as any).properties }))
157
+ case "session.error":
158
+ return api.event.on("session.error", (e) => cb({ type, payload: (e as any).properties }))
159
+ }
160
+ },
161
+ },
162
+ client: {
163
+ abort: (input) => api.client.session.abort(input).then(() => {}),
164
+ },
165
+ route: {
166
+ navigateSession: (sessionID) => api.route.navigate("session", { sessionID }),
167
+ },
168
+ ui: {
169
+ toast: (message, opts) => api.ui.toast({ ...opts, message } as any),
170
+ },
171
+ settings: {
172
+ lang: () => lang(),
173
+ maxEntries: () => maxEntries(),
174
+ sortOrder: () => sortOrder(),
175
+ scrollMode: () => scrollMode(),
176
+ },
177
+ }
178
+
179
+ api.slots.register(createSidebarSlot(api, v1Api, signals))
180
+
181
+ // ── slash command: /subagent-lang ──
182
+ api.command?.register(() => [
183
+ {
184
+ title: "SubAgent Magazine: Language",
185
+ value: "subagent-lang",
186
+ description: "Switch display language (中文 / English)",
187
+ slash: { name: "subagent-lang" },
188
+ onSelect: (dialog) => {
189
+ dialog?.replace(() => (
190
+ <api.ui.DialogSelect
191
+ title="Language / 语言"
192
+ options={LANG_META.map((m) => ({ title: m.label, value: m.code }))}
193
+ onSelect={(opt) => {
194
+ const l = opt.value as Lang
195
+ setLang(l)
196
+ api.kv.set(`${KV_PREFIX}.lang`, l)
197
+ api.ui.toast({
198
+ message: "Language: " + (LANG_META.find((m) => m.code === l)?.label ?? l),
199
+ })
200
+ dialog?.clear()
201
+ }}
202
+ />
203
+ ))
204
+ },
205
+ },
206
+ {
207
+ title: "SubAgent Magazine: Sort Order",
208
+ value: "subagent-order",
209
+ description: "Set sub-agent entry sort order (desc / asc)",
210
+ slash: { name: "subagent-order" },
211
+ onSelect: (dialog) => {
212
+ const t = createT(() => lang())
213
+ dialog?.replace(() => (
214
+ <api.ui.DialogSelect
215
+ title="Sort Order / 排序方式"
216
+ options={[
217
+ { title: t("order.desc"), value: "desc" },
218
+ { title: t("order.asc"), value: "asc" },
219
+ ]}
220
+ onSelect={(opt) => {
221
+ const o = opt.value as SortOrder
222
+ setSortOrder(o)
223
+ api.kv.set(`${KV_PREFIX}.order`, o)
224
+ api.ui.toast({
225
+ message: o === "desc" ? t("order.desc") : t("order.asc"),
226
+ })
227
+ dialog?.clear()
228
+ }}
229
+ />
230
+ ))
231
+ },
232
+ },
233
+ {
234
+ title: "SubAgent Magazine: Scroll Mode",
235
+ value: "subagent-scroll",
236
+ description: "Set scroll mode (wheel / click)",
237
+ slash: { name: "subagent-scroll" },
238
+ onSelect: (dialog) => {
239
+ const t = createT(() => lang())
240
+ dialog?.replace(() => (
241
+ <api.ui.DialogSelect
242
+ title="Scroll Mode / 滚动模式"
243
+ options={[
244
+ { title: t("scroll.wheel"), value: "wheel" },
245
+ { title: t("scroll.click"), value: "click" },
246
+ ]}
247
+ onSelect={(opt) => {
248
+ const m = opt.value as ScrollMode
249
+ setScrollMode(m)
250
+ api.kv.set(`${KV_PREFIX}.scroll_mode`, m)
251
+ api.ui.toast({
252
+ message: m === "wheel" ? t("scroll.wheel") : t("scroll.click"),
253
+ })
254
+ dialog?.clear()
255
+ }}
256
+ />
257
+ ))
258
+ },
259
+ },
260
+ {
261
+ title: "SubAgent Magazine: Max Entries",
262
+ value: "subagent-max",
263
+ description: "Set max visible sub-agent entries in sidebar",
264
+ slash: { name: "subagent-max" },
265
+ onSelect: (dialog) => {
266
+ dialog?.replace(() => (
267
+ <api.ui.DialogPrompt
268
+ title="Max Visible Entries"
269
+ description={() => (
270
+ <text>Number of entries to show in the sidebar (1–50)</text>
271
+ )}
272
+ value={String(maxEntries())}
273
+ onConfirm={(val) => {
274
+ const n = Math.max(1, Math.min(50, parseInt(val, 10) || 10))
275
+ setMaxEntries(n)
276
+ api.kv.set(`${KV_PREFIX}.max_entries`, n)
277
+ api.ui.toast({ message: `Max entries: ${n}` })
278
+ dialog?.clear()
279
+ }}
280
+ />
281
+ ))
282
+ },
283
+ },
284
+ {
285
+ title: "SubAgent Magazine: Version",
286
+ value: "subagent-version",
287
+ description: "Show plugin version",
288
+ slash: { name: "subagent-version" },
289
+ onSelect: (dialog) => {
290
+ api.ui.toast({ message: `opencode-subagent-magazine v${PLUGIN_VERSION}` })
291
+ dialog?.clear()
292
+ },
293
+ },
294
+ {
295
+ title: "SubAgent Magazine: Session",
296
+ value: "subagent-session",
297
+ description: "Show current session ID",
298
+ slash: { name: "subagent-session" },
299
+ onSelect: (dialog) => {
300
+ api.ui.toast({ message: `Session: ${signals.sessionId}` })
301
+ dialog?.clear()
302
+ },
303
+ },
304
+ {
305
+ title: "SubAgent Magazine: Clear Running",
306
+ value: "subagent-clear-running",
307
+ description: "Mark all running sub-agent entries as done (for stuck/zombie entries)",
308
+ slash: { name: "subagent-clear-running" },
309
+ onSelect: (dialog) => {
310
+ const sid = signals.sessionId
311
+ const entries = globalEntryCache.get(sid)
312
+ if (!entries || entries.size === 0) {
313
+ const msg = signals.lang() === "zh" ? "暂无子代理条目" : "No sub-agent entries found"
314
+ api.ui.toast({ message: msg })
315
+ dialog?.clear()
316
+ return
317
+ }
318
+ let count = 0
319
+ for (const [, entry] of entries) {
320
+ if (entry.status === "running" || entry.status === "cancel_requested") {
321
+ entry.status = "done" as SubStatus
322
+ entry.endedAt = Date.now()
323
+ count++
324
+ }
325
+ }
326
+ if (count > 0) {
327
+ // 立即写 KV
328
+ try {
329
+ const data = JSON.parse(String(api.kv.get(`${KV_PREFIX}.session_data`, "{}")))
330
+ const sessionObj = api.state.session.get(sid)
331
+ const parentID = (sessionObj as any)?.parentID as string | undefined
332
+ if (parentID) {
333
+ if (!data[parentID]) data[parentID] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
334
+ if (!data[parentID].children) data[parentID].children = {}
335
+ if (!data[parentID].children[sid]) data[parentID].children[sid] = { scroll: 0, expanded: "", entries: [] }
336
+ data[parentID].children[sid] = { ...data[parentID].children[sid], entries: [...entries.values()] }
337
+ } else {
338
+ data[sid] = {
339
+ ts: Date.now(),
340
+ entries: [...entries.values()],
341
+ scroll: data[sid]?.scroll ?? 0,
342
+ expanded: data[sid]?.expanded ?? "",
343
+ children: data[sid]?.children ?? {},
344
+ }
345
+ }
346
+ api.kv.set(`${KV_PREFIX}.session_data`, JSON.stringify(data))
347
+ } catch {}
348
+ const msg = signals.lang() === "zh"
349
+ ? `已标记 ${count} 个运行中的条目为完成`
350
+ : `Marked ${count} running entries as done`
351
+ api.ui.toast({ message: msg })
352
+ } else {
353
+ const msg = signals.lang() === "zh"
354
+ ? "没有需要清理的运行中条目"
355
+ : "No running entries to clear"
356
+ api.ui.toast({ message: msg })
357
+ }
358
+ dialog?.clear()
359
+ },
360
+ },
361
+ {
362
+ title: "SubAgent Magazine: TTL",
363
+ value: "subagent-ttl",
364
+ description: "Set session data retention period (days before auto-cleanup)",
365
+ slash: { name: "subagent-ttl" },
366
+ onSelect: (dialog) => {
367
+ const t = createT(() => signals.lang())
368
+ const curRaw = parseInt(String(api.kv.get(`${KV_PREFIX}.ttl_days`, "3")), 10)
369
+ const curDays = Number.isNaN(curRaw) ? 3 : curRaw
370
+ const curLabel = curDays === 0 ? t("ttl.unlimited") : `${curDays}d`
371
+ dialog?.replace(() => (
372
+ <api.ui.DialogSelect
373
+ title={`${t("ttl.label")} (${curLabel})`}
374
+ options={[
375
+ { title: t("ttl.3d"), value: "3" },
376
+ { title: t("ttl.7d"), value: "7" },
377
+ { title: t("ttl.14d"), value: "14" },
378
+ { title: t("ttl.30d"), value: "30" },
379
+ { title: t("ttl.unlimited"), value: "0" },
380
+ ]}
381
+ onSelect={(opt) => {
382
+ const days = parseInt(opt.value, 10)
383
+ api.kv.set(`${KV_PREFIX}.ttl_days`, String(days))
384
+ const msg = days === 0 ? t("ttl.toast_unlimited") : t("ttl.toast", { n: days })
385
+ api.ui.toast({ message: msg })
386
+ dialog?.clear()
387
+ }}
388
+ />
389
+ ))
390
+ },
391
+ },
392
+ {
393
+ title: "SubAgent Magazine: Clear Entries",
394
+ value: "subagent-clear-entries",
395
+ description: "Delete all sub-agent records for the current session (cannot be undone)",
396
+ slash: { name: "subagent-clear-entries" },
397
+ onSelect: (dialog) => {
398
+ const t = createT(() => signals.lang())
399
+ const sid = signals.sessionId
400
+ const sessionObj = api.state.session.get(sid)
401
+ const parentID = (sessionObj as any)?.parentID as string | undefined
402
+ // 检查是否存在运行中的条目
403
+ const cached = globalEntryCache.get(sid)
404
+ let runningCount = 0
405
+ if (cached) {
406
+ for (const [, e] of cached) { if (e.status === "running") runningCount++ }
407
+ }
408
+ const msg = runningCount > 0
409
+ ? t("clear.prompt_running", { n: runningCount })
410
+ : t("clear.prompt")
411
+ dialog?.replace(() => (
412
+ <api.ui.DialogConfirm
413
+ title={t("clear.title")}
414
+ message={msg}
415
+ onConfirm={() => {
416
+ try {
417
+ const data = JSON.parse(String(api.kv.get(`${KV_PREFIX}.session_data`, "{}")))
418
+ let count = 0
419
+ if (parentID) {
420
+ if (data[parentID]?.children?.[sid]) {
421
+ const child = data[parentID].children[sid]
422
+ const ids = child.entries?.map((e: any) => e.id) ?? []
423
+ count = ids.length
424
+ child.entries = []
425
+ child.scroll = 0
426
+ child.expanded = ""
427
+ child.clearedIds = [...new Set([...(child.clearedIds ?? []), ...ids])]
428
+ }
429
+ } else {
430
+ count = data[sid]?.entries?.length ?? 0
431
+ if (data[sid]) {
432
+ const ids = data[sid].entries?.map((e: any) => e.id) ?? []
433
+ data[sid].entries = []
434
+ data[sid].scroll = 0
435
+ data[sid].expanded = ""
436
+ data[sid].clearedIds = [...new Set([...(data[sid].clearedIds ?? []), ...ids])]
437
+ }
438
+ }
439
+ api.kv.set(`${KV_PREFIX}.session_data`, JSON.stringify(data))
440
+ globalEntryCache.delete(sid)
441
+ setClearTick((v) => v + 1)
442
+ const msg = t("clear.done", { n: count })
443
+ api.ui.toast({ message: msg })
444
+ } catch {}
445
+ dialog?.clear()
446
+ }}
447
+ />
448
+ ))
449
+ },
450
+ },
451
+ {
452
+ title: "SubAgent Magazine: Border",
453
+ value: "subagent-border",
454
+ description: "Show or hide the panel border",
455
+ slash: { name: "subagent-border" },
456
+ onSelect: (dialog) => {
457
+ const t = createT(() => signals.lang())
458
+ const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, false))
459
+ api.kv.set(`${KV_PREFIX}.border`, !cur)
460
+ signals.setBorderVisible(!cur)
461
+ api.ui.toast({ message: !cur ? t("borderShown") : t("borderHidden") })
462
+ dialog?.clear()
463
+ },
464
+ },
465
+ ])
466
+ }
467
+
468
+ const mod: TuiPluginModule & { id: string } = {
469
+ id: "opencode-subagent-magazine",
470
+ tui,
471
+ }
472
+
455
473
  export default mod