opencode-subagent-magazine 1.1.0

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 ADDED
@@ -0,0 +1,1380 @@
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
+ TuiThemeCurrent,
11
+ } from "@opencode-ai/plugin/tui"
12
+ import {
13
+ createMemo,
14
+ createSignal,
15
+ createEffect,
16
+ onMount,
17
+ onCleanup,
18
+ untrack,
19
+ Show,
20
+ For,
21
+ } from "solid-js"
22
+ import { PLUGIN_VERSION } from "./_version"
23
+
24
+ // ===================================================================
25
+ // Types
26
+ // ===================================================================
27
+
28
+ type SubStatus = "running" | "done" | "error"
29
+
30
+ interface SubEntry {
31
+ id: string
32
+ title: string
33
+ agent: string
34
+ prompt: string
35
+ error?: string
36
+ tokens?: number
37
+ cost?: number
38
+ status: SubStatus
39
+ sessionId?: string
40
+ startedAt: number
41
+ endedAt?: number
42
+ model?: string
43
+ todoTotal?: number
44
+ todoDone?: number
45
+ }
46
+
47
+ type Lang = "zh" | "en"
48
+
49
+ /** OpenCode built-in tool names that spawn sub-agents or delegate tasks. */
50
+ const SUBAGENT_TOOLS = new Set(["task", "delegate", "call_omo_agent"])
51
+
52
+ // ===================================================================
53
+ // i18n
54
+ // ===================================================================
55
+
56
+ const I18N: Record<Lang, Record<string, string>> = {
57
+ zh: {
58
+ "panel.title": "子代理",
59
+ "status.none": "暂无子代理",
60
+ "agent.label": "代理",
61
+ "time.label": "耗时",
62
+ "tokens.label": "上下文",
63
+ "error.label": "错误",
64
+ "model.label": "模型",
65
+ "todo.label": "进度",
66
+ "open.label": "进入会话",
67
+ "cost.label": "费用",
68
+ "scroll.more": "更多",
69
+ "scroll.top": "回顶",
70
+ },
71
+ en: {
72
+ "panel.title": "SubAgent",
73
+ "status.none": "No sub-agents yet",
74
+ "agent.label": "agent",
75
+ "time.label": "time",
76
+ "tokens.label": "tokens",
77
+ "error.label": "error",
78
+ "model.label": "model",
79
+ "todo.label": "todo",
80
+ "open.label": "Open session",
81
+ "cost.label": "cost",
82
+ "scroll.more": "more",
83
+ "scroll.top": "Top",
84
+ },
85
+ }
86
+
87
+ declare const process: { env: Record<string, string | undefined> } | undefined
88
+
89
+ function detectLang(): Lang {
90
+ const env = process?.env?.OPENCODE_LANG ?? process?.env?.LANG ?? ""
91
+ if (env.startsWith("zh")) return "zh"
92
+ return "en"
93
+ }
94
+
95
+ // ===================================================================
96
+ // Helpers — visual width
97
+ // ===================================================================
98
+
99
+ function charColumns(c: string): number {
100
+ const code = c.codePointAt(0) ?? 0
101
+ if (code < 0x20) return 0
102
+ if (code < 0x7f) return 1
103
+ if (code < 0xa0) return 0
104
+ if (
105
+ (code >= 0x1100 && code <= 0x115f) ||
106
+ (code >= 0x2e80 && code <= 0xa4cf) ||
107
+ (code >= 0xac00 && code <= 0xd7a3) ||
108
+ (code >= 0xf900 && code <= 0xfaff) ||
109
+ (code >= 0xfe10 && code <= 0xfe6f) ||
110
+ (code >= 0xff01 && code <= 0xff60) ||
111
+ (code >= 0xffe0 && code <= 0xffe6) ||
112
+ (code >= 0x1f300 && code <= 0x1f64f) ||
113
+ (code >= 0x20000 && code <= 0x3fffd)
114
+ )
115
+ return 2
116
+ return 1
117
+ }
118
+
119
+ function visualWidth(s: string): number {
120
+ let w = 0
121
+ for (const c of s) w += charColumns(c)
122
+ return w
123
+ }
124
+
125
+ function truncate(text: string, maxCols: number): string {
126
+ if (visualWidth(text) <= maxCols) return text
127
+ let cols = 0
128
+ let i = 0
129
+ for (const c of text) {
130
+ const w = charColumns(c)
131
+ if (cols + w > maxCols - 1) break
132
+ cols += w
133
+ i += c.length
134
+ }
135
+ return text.slice(0, i) + "\u2026"
136
+ }
137
+
138
+ function fmtDurationShort(ms: number, running: boolean): string {
139
+ if (running && ms < 2000) return ""
140
+ if (ms < 1000) return (ms / 1000).toFixed(2) + "s"
141
+ if (ms < 60000) return (ms / 1000).toFixed(2) + "s"
142
+ const m = Math.floor(ms / 60000)
143
+ const s = Math.round((ms % 60000) / 1000)
144
+ return `${m}m${s}s`
145
+ }
146
+
147
+ function fmtTokens(n: number): string {
148
+ if (n < 1000) return `${n}`
149
+ if (n < 1000000) return `${(n / 1000).toFixed(1)}k`
150
+ return `${(n / 1000000).toFixed(1)}M`
151
+ }
152
+
153
+ // ===================================================================
154
+ // Color helpers — Morandi palette
155
+ // ===================================================================
156
+
157
+ function rgb(raw: unknown): { r: number; g: number; b: number } | null {
158
+ if (typeof raw === "string" && raw.startsWith("#")) {
159
+ const h = raw.slice(1)
160
+ return {
161
+ r: parseInt(h.slice(0, 2), 16),
162
+ g: parseInt(h.slice(2, 4), 16),
163
+ b: parseInt(h.slice(4, 6), 16),
164
+ }
165
+ }
166
+ if (raw && typeof raw === "object") {
167
+ const o = raw as Record<string, unknown>
168
+ if (typeof o.r === "number" && typeof o.g === "number" && typeof o.b === "number") {
169
+ const scale = o.r > 1 || o.g > 1 || o.b > 1 ? 1 : 255
170
+ return { r: Math.round(o.r * scale), g: Math.round(o.g * scale), b: Math.round(o.b * scale) }
171
+ }
172
+ }
173
+ return null
174
+ }
175
+
176
+ function saturation(r: number, g: number, b: number): number {
177
+ const max = Math.max(r, g, b) / 255
178
+ const min = Math.min(r, g, b) / 255
179
+ const delta = max - min
180
+ if (delta === 0) return 0
181
+ const L = (max + min) / 2
182
+ return L <= 0.5 ? delta / (max + min) : delta / (2 - max - min)
183
+ }
184
+
185
+ function desaturateTo(raw: unknown, maxSat: number, fallback: string): string {
186
+ const c = rgb(raw)
187
+ if (!c) return fallback
188
+ const sat = saturation(c.r, c.g, c.b)
189
+ if (sat <= maxSat) {
190
+ return "#" + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")
191
+ }
192
+ const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114
193
+ let lo = 0, hi = 1
194
+ for (let i = 0; i < 12; i++) {
195
+ const mid = (lo + hi) / 2
196
+ const nr = Math.round(c.r + (luma - c.r) * mid)
197
+ const ng = Math.round(c.g + (luma - c.g) * mid)
198
+ const nb = Math.round(c.b + (luma - c.b) * mid)
199
+ if (saturation(nr, ng, nb) > maxSat) lo = mid
200
+ else hi = mid
201
+ }
202
+ const nr = Math.round(c.r + (luma - c.r) * hi)
203
+ const ng = Math.round(c.g + (luma - c.g) * hi)
204
+ const nb = Math.round(c.b + (luma - c.b) * hi)
205
+ return "#" + [nr, ng, nb].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
206
+ }
207
+
208
+ function dimColor(hex: string, factor = 0.5): string {
209
+ const c = rgb(hex)
210
+ if (!c) return hex
211
+ const r = Math.round(c.r * factor)
212
+ const g = Math.round(c.g * factor)
213
+ const b = Math.round(c.b * factor)
214
+ return "#" + [r, g, b].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
215
+ }
216
+
217
+ const FALLBACK = {
218
+ primary: "#8B9DAF", text: "#C5C5BB", muted: "#7A7A72",
219
+ success: "#9CAF8B", warning: "#C5B88D", error: "#B08A8A", border: "#6B6B63",
220
+ } as const
221
+
222
+ const MAX_SAT = 0.28
223
+
224
+ /** Entry line left prefix: icon + space + status dot + space */
225
+ const LEFT_PAD = 4
226
+ /** Detail row indent: two spaces */
227
+ const INDENT = 2
228
+
229
+ function safeErrorMsg(err: unknown): string {
230
+ if (!err) return ""
231
+ if (typeof err === "string") return err
232
+ if (typeof err === "object") return String((err as any).message || (err as any).code || "")
233
+ return ""
234
+ }
235
+
236
+ // ===================================================================
237
+ // Sidebar component
238
+ // ===================================================================
239
+
240
+ function SubAgentPanel(props: {
241
+ theme: TuiThemeCurrent
242
+ api: TuiPluginApi
243
+ lang: () => Lang
244
+ maxEntries: () => number
245
+ sessionId: string
246
+ }): JSX.Element {
247
+ const t = (key: string) => I18N[props.lang()][key] ?? key
248
+
249
+ // ── session data (single-key, true deletion on cleanup) ──
250
+ const SESSION_DATA_KEY = `${KV_PREFIX}.session_data`
251
+ const TTL_MS = 3 * 24 * 60 * 60 * 1000
252
+
253
+ interface SessionRecord {
254
+ ts: number
255
+ entries: SubEntry[]
256
+ scroll: number
257
+ expanded: string
258
+ }
259
+
260
+ const loadSessionData = (): Record<string, SessionRecord> => {
261
+ try {
262
+ const raw = props.api.kv.get(SESSION_DATA_KEY, "{}")
263
+ return JSON.parse(String(raw))
264
+ } catch { return {} }
265
+ }
266
+
267
+ const saveSessionData = (data: Record<string, SessionRecord>) => {
268
+ try { props.api.kv.set(SESSION_DATA_KEY, JSON.stringify(data)) } catch {}
269
+ }
270
+
271
+ const loadEntries = (sid: string): Map<string, SubEntry> => {
272
+ const m = new Map<string, SubEntry>()
273
+ try {
274
+ const rec = loadSessionData()[sid]
275
+ if (rec?.entries) {
276
+ for (const e of rec.entries) m.set(e.id, e)
277
+ }
278
+ } catch {}
279
+ return m
280
+ }
281
+
282
+ let persistTimer: ReturnType<typeof setTimeout> | undefined
283
+ const persistEntries = (sid: string, entries: Map<string, SubEntry>) => {
284
+ clearTimeout(persistTimer)
285
+ persistTimer = setTimeout(() => {
286
+ try {
287
+ const data = loadSessionData()
288
+ data[sid] = { ...data[sid], ts: Date.now(), entries: [...entries.values()] }
289
+ saveSessionData(data)
290
+ } catch {}
291
+ }, 200)
292
+ }
293
+
294
+ const persistScroll = (sid: string, scroll: number) => {
295
+ try {
296
+ const data = loadSessionData()
297
+ data[sid] = { ...data[sid], ts: Date.now(), scroll }
298
+ saveSessionData(data)
299
+ } catch {}
300
+ }
301
+
302
+ const persistExpanded = (sid: string, expanded: string) => {
303
+ try {
304
+ const data = loadSessionData()
305
+ data[sid] = { ...data[sid], ts: Date.now(), expanded }
306
+ saveSessionData(data)
307
+ } catch {}
308
+ }
309
+
310
+ const cleanupOldSessions = () => {
311
+ try {
312
+ const data = loadSessionData()
313
+ const cutoff = Date.now() - TTL_MS
314
+ let changed = false
315
+ for (const sid of Object.keys(data)) {
316
+ if (data[sid].ts < cutoff) {
317
+ delete data[sid]
318
+ changed = true
319
+ }
320
+ }
321
+ if (changed) saveSessionData(data)
322
+ } catch {}
323
+ }
324
+
325
+ cleanupOldSessions()
326
+
327
+ const [entryMap, setEntryMapRaw] = createSignal(loadEntries(props.sessionId))
328
+
329
+ // Wrapped setter — also persists to kv on every mutation
330
+ const setEntryMap = (
331
+ arg: Map<string, SubEntry> | ((prev: Map<string, SubEntry>) => Map<string, SubEntry>),
332
+ ) => {
333
+ setEntryMapRaw((prev) => {
334
+ const next = typeof arg === "function" ? (arg as Function)(prev) : arg
335
+ persistEntries(props.sessionId, next)
336
+ return next
337
+ })
338
+ }
339
+
340
+ const [panelWidth, setPanelWidth] = createSignal(28)
341
+ const [open, setOpen] = createSignal(
342
+ (() => { try { return props.api.kv.get(`${KV_PREFIX}.open`, true) as boolean } catch { return true } })()
343
+ )
344
+ const [expanded, setExpanded] = createSignal<string | undefined>(
345
+ (() => { try { return loadSessionData()[props.sessionId]?.expanded || undefined } catch { return undefined } })()
346
+ )
347
+ const [hoveredOpen, setHoveredOpen] = createSignal<string | undefined>(undefined)
348
+ const [hoveredTop, setHoveredTop] = createSignal(false)
349
+ const [scrollOffset, setScrollOffset] = createSignal(
350
+ (() => { try { return loadSessionData()[props.sessionId]?.scroll ?? 0 } catch { return 0 } })()
351
+ )
352
+ const [now, setNow] = createSignal(Date.now())
353
+ const [renderTick, setRenderTick] = createSignal(0)
354
+
355
+ let boxEl: any
356
+ let disposed = false
357
+
358
+ /** Total context tokens for a sub-agent session.
359
+ * Matches opencode-visual-cache's "总计": last assistant message's input + cache.read. */
360
+ const readSessionTokens = (sid: string): number | undefined => {
361
+ if (!sid) return undefined
362
+ try {
363
+ const msgs = props.api.state.session.messages(sid)
364
+ if (msgs) {
365
+ for (let i = (msgs as any[]).length - 1; i >= 0; i--) {
366
+ const m = (msgs as any[])[i]
367
+ if (m.role !== "assistant") continue
368
+ const t = m.tokens
369
+ if (!t) continue
370
+ const cache = t.cache as { read?: number; write?: number } | undefined
371
+ const ctx = (Number(t.input) || 0) + (cache?.read ?? 0)
372
+ if (ctx > 0) return ctx
373
+ }
374
+ }
375
+ return undefined
376
+ } catch {
377
+ return undefined
378
+ }
379
+ }
380
+
381
+ /** Sum USD cost from a session's messages.
382
+ * Prefers the database-level aggregate (`session.cost`) which is not affected
383
+ * by the sync layer's `limit: 100` message window. Falls back to message
384
+ * traversal when the aggregate is unavailable (older SDK versions). */
385
+ const readSessionCost = (sid: string): number | undefined => {
386
+ if (!sid) return undefined
387
+ try {
388
+ const session = props.api.state.session.get(sid)
389
+ if (session?.cost != null && session.cost > 0) return session.cost
390
+ const msgs = props.api.state.session.messages(sid)
391
+ if (!msgs) return undefined
392
+ let total = 0
393
+ for (const m of msgs as any[]) {
394
+ if (m.role === "assistant" && typeof m.cost === "number") total += m.cost
395
+ }
396
+ return total > 0 ? total : undefined
397
+ } catch {
398
+ return undefined
399
+ }
400
+ }
401
+
402
+ /** Last assistant message's modelID for a sub-agent session. */
403
+ const readSessionModel = (sid: string): string | undefined => {
404
+ if (!sid) return undefined
405
+ try {
406
+ const msgs = props.api.state.session.messages(sid)
407
+ if (msgs) {
408
+ for (let i = (msgs as any[]).length - 1; i >= 0; i--) {
409
+ const m = (msgs as any[])[i]
410
+ if (m.role === "assistant" && m.modelID) return String(m.modelID)
411
+ }
412
+ }
413
+ return undefined
414
+ } catch {
415
+ return undefined
416
+ }
417
+ }
418
+
419
+ /** Todo completion stats for a sub-agent session.
420
+ * `done` counts completed + cancelled items. */
421
+ const readSessionTodo = (sid: string): { total: number; done: number } | undefined => {
422
+ if (!sid) return undefined
423
+ try {
424
+ const todos = props.api.state.session.todo(sid)
425
+ if (!todos || todos.length === 0) return undefined
426
+ let done = 0
427
+ for (const t of todos) {
428
+ if (t.status === "completed" || t.status === "cancelled") done++
429
+ }
430
+ return { total: todos.length, done }
431
+ } catch {
432
+ return undefined
433
+ }
434
+ }
435
+
436
+ // ── upsert ──
437
+ const upsertEntry = (
438
+ partial: Omit<SubEntry, "startedAt" | "endedAt"> & { startedAt?: number }
439
+ ) => {
440
+ setEntryMap((prev) => {
441
+ const existing = prev.get(partial.id)
442
+ const next = new Map(prev)
443
+ const nowTs = Date.now()
444
+ const e = partial.status
445
+ const ended = e === "done" || e === "error"
446
+ next.set(partial.id, {
447
+ ...(existing ?? { startedAt: nowTs }),
448
+ ...partial,
449
+ startedAt: existing?.startedAt || partial.startedAt || nowTs,
450
+ endedAt: ended ? (existing?.endedAt || nowTs) : undefined,
451
+ })
452
+ return next
453
+ })
454
+ }
455
+
456
+ // ── event handlers ──
457
+ const handlePartUpdated = (event: unknown) => {
458
+ const e = event as Record<string, unknown>
459
+ const props_ = e.properties as Record<string, unknown> | undefined
460
+ const part = props_?.part as Record<string, unknown> | undefined
461
+ if (!part) return
462
+
463
+ // SubtaskPart
464
+ if (part.type === "subtask") {
465
+ const agent = String(part.agent ?? "?")
466
+ const prompt = String(part.prompt ?? "")
467
+ const desc = String(part.description ?? "")
468
+ const title = desc || truncate(prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim(), 40)
469
+
470
+ const id = `sub:${String(part.id ?? crypto.randomUUID())}`
471
+ const subSid = part.sessionID !== undefined ? String(part.sessionID) : undefined
472
+ const partModel = part.model as { modelID?: string } | undefined
473
+ const modelId = partModel?.modelID ? String(partModel.modelID) : undefined
474
+ upsertEntry({ id, title, agent, prompt, sessionId: subSid, status: "running", model: modelId })
475
+ }
476
+
477
+ // ToolPart
478
+ if (part.type === "tool") {
479
+ const tool = String(part.tool ?? "")
480
+ if (!SUBAGENT_TOOLS.has(tool)) return
481
+ const st = part.state as Record<string, unknown> | undefined
482
+ const rawStatus = String(st?.status ?? "")
483
+
484
+ // Only create entries for tool calls that actually entered execution.
485
+ // "pending" / empty → state unknown yet, wait for next event
486
+ if (rawStatus === "pending" || rawStatus === "") return
487
+
488
+ // "error" → tool call failed, sub-agent never spawned.
489
+ // Only update an existing entry (e.g. previously running → now error),
490
+ // never create a new one.
491
+ if (rawStatus === "error") {
492
+ const id = `tool:${String(part.id ?? "")}`
493
+ if (!part.id) return
494
+ const existing = entryMap().get(id)
495
+ if (existing) {
496
+ upsertEntry({ id, title: existing.title, agent: existing.agent, prompt: existing.prompt, status: "error" })
497
+ }
498
+ return
499
+ }
500
+
501
+ // rawStatus is "running" or "completed" — tool entered execution, track it.
502
+ const input = st?.input as Record<string, unknown> | undefined
503
+ let status: SubStatus = "running"
504
+ if (rawStatus === "completed") status = "done"
505
+ // Background tasks: tool completion ≠ agent completion — keep running until session.idle
506
+ // Only keep running if state metadata confirms a child session was spawned;
507
+ // otherwise (failed spawn, invalid agent) mark as done so the entry isn't stuck forever.
508
+ if (input?.run_in_background === true && status === "done") {
509
+ const stMetaCheck = st?.metadata as Record<string, unknown> | undefined
510
+ const hasChild = stMetaCheck?.session_id !== undefined || stMetaCheck?.sessionId !== undefined
511
+ if (hasChild) status = "running"
512
+ }
513
+
514
+ const agent = String((part as any).subagent_type ?? input?.subagent_type ?? input?.category ?? tool)
515
+ const prompt = String(input?.prompt ?? (part as any).description ?? "")
516
+ const desc = input?.description !== undefined ? String(input.description) : ""
517
+ const title = desc || truncate(prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim(), 40)
518
+
519
+ const id = `tool:${String(part.id ?? crypto.randomUUID())}`
520
+ // Child session ID lives in state-level metadata (ToolStateCompleted.metadata),
521
+ // injected by the tool executor. ToolPart.sessionID is the parent session.
522
+ const stMeta = st?.metadata as Record<string, unknown> | undefined
523
+ const subSid = stMeta?.session_id !== undefined ? String(stMeta.session_id)
524
+ : stMeta?.sessionId !== undefined ? String(stMeta.sessionId)
525
+ : undefined
526
+ upsertEntry({ id, title, agent, prompt, sessionId: subSid, status })
527
+ }
528
+ }
529
+
530
+ const handleSessionEnd = (event: unknown, status: SubStatus) => {
531
+ const e = event as Record<string, unknown>
532
+ const props_ = e.properties as Record<string, unknown> | undefined
533
+ const sid = String(props_?.sessionID ?? "")
534
+ if (!sid) return
535
+
536
+ const sessionTokens = readSessionTokens(sid)
537
+ const sessionCost = readSessionCost(sid)
538
+ const sessionModel = readSessionModel(sid)
539
+ const sessionTodo = readSessionTodo(sid)
540
+ let sessionAgent: string | undefined
541
+ let errorMsg: string | undefined
542
+ try {
543
+ const s = props.api.state.session.get(sid)
544
+ sessionAgent = s?.agent
545
+ if (status === "error") {
546
+ const evtErr = props_?.error as Record<string, unknown> | undefined
547
+ errorMsg = safeErrorMsg(evtErr) || safeErrorMsg(props_?.message)
548
+ if (!errorMsg) {
549
+ const msgs = props.api.state.session.messages(sid)
550
+ if (msgs) {
551
+ for (let i = (msgs as any[]).length - 1; i >= 0; i--) {
552
+ const m = (msgs as any[])[i]
553
+ if (m.role === "assistant" && m.error) {
554
+ errorMsg = safeErrorMsg(m.error)
555
+ break
556
+ }
557
+ }
558
+ }
559
+ }
560
+ }
561
+ } catch {}
562
+
563
+ setEntryMap((prev) => {
564
+ let changed = false
565
+ const next = new Map(prev)
566
+ for (const [id, entry] of next) {
567
+ if (entry.sessionId !== sid) continue
568
+ if (entry.status !== "running" && entry.status !== "done") continue
569
+ // Skip parent session idle — subagent entries belong to child sessions only
570
+ if (sid === props.sessionId) continue
571
+ // For "done" entries (sync tasks completed before session.idle), only backfill tokens/cost
572
+ const alreadySettled = entry.status !== "running"
573
+ next.set(id, {
574
+ ...entry,
575
+ ...(alreadySettled ? {} : { status, endedAt: Date.now() }),
576
+ tokens: entry.tokens ?? sessionTokens,
577
+ cost: entry.cost ?? sessionCost,
578
+ model: entry.model ?? sessionModel,
579
+ todoTotal: entry.todoTotal ?? sessionTodo?.total,
580
+ todoDone: entry.todoDone ?? sessionTodo?.done,
581
+ error: errorMsg || entry.error,
582
+ })
583
+ changed = true
584
+ }
585
+ if (!changed && sessionAgent) {
586
+ const nowTs = Date.now()
587
+ const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]/g, "")
588
+ const saNorm = normalize(sessionAgent)
589
+ let best: { id: string; gap: number } | null = null
590
+
591
+ // Phase 1: try matching by agent name(agent 名有交集)
592
+ for (const [id, entry] of next) {
593
+ if (entry.status !== "running") continue
594
+ const eaNorm = normalize(entry.agent)
595
+ if (!eaNorm || !saNorm) continue
596
+ if (!eaNorm.includes(saNorm) && !saNorm.includes(eaNorm)) continue
597
+ const gap = nowTs - (entry.startedAt || 0)
598
+ if (!best || gap > best.gap) best = { id, gap }
599
+ }
600
+
601
+ // Phase 2: if agent name has no overlap (e.g. category calls: agent="deep" vs sessionAgent="Sisyphus-Junior"),
602
+ // fall back to time proximity for entries that have no sessionId yet
603
+ if (!best) {
604
+ for (const [id, entry] of next) {
605
+ if (entry.status !== "running") continue
606
+ if (entry.sessionId) continue
607
+ const gap = nowTs - (entry.startedAt || 0)
608
+ if (!best || gap > best.gap) best = { id, gap }
609
+ }
610
+ }
611
+
612
+ if (best) {
613
+ const entry = next.get(best.id)!
614
+ next.set(best.id, {
615
+ ...entry, status, endedAt: nowTs,
616
+ tokens: sessionTokens || entry.tokens,
617
+ cost: sessionCost || entry.cost,
618
+ sessionId: sid,
619
+ error: errorMsg || entry.error,
620
+ })
621
+ changed = true
622
+ }
623
+ }
624
+ return changed ? next : prev
625
+ })
626
+
627
+ // Delayed backfill: re-read data after state sync catches up, to capture the final
628
+ // token/cost values that may not have been available when session.idle fired.
629
+ setTimeout(() => {
630
+ if (disposed) return
631
+ const finalTokens = readSessionTokens(sid)
632
+ const finalCost = readSessionCost(sid)
633
+ const finalModel = readSessionModel(sid)
634
+ const finalTodo = readSessionTodo(sid)
635
+ setEntryMap((prev) => {
636
+ let changed = false
637
+ const next = new Map(prev)
638
+ for (const [id, entry] of next) {
639
+ if (entry.sessionId !== sid) continue
640
+ const t = finalTokens ?? entry.tokens
641
+ const c = finalCost ?? entry.cost
642
+ const m = finalModel ?? entry.model
643
+ const tt = finalTodo?.total ?? entry.todoTotal
644
+ const td = finalTodo?.done ?? entry.todoDone
645
+ if (t !== entry.tokens || c !== entry.cost || m !== entry.model ||
646
+ tt !== entry.todoTotal || td !== entry.todoDone) {
647
+ next.set(id, { ...entry, tokens: t, cost: c, model: m, todoTotal: tt, todoDone: td })
648
+ changed = true
649
+ }
650
+ }
651
+ return changed ? next : prev
652
+ })
653
+ bump()
654
+ }, 150)
655
+ }
656
+
657
+ // ── bumpRenderTick: force re-render (visual-cache pattern) ──
658
+ const bump = () => setRenderTick((v) => v + 1)
659
+
660
+ onMount(() => {
661
+ // Fast clock for smooth time display, separate from token polling
662
+ const clock = setInterval(() => { setNow(Date.now()); bump() }, 100)
663
+ // Token poll — runs every 500ms for running entries
664
+ const tokenTimer = setInterval(() => {
665
+ untrack(() => {
666
+ setEntryMapRaw((prev) => {
667
+ let changed = false
668
+ const next = new Map(prev)
669
+ for (const [id, entry] of next) {
670
+ if (entry.status === "running" && entry.sessionId) {
671
+ // Only read from child sessions, never the parent
672
+ let isChild = false
673
+ try {
674
+ const s = props.api.state.session.get(entry.sessionId)
675
+ isChild = s?.parentID === props.sessionId
676
+ } catch {}
677
+ if (!isChild) continue
678
+ const total = readSessionTokens(entry.sessionId)
679
+ const todo = readSessionTodo(entry.sessionId)
680
+ const model = entry.model ?? readSessionModel(entry.sessionId)
681
+ const nextEntry: SubEntry = { ...entry }
682
+ if (total !== undefined && total !== entry.tokens) { nextEntry.tokens = total; changed = true }
683
+ if (todo !== undefined) {
684
+ if (todo.total !== entry.todoTotal || todo.done !== entry.todoDone) {
685
+ nextEntry.todoTotal = todo.total; nextEntry.todoDone = todo.done; changed = true
686
+ }
687
+ }
688
+ if (model && !entry.model) { nextEntry.model = model; changed = true }
689
+ if (changed) next.set(id, nextEntry)
690
+ }
691
+ }
692
+ return changed ? next : prev
693
+ })
694
+ })
695
+ bump()
696
+ }, 500)
697
+ bump()
698
+
699
+ const unsubPart = props.api.event.on("message.part.updated", (e) => {
700
+ handlePartUpdated(e)
701
+ bump()
702
+ })
703
+ const unsubMsg = props.api.event.on("message.updated", () => bump())
704
+ const unsubIdle = props.api.event.on("session.idle", (e) => {
705
+ handleSessionEnd(e, "done")
706
+ bump()
707
+ })
708
+ const unsubError = props.api.event.on("session.error", (e) => {
709
+ handleSessionEnd(e, "error")
710
+ bump()
711
+ })
712
+
713
+ onCleanup(() => {
714
+ disposed = true
715
+ clearInterval(clock)
716
+ clearInterval(tokenTimer)
717
+ unsubPart()
718
+ unsubMsg()
719
+ unsubIdle()
720
+ unsubError()
721
+ })
722
+ })
723
+
724
+ // ── session‑switch & initial‑load scan ──
725
+ // On session change: load from kv (entries survive component unmount), then scan+merge.
726
+ // On same session: only scan+merge (keep event‑driven running entries).
727
+ let lastSid = props.sessionId
728
+ createEffect(() => {
729
+ const sid = props.sessionId
730
+ const switched = sid !== lastSid
731
+ lastSid = sid
732
+ const t = setTimeout(() => {
733
+ untrack(() => {
734
+ if (switched) {
735
+ const saved = loadSessionData()[sid]?.scroll ?? 0
736
+ setScrollOffset(saved)
737
+ }
738
+ // scan uses setEntryMapRaw — ephemeral data, not persisted to kv.
739
+ // Only event-driven changes (handlePartUpdated, handleSessionEnd) persist.
740
+ setEntryMapRaw((prev) => {
741
+ const next = switched ? loadEntries(sid) : new Map(prev)
742
+ try {
743
+ const msgs = props.api.state.session.messages(sid)
744
+ if (msgs && (msgs as any[]).length) {
745
+ for (const msg of msgs) {
746
+ const parts = props.api.state.part(msg.id) ?? []
747
+ for (const partRaw of parts) {
748
+ const part = partRaw as Record<string, unknown>
749
+
750
+ // Subtask entries are purely event-driven — never created by scan.
751
+ // (SubtaskPart exists from spawn, not completion, so we cannot infer status.)
752
+ if (part.type === "tool") {
753
+ const tool = String((part as any).tool ?? "")
754
+ if (!SUBAGENT_TOOLS.has(tool)) continue
755
+ const id = `tool:${String(part.id ?? "")}`
756
+ if (!part.id) continue
757
+
758
+ const st = (part as any).state as Record<string, unknown> | undefined
759
+ const rawStatus = String(st?.status ?? "")
760
+ const exists = next.get(id)
761
+
762
+ // Only create entries for tool calls that entered execution.
763
+ // "pending" / empty: skip new entries; allow heuristics for existing ones below.
764
+ if ((rawStatus === "pending" || rawStatus === "") && !exists) continue
765
+
766
+ // "error": only update existing, never create a new entry
767
+ if (rawStatus === "error") {
768
+ if (exists && exists.status === "running") {
769
+ next.set(id, { ...exists, status: "error", endedAt: Date.now() })
770
+ }
771
+ continue
772
+ }
773
+
774
+ let status: SubStatus = "running"
775
+ if (rawStatus === "completed") status = "done"
776
+ // Background tasks: tool completion ≠ agent completion — keep running until session.idle
777
+ // Only keep running if state metadata confirms a child session was spawned.
778
+ if ((st?.input as Record<string, unknown> | undefined)?.run_in_background === true && status === "done") {
779
+ const scanStMeta = st?.metadata as Record<string, unknown> | undefined
780
+ const scanHasChild = scanStMeta?.session_id !== undefined || scanStMeta?.sessionId !== undefined
781
+ if (scanHasChild) status = "running"
782
+ }
783
+
784
+ // Already settled → skip
785
+ if (exists && exists.status !== "running") continue
786
+ // Running entry with no explicit status improvement from part:
787
+ // try message-level heuristics first, then time-based fallback.
788
+ if (exists && status === "running") {
789
+ if (!rawStatus) {
790
+ const msgTokens = (msg as any)?.tokens as Record<string, unknown> | undefined
791
+ if (msgTokens && (Number(msgTokens.input) > 0 || Number(msgTokens.output) > 0)) {
792
+ status = "done" // LLM returned tokens → agent completed
793
+ } else if (Date.now() - exists.startedAt > 30 * 60 * 1000) {
794
+ status = "done" // >30 min idle → assume completed
795
+ } else {
796
+ continue
797
+ }
798
+ } else {
799
+ continue
800
+ }
801
+ }
802
+
803
+ // If already tracked as running but tool state says completed/error → update
804
+ // If not tracked → add fresh
805
+
806
+ const input = st?.input as Record<string, unknown> | undefined
807
+ const agent = String((part as any).subagent_type ?? input?.subagent_type ?? tool)
808
+ const prompt = String(input?.prompt ?? (part as any).description ?? "")
809
+ const desc = input?.description !== undefined ? String(input.description) : ""
810
+ const title = desc || truncate(prompt.replace(/\n/g, " ").trim(), 40)
811
+
812
+ let tokens: number | undefined
813
+ const scanStMeta2 = st?.metadata as Record<string, unknown> | undefined
814
+ const scanSubSid = scanStMeta2?.session_id !== undefined ? String(scanStMeta2.session_id)
815
+ : scanStMeta2?.sessionId !== undefined ? String(scanStMeta2.sessionId)
816
+ : undefined
817
+ if (scanSubSid) tokens = readSessionTokens(scanSubSid)
818
+
819
+ const ended = status === "done" // "error" handled above, never reaches here
820
+ next.set(id, {
821
+ id, title, agent, prompt,
822
+ // Preserve existing values (from handleSessionEnd / KV) — scan must not overwrite
823
+ tokens: exists?.tokens ?? tokens,
824
+ sessionId: exists?.sessionId ?? scanSubSid,
825
+ status,
826
+ startedAt: exists?.startedAt || Date.now(),
827
+ endedAt: ended ? (exists?.endedAt || Date.now()) : undefined,
828
+ })
829
+ }
830
+ }
831
+ }
832
+ }
833
+ } catch {}
834
+ return next
835
+ })
836
+ // Reconcile: check running entries against live child session status.
837
+ // Covers session.idle events missed while user was inside a child session.
838
+ setEntryMapRaw((prev) => {
839
+ let changed = false
840
+ const next = new Map(prev)
841
+ for (const [id, entry] of next) {
842
+ if (entry.status !== "running" || !entry.sessionId) continue
843
+ try {
844
+ const st = props.api.state.session.status(entry.sessionId)
845
+ if (!st || st.type !== "idle") continue
846
+ const tokens = readSessionTokens(entry.sessionId)
847
+ const cost = readSessionCost(entry.sessionId)
848
+ next.set(id, {
849
+ ...entry, status: "done" as SubStatus, endedAt: Date.now(),
850
+ tokens: tokens ?? entry.tokens,
851
+ cost: cost ?? entry.cost,
852
+ })
853
+ changed = true
854
+ } catch {}
855
+ }
856
+ return changed ? next : prev
857
+ })
858
+ bump()
859
+ })
860
+ }, 150)
861
+ onCleanup(() => clearTimeout(t))
862
+ })
863
+
864
+ // ── palette ──
865
+ const pal = createMemo(() => {
866
+ const th = props.theme as Record<string, unknown>
867
+ const sat = (k: string, fb: string) => desaturateTo(th[k], MAX_SAT, fb)
868
+ return {
869
+ primary: sat("primary", FALLBACK.primary),
870
+ text: sat("text", FALLBACK.text),
871
+ muted: sat("textMuted", FALLBACK.muted),
872
+ success: sat("success", FALLBACK.success),
873
+ warning: sat("warning", FALLBACK.warning),
874
+ error: sat("error", FALLBACK.error),
875
+ border: sat("border", FALLBACK.border),
876
+ }
877
+ })
878
+
879
+ // ── derived signals ──
880
+ // Stable list — only changes when entryMap changes
881
+ const entryList = createMemo(() => {
882
+ return [...entryMap().values()].sort((a, b) => b.startedAt - a.startedAt)
883
+ })
884
+
885
+ const max = props.maxEntries
886
+ const clampedOffset = createMemo(() => {
887
+ const total = entryList().length
888
+ const m = max()
889
+ if (total <= m) return 0
890
+ return Math.min(scrollOffset(), total - m)
891
+ })
892
+ const visibleList = createMemo(() => entryList().slice(clampedOffset(), clampedOffset() + max()))
893
+ const hiddenAbove = createMemo(() => clampedOffset())
894
+ const hiddenBelow = createMemo(() => Math.max(0, entryList().length - clampedOffset() - max()))
895
+
896
+ const entries = createMemo(() => {
897
+ const nowVal = now()
898
+ return entryList().map((e) => ({
899
+ ...e,
900
+ elapsed: (e.endedAt ?? nowVal) - e.startedAt,
901
+ }))
902
+ })
903
+
904
+ const doneCount = createMemo(() => entryList().filter((e) => e.status === "done").length)
905
+ const runningCount = createMemo(() => entryList().filter((e) => e.status === "running").length)
906
+ const errCount = createMemo(() => entryList().filter((e) => e.status === "error").length)
907
+ const anyEntry = () => entryList().length > 0
908
+
909
+ const totalTokens = createMemo(() => {
910
+ let sum = 0
911
+ for (const e of entryList()) { if (e.tokens) sum += e.tokens }
912
+ return sum
913
+ })
914
+
915
+ const totalCost = createMemo(() => {
916
+ let sum = 0
917
+ for (const e of entryList()) { if (e.cost) sum += e.cost }
918
+ return sum
919
+ })
920
+
921
+ const toggleExpand = (id: string) => {
922
+ setExpanded((prev) => {
923
+ const next = prev === id ? undefined : id
924
+ try { persistExpanded(props.sessionId, next ?? "") } catch {}
925
+ return next
926
+ })
927
+ }
928
+
929
+ const sep = () => "\u2500".repeat(Math.max(1, panelWidth()))
930
+
931
+ // ── expanded detail right-align ──
932
+ const expandedMaxLabelW = createMemo(() => {
933
+ const labels = [
934
+ t("agent.label"), t("time.label"), t("tokens.label"),
935
+ t("error.label"), t("cost.label"), t("model.label"), t("todo.label"),
936
+ ]
937
+ return Math.max(...labels.map(l => visualWidth(l + ": ")))
938
+ })
939
+
940
+ const expandedPad = (label: string) => Math.max(0, expandedMaxLabelW() - visualWidth(label + ": "))
941
+
942
+ const expandedValAvail = () => Math.max(6, panelWidth() - INDENT - expandedMaxLabelW())
943
+
944
+ // ── header parts for colored spans ──
945
+ const summaryParts = createMemo(() => {
946
+ if (!anyEntry()) return null
947
+ const dot = "\u25cf"
948
+ const cost = totalCost()
949
+ return {
950
+ done: `${dot}${doneCount()}`,
951
+ running: runningCount() > 0 ? `${dot}${runningCount()}` : null,
952
+ err: errCount() > 0 ? `${dot}${errCount()}` : null,
953
+ duration: totalTokens() > 0 ? fmtTokens(totalTokens()) : "",
954
+ cost: cost > 0 ? `$${cost.toFixed(2)}` : "",
955
+ }
956
+ })
957
+
958
+ const summaryCols = createMemo(() => {
959
+ const p = summaryParts()
960
+ if (!p) return 0
961
+ let w = visualWidth(p.done)
962
+ if (p.running) w += 1 + visualWidth(p.running)
963
+ if (p.err) w += 1 + visualWidth(p.err)
964
+ w += p.duration ? 1 + visualWidth(p.duration) : 0
965
+ w += p.cost ? 1 + visualWidth(p.cost) : 0
966
+ return w
967
+ })
968
+
969
+ const versionText = ` v${PLUGIN_VERSION}`
970
+ const versionW = visualWidth(versionText)
971
+
972
+ const showVersion = createMemo(() => {
973
+ if (!open()) return false
974
+ const icon = "\u25bc"
975
+ const need = visualWidth(icon) + 1 + visualWidth(t("panel.title")) + versionW + summaryCols()
976
+ return need <= panelWidth()
977
+ })
978
+
979
+ const leftCols = createMemo(() => {
980
+ const icon = open() ? "\u25bc" : "\u25b6"
981
+ let w = visualWidth(icon) + 1 + visualWidth(t("panel.title"))
982
+ if (showVersion()) w += versionW
983
+ return w
984
+ })
985
+
986
+ const spacerCols = createMemo(() => {
987
+ if (!anyEntry()) return 0
988
+ return Math.max(0, panelWidth() - leftCols() - summaryCols())
989
+ })
990
+
991
+ const valueCols = (label: string) =>
992
+ Math.max(4, panelWidth() - INDENT - visualWidth(label + ": "))
993
+
994
+ // ── render ──
995
+ return (
996
+ <box
997
+ border={false}
998
+ paddingTop={0} paddingBottom={0} paddingLeft={0} paddingRight={0}
999
+ flexDirection="column" gap={0}
1000
+ ref={boxEl}
1001
+ onSizeChange={() => {
1002
+ const w = boxEl ? Math.max(20, boxEl.width ?? 0) : 28
1003
+ setPanelWidth((prev) => (prev === w ? prev : w))
1004
+ }}
1005
+ >
1006
+ {/* ── header: same pattern as visual-cache's fold toggle ── */}
1007
+ {/* renderTick in span forces the text element to re-evaluate */}
1008
+ <text
1009
+ onMouseUp={() => {
1010
+ setOpen((o) => {
1011
+ const n = !o
1012
+ try { props.api.kv.set(`${KV_PREFIX}.open`, n) } catch {}
1013
+ return n
1014
+ })
1015
+ bump()
1016
+ }}
1017
+ >
1018
+ <span style={{ fg: pal().muted }}>{renderTick() >= 0 && open() ? "\u25bc " : "\u25b6 "}</span>
1019
+ <span style={{ fg: pal().primary }}>{t("panel.title")}</span>
1020
+ <Show when={showVersion()}><span style={{ fg: dimColor(pal().muted, 0.75) }}>{versionText}</span></Show>
1021
+ {anyEntry() ? (
1022
+ <>
1023
+ <span style={{ fg: pal().muted }}>{" ".repeat(spacerCols())}</span>
1024
+ <span style={{ fg: pal().success }}>{summaryParts()!.done}</span>
1025
+ {runningCount() > 0 && (
1026
+ <span style={{ fg: pal().warning }}> {summaryParts()!.running}</span>
1027
+ )}
1028
+ {errCount() > 0 && (
1029
+ <span style={{ fg: pal().error }}> {summaryParts()!.err}</span>
1030
+ )}
1031
+ {summaryParts()!.duration ? (
1032
+ <span style={{ fg: pal().muted }}> {summaryParts()!.duration}</span>
1033
+ ) : null}
1034
+ {summaryParts()!.cost ? (
1035
+ <span style={{ fg: pal().warning }}> {summaryParts()!.cost}</span>
1036
+ ) : null}
1037
+ </>
1038
+ ) : null}
1039
+ </text>
1040
+
1041
+ {/* ── panel body ── */}
1042
+ <Show when={open()}>
1043
+ <text fg={pal().muted}>{sep()}</text>
1044
+
1045
+ <Show
1046
+ when={anyEntry()}
1047
+ fallback={
1048
+ <text style={{ fg: pal().muted }}>
1049
+ {" "}&gt; {t("status.none")} {/* empty indent kept for visual balance */}
1050
+ </text>
1051
+ }
1052
+ >
1053
+ <box
1054
+ onMouseScroll={(e) => {
1055
+ const total = entryList().length
1056
+ const m = max()
1057
+ if (total <= m) return
1058
+ const dir = e.button === 0 ? 1 : -1
1059
+ setScrollOffset((prev) => {
1060
+ const next = Math.max(0, Math.min(prev + dir, total - m))
1061
+ try { persistScroll(props.sessionId, next) } catch {}
1062
+ return next
1063
+ })
1064
+ }}
1065
+ >
1066
+ <Show when={hiddenAbove() > 0}>
1067
+ <text style={{ fg: pal().muted }}>
1068
+ {" "}&uarr; {hiddenAbove()} {t("scroll.more")}
1069
+ </text>
1070
+ </Show>
1071
+ <For each={visibleList()}>
1072
+ {(entry) => {
1073
+ const isExpanded = () => expanded() === entry.id
1074
+ const isRunning = entry.status === "running"
1075
+ const isError = entry.status === "error"
1076
+ const elapsed = () => (entry.endedAt ?? now()) - entry.startedAt
1077
+
1078
+ const statusDot = () => "\u25cf"
1079
+ const statusColor = () => {
1080
+ if (!isRunning) return isError ? pal().error : pal().success
1081
+ const t = (Math.sin(((now() % 2000) / 2000) * Math.PI * 2 - Math.PI / 2) + 1) / 2
1082
+ const a = rgb(pal().muted), b = rgb(pal().warning)
1083
+ if (!a || !b) return pal().warning
1084
+ const r = Math.round(a.r + (b.r - a.r) * t)
1085
+ const g = Math.round(a.g + (b.g - a.g) * t)
1086
+ const bl = Math.round(a.b + (b.b - a.b) * t)
1087
+ return "#" + [r, g, bl].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
1088
+ }
1089
+
1090
+ const timeColor = () =>
1091
+ isRunning ? pal().warning : isError ? pal().error : pal().muted
1092
+
1093
+ // Entry label: collapsed shows title only, expanded shows title only too
1094
+ const tokenText = () =>
1095
+ !isExpanded() && entry.tokens !== undefined && entry.tokens > 0
1096
+ ? ` ${fmtTokens(entry.tokens!)}`
1097
+ : ""
1098
+ const timeText = () =>
1099
+ !isExpanded() && (elapsed() >= 2000 || entry.endedAt !== undefined)
1100
+ ? fmtDurationShort(elapsed(), isRunning)
1101
+ : ""
1102
+ const suffixW = () => {
1103
+ let w = 0
1104
+ const t = timeText()
1105
+ if (t) w += 1 + visualWidth(t)
1106
+ const tk = tokenText()
1107
+ if (tk) w += visualWidth(tk)
1108
+ return w
1109
+ }
1110
+ const labelAvail = () => Math.max(6, panelWidth() - LEFT_PAD - suffixW())
1111
+ const labelText = () => {
1112
+ const max = labelAvail()
1113
+ const text = entry.title || entry.agent
1114
+ const truncated = truncate(text, max)
1115
+ const pad = Math.max(0, max - visualWidth(truncated))
1116
+ return truncated + " ".repeat(pad)
1117
+ }
1118
+
1119
+ return (
1120
+ <>
1121
+ {/* entry line — left-aligned */}
1122
+ <text onMouseUp={() => toggleExpand(entry.id)}>
1123
+ <span style={{ fg: pal().muted }}>
1124
+ {isExpanded() ? "\u25bc" : "\u25b6"}
1125
+ </span>
1126
+ {" "}
1127
+ <span style={{ fg: statusColor() }}>{statusDot()}</span>
1128
+ {" "}
1129
+ <span style={{ fg: pal().text }}>{labelText()}</span>
1130
+ {timeText() ? (
1131
+ <>
1132
+ {" "}
1133
+ <span style={{ fg: timeColor() }}>{timeText()}</span>
1134
+ </>
1135
+ ) : null}
1136
+ {tokenText() ? (
1137
+ <span style={{ fg: pal().muted }}>{tokenText()}</span>
1138
+ ) : null}
1139
+ </text>
1140
+
1141
+ {/* expanded detail — right-aligned values */}
1142
+ <Show when={isExpanded()}>
1143
+ <text>
1144
+ {" "}
1145
+ <span style={{ fg: pal().primary }}>{t("agent.label")}: </span>
1146
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("agent.label")))}</span>
1147
+ <span style={{ fg: pal().muted }}>{entry.agent}</span>
1148
+ </text>
1149
+ <Show when={elapsed() >= 2000 || entry.endedAt !== undefined}>
1150
+ <text>
1151
+ {" "}
1152
+ <span style={{ fg: pal().primary }}>{t("time.label")}: </span>
1153
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("time.label")))}</span>
1154
+ <span style={{ fg: pal().muted }}>
1155
+ {fmtDurationShort(elapsed(), isRunning)}
1156
+ </span>
1157
+ </text>
1158
+ </Show>
1159
+ <Show when={entry.tokens !== undefined}>
1160
+ <text>
1161
+ {" "}
1162
+ <span style={{ fg: pal().primary }}>{t("tokens.label")}: </span>
1163
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("tokens.label")))}</span>
1164
+ <span style={{ fg: pal().muted }}>{fmtTokens(entry.tokens!)}</span>
1165
+ </text>
1166
+ </Show>
1167
+ <Show when={entry.error}>
1168
+ <text>
1169
+ {" "}
1170
+ <span style={{ fg: pal().error }}>{t("error.label")}: </span>
1171
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("error.label")))}</span>
1172
+ <span style={{ fg: pal().error }}>{truncate(String(entry.error), expandedValAvail())}</span>
1173
+ </text>
1174
+ </Show>
1175
+ <Show when={entry.cost !== undefined}>
1176
+ <text>
1177
+ {" "}
1178
+ <span style={{ fg: pal().primary }}>{t("cost.label")}: </span>
1179
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("cost.label")))}</span>
1180
+ <span style={{ fg: pal().muted }}>${entry.cost!.toFixed(4)}</span>
1181
+ </text>
1182
+ </Show>
1183
+ <Show when={entry.model}>
1184
+ <text>
1185
+ {" "}
1186
+ <span style={{ fg: pal().primary }}>{t("model.label")}: </span>
1187
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("model.label")))}</span>
1188
+ <span style={{ fg: pal().muted }}>{truncate(entry.model!, expandedValAvail())}</span>
1189
+ </text>
1190
+ </Show>
1191
+ <Show when={entry.todoTotal !== undefined}>
1192
+ <text>
1193
+ {" "}
1194
+ <span style={{ fg: pal().primary }}>{t("todo.label")}: </span>
1195
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("todo.label")))}</span>
1196
+ <span style={{ fg: pal().muted }}>{entry.todoDone}/{entry.todoTotal}</span>
1197
+ </text>
1198
+ </Show>
1199
+ <Show when={entry.sessionId}>
1200
+ <text
1201
+ onMouseOver={() => setHoveredOpen(entry.id)}
1202
+ onMouseOut={() => setHoveredOpen(undefined)}
1203
+ onMouseUp={() => {
1204
+ if (entry.sessionId) {
1205
+ props.api.route.navigate("session", { sessionID: entry.sessionId })
1206
+ }
1207
+ }}
1208
+ >
1209
+ {" "}
1210
+ <span style={{ fg: hoveredOpen() === entry.id ? pal().warning : pal().primary }}>{"\u2192 "}</span>
1211
+ <span style={{ fg: hoveredOpen() === entry.id ? pal().warning : pal().primary }}>{t("open.label")}</span>
1212
+ </text>
1213
+ </Show>
1214
+ </Show>
1215
+ </>
1216
+ )
1217
+ }}
1218
+ </For>
1219
+ <Show when={hiddenBelow() > 0 || scrollOffset() > 0}>
1220
+ {(() => {
1221
+ const showMore = hiddenBelow() > 0
1222
+ const showTop = scrollOffset() > 0
1223
+ const left = showMore ? ` \u2193 ${hiddenBelow()} ${t("scroll.more")}` : " "
1224
+ const right = `\u2191 ${t("scroll.top")}`
1225
+ const pad = showTop ? Math.max(1, panelWidth() - visualWidth(left) - visualWidth(right)) : 0
1226
+ return (
1227
+ <box flexDirection="row">
1228
+ <text style={{ fg: pal().muted }}>{left}</text>
1229
+ {showTop ? (
1230
+ <>
1231
+ <text style={{ fg: pal().muted }}>{" ".repeat(pad)}</text>
1232
+ <text
1233
+ onMouseOver={() => setHoveredTop(true)}
1234
+ onMouseOut={() => setHoveredTop(false)}
1235
+ onMouseUp={() => { setScrollOffset(0); setHoveredTop(false) }}
1236
+ >
1237
+ <span style={{ fg: hoveredTop() ? pal().warning : pal().muted }}>{right}</span>
1238
+ </text>
1239
+ </>
1240
+ ) : null}
1241
+ </box>
1242
+ )
1243
+ })()}
1244
+ </Show>
1245
+ </box>
1246
+ </Show>
1247
+ </Show>
1248
+ </box>
1249
+ )
1250
+ }
1251
+
1252
+ // ===================================================================
1253
+ // Plugin entry
1254
+ // ===================================================================
1255
+
1256
+ interface SharedSignals {
1257
+ lang: () => Lang
1258
+ setLang: (l: Lang) => void
1259
+ maxEntries: () => number
1260
+ setMaxEntries: (n: number) => void
1261
+ sessionId: string
1262
+ }
1263
+
1264
+ function createSidebarSlot(api: TuiPluginApi, sig: SharedSignals): TuiSlotPlugin {
1265
+ return {
1266
+ order: 60,
1267
+ slots: {
1268
+ sidebar_content(ctx: TuiSlotContext, input: { session_id: string }): JSX.Element {
1269
+ sig.sessionId = input.session_id
1270
+ return (
1271
+ <SubAgentPanel
1272
+ theme={ctx.theme.current}
1273
+ api={api}
1274
+ lang={sig.lang}
1275
+ maxEntries={sig.maxEntries}
1276
+ sessionId={input.session_id}
1277
+ />
1278
+ )
1279
+ },
1280
+ },
1281
+ }
1282
+ }
1283
+
1284
+ const KV_PREFIX = "subagent_magazine"
1285
+
1286
+ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1287
+ // ── language ──
1288
+ const stored = String(api.kv.get(`${KV_PREFIX}.lang`, ""))
1289
+ const initialLang: Lang =
1290
+ stored === "zh" || stored === "en" ? stored : detectLang()
1291
+ const [lang, setLang] = createSignal<Lang>(initialLang)
1292
+ const [maxEntries, setMaxEntries] = createSignal(
1293
+ parseInt(String(api.kv.get(`${KV_PREFIX}.max_entries`, "10")), 10) || 10
1294
+ )
1295
+
1296
+ const signals: SharedSignals = { lang, setLang, maxEntries, setMaxEntries, sessionId: "" }
1297
+
1298
+ api.slots.register(createSidebarSlot(api, signals))
1299
+
1300
+ // ── slash command: /subagent-lang ──
1301
+ api.command?.register(() => [
1302
+ {
1303
+ title: "SubAgent Magazine: Language",
1304
+ value: "subagent-lang",
1305
+ description: "Switch display language (中文 / English)",
1306
+ slash: { name: "subagent-lang" },
1307
+ onSelect: (dialog) => {
1308
+ dialog?.replace(() => (
1309
+ <api.ui.DialogSelect
1310
+ title="Language / 语言"
1311
+ options={[
1312
+ { title: "中文", value: "zh" },
1313
+ { title: "English", value: "en" },
1314
+ ]}
1315
+ onSelect={(opt) => {
1316
+ const l = opt.value as Lang
1317
+ setLang(l)
1318
+ api.kv.set(`${KV_PREFIX}.lang`, l)
1319
+ api.ui.toast({
1320
+ message: l === "zh" ? "语言: 中文" : "Language: English",
1321
+ })
1322
+ dialog?.clear()
1323
+ }}
1324
+ />
1325
+ ))
1326
+ },
1327
+ },
1328
+ {
1329
+ title: "SubAgent Magazine: Max Entries",
1330
+ value: "subagent-max",
1331
+ description: "Set max visible sub-agent entries in sidebar",
1332
+ slash: { name: "subagent-max" },
1333
+ onSelect: (dialog) => {
1334
+ dialog?.replace(() => (
1335
+ <api.ui.DialogPrompt
1336
+ title="Max Visible Entries"
1337
+ description={() => (
1338
+ <text>Number of entries to show in the sidebar (1–50)</text>
1339
+ )}
1340
+ value={String(maxEntries())}
1341
+ onConfirm={(val) => {
1342
+ const n = Math.max(1, Math.min(50, parseInt(val, 10) || 10))
1343
+ setMaxEntries(n)
1344
+ api.kv.set(`${KV_PREFIX}.max_entries`, n)
1345
+ api.ui.toast({ message: `Max entries: ${n}` })
1346
+ dialog?.clear()
1347
+ }}
1348
+ />
1349
+ ))
1350
+ },
1351
+ },
1352
+ {
1353
+ title: "SubAgent Magazine: Version",
1354
+ value: "subagent-version",
1355
+ description: "Show plugin version",
1356
+ slash: { name: "subagent-version" },
1357
+ onSelect: (dialog) => {
1358
+ api.ui.toast({ message: `opencode-subagent-magazine v${PLUGIN_VERSION}` })
1359
+ dialog?.clear()
1360
+ },
1361
+ },
1362
+ {
1363
+ title: "SubAgent Magazine: Session",
1364
+ value: "subagent-session",
1365
+ description: "Show current session ID",
1366
+ slash: { name: "subagent-session" },
1367
+ onSelect: (dialog) => {
1368
+ api.ui.toast({ message: `Session: ${signals.sessionId}` })
1369
+ dialog?.clear()
1370
+ },
1371
+ },
1372
+ ])
1373
+ }
1374
+
1375
+ const mod: TuiPluginModule & { id: string } = {
1376
+ id: "opencode-subagent-magazine",
1377
+ tui,
1378
+ }
1379
+
1380
+ export default mod