opencode-subagent-magazine 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.tsx CHANGED
@@ -1,1380 +1,1509 @@
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",
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
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
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
+ // 模块级缓存:保留各 session 的最新 entryMap,不随组件内 session 切换而丢失。
241
+ // 当用户在子 session 内部时,handleSessionEnd 可通过此缓存找到父 session 的 entries。
242
+ const globalEntryCache = new Map<string, Map<string, SubEntry>>()
243
+
244
+ function SubAgentPanel(props: {
245
+ theme: TuiThemeCurrent
246
+ api: TuiPluginApi
247
+ lang: () => Lang
248
+ maxEntries: () => number
249
+ sessionId: string
250
+ }): JSX.Element {
251
+ const t = (key: string) => I18N[props.lang()][key] ?? key
252
+
253
+ // ── session data (single-key, true deletion on cleanup) ──
254
+ const SESSION_DATA_KEY = `${KV_PREFIX}.session_data`
255
+ const TTL_MS = 3 * 24 * 60 * 60 * 1000
256
+
257
+ interface SessionRecord {
258
+ ts: number
259
+ entries: SubEntry[]
260
+ scroll: number
261
+ expanded: string
262
+ }
263
+
264
+ const loadSessionData = (): Record<string, SessionRecord> => {
265
+ try {
266
+ const raw = props.api.kv.get(SESSION_DATA_KEY, "{}")
267
+ return JSON.parse(String(raw))
268
+ } catch { return {} }
269
+ }
270
+
271
+ const saveSessionData = (data: Record<string, SessionRecord>) => {
272
+ try { props.api.kv.set(SESSION_DATA_KEY, JSON.stringify(data)) } catch {}
273
+ }
274
+
275
+ const loadEntries = (sid: string): Map<string, SubEntry> => {
276
+ const m = new Map<string, SubEntry>()
277
+ try {
278
+ const rec = loadSessionData()[sid]
279
+ if (rec?.entries) {
280
+ for (const e of rec.entries) m.set(e.id, e)
281
+ }
282
+ } catch {}
283
+ return m
284
+ }
285
+
286
+ let persistTimer: ReturnType<typeof setTimeout> | undefined
287
+ const persistEntries = (sid: string, entries: Map<string, SubEntry>) => {
288
+ clearTimeout(persistTimer)
289
+ persistTimer = setTimeout(() => {
290
+ try {
291
+ const data = loadSessionData()
292
+ data[sid] = { ...data[sid], ts: Date.now(), entries: [...entries.values()] }
293
+ saveSessionData(data)
294
+ } catch {}
295
+ }, 200)
296
+ }
297
+
298
+ const persistScroll = (sid: string, scroll: number) => {
299
+ try {
300
+ const data = loadSessionData()
301
+ data[sid] = { ...data[sid], ts: Date.now(), scroll }
302
+ saveSessionData(data)
303
+ } catch {}
304
+ }
305
+
306
+ const persistExpanded = (sid: string, expanded: string) => {
307
+ try {
308
+ const data = loadSessionData()
309
+ data[sid] = { ...data[sid], ts: Date.now(), expanded }
310
+ saveSessionData(data)
311
+ } catch {}
312
+ }
313
+
314
+ const cleanupOldSessions = () => {
315
+ try {
316
+ const data = loadSessionData()
317
+ const cutoff = Date.now() - TTL_MS
318
+ let changed = false
319
+ for (const sid of Object.keys(data)) {
320
+ if (data[sid].ts < cutoff) {
321
+ delete data[sid]
322
+ changed = true
323
+ }
324
+ }
325
+ if (changed) saveSessionData(data)
326
+ } catch {}
327
+ }
328
+
329
+ cleanupOldSessions()
330
+
331
+ const [entryMap, setEntryMapRaw] = createSignal(loadEntries(props.sessionId))
332
+
333
+ // Wrapped setter — also persists to kv on every mutation
334
+ const setEntryMap = (
335
+ arg: Map<string, SubEntry> | ((prev: Map<string, SubEntry>) => Map<string, SubEntry>),
336
+ ) => {
337
+ setEntryMapRaw((prev) => {
338
+ const next = typeof arg === "function" ? (arg as Function)(prev) : arg
339
+
340
+ // 检测是否有 entry running 变为 done/error → 立即写 KV
341
+ // 避免 session 切换时 KV 仍是旧状态,导致 scan 用 Date.now() 覆盖正确的 endedAt
342
+ let needsImmediateFlush = false
343
+ for (const [id, entry] of next) {
344
+ const prevEntry = prev.get(id)
345
+ if (prevEntry?.status === "running" && (entry.status === "done" || entry.status === "error")) {
346
+ needsImmediateFlush = true
347
+ break
348
+ }
349
+ }
350
+
351
+ if (needsImmediateFlush) {
352
+ clearTimeout(persistTimer)
353
+ try {
354
+ const data = loadSessionData()
355
+ data[props.sessionId] = { ...data[props.sessionId], ts: Date.now(), entries: [...next.values()] }
356
+ saveSessionData(data)
357
+ } catch {}
358
+ } else {
359
+ persistEntries(props.sessionId, next)
360
+ }
361
+
362
+ // 同步到全局缓存,确保跨 session 查找时数据可用
363
+ globalEntryCache.set(props.sessionId, new Map(next))
364
+
365
+ return next
366
+ })
367
+ }
368
+
369
+ const [panelWidth, setPanelWidth] = createSignal(28)
370
+ const [open, setOpen] = createSignal(
371
+ (() => { try { return props.api.kv.get(`${KV_PREFIX}.open`, true) as boolean } catch { return true } })()
372
+ )
373
+ const [expanded, setExpanded] = createSignal<string | undefined>(
374
+ (() => { try { return loadSessionData()[props.sessionId]?.expanded || undefined } catch { return undefined } })()
375
+ )
376
+ const [hoveredOpen, setHoveredOpen] = createSignal<string | undefined>(undefined)
377
+ const [hoveredTop, setHoveredTop] = createSignal(false)
378
+ const [scrollOffset, setScrollOffset] = createSignal(
379
+ (() => { try { return loadSessionData()[props.sessionId]?.scroll ?? 0 } catch { return 0 } })()
380
+ )
381
+ const [now, setNow] = createSignal(Date.now())
382
+ const [renderTick, setRenderTick] = createSignal(0)
383
+
384
+ let boxEl: any
385
+ let disposed = false
386
+
387
+ /** Total context tokens for a sub-agent session.
388
+ * Matches opencode-visual-cache's "总计": last assistant message's input + cache.read. */
389
+ const readSessionTokens = (sid: string): number | undefined => {
390
+ if (!sid) return undefined
391
+ try {
392
+ const msgs = props.api.state.session.messages(sid)
393
+ if (msgs) {
394
+ for (let i = (msgs as any[]).length - 1; i >= 0; i--) {
395
+ const m = (msgs as any[])[i]
396
+ if (m.role !== "assistant") continue
397
+ const t = m.tokens
398
+ if (!t) continue
399
+ const cache = t.cache as { read?: number; write?: number } | undefined
400
+ const ctx = (Number(t.input) || 0) + (cache?.read ?? 0)
401
+ if (ctx > 0) return ctx
402
+ }
403
+ }
404
+ return undefined
405
+ } catch {
406
+ return undefined
407
+ }
408
+ }
409
+
410
+ /** Sum USD cost from a session's messages.
411
+ * Prefers the database-level aggregate (`session.cost`) which is not affected
412
+ * by the sync layer's `limit: 100` message window. Falls back to message
413
+ * traversal when the aggregate is unavailable (older SDK versions). */
414
+ const readSessionCost = (sid: string): number | undefined => {
415
+ if (!sid) return undefined
416
+ try {
417
+ const session = props.api.state.session.get(sid)
418
+ if (session?.cost != null && session.cost > 0) return session.cost
419
+ const msgs = props.api.state.session.messages(sid)
420
+ if (!msgs) return undefined
421
+ let total = 0
422
+ for (const m of msgs as any[]) {
423
+ if (m.role === "assistant" && typeof m.cost === "number") total += m.cost
424
+ }
425
+ return total > 0 ? total : undefined
426
+ } catch {
427
+ return undefined
428
+ }
429
+ }
430
+
431
+ /** Last assistant message's modelID for a sub-agent session. */
432
+ const readSessionModel = (sid: string): string | undefined => {
433
+ if (!sid) return undefined
434
+ try {
435
+ const msgs = props.api.state.session.messages(sid)
436
+ if (msgs) {
437
+ for (let i = (msgs as any[]).length - 1; i >= 0; i--) {
438
+ const m = (msgs as any[])[i]
439
+ if (m.role === "assistant" && m.modelID) return String(m.modelID)
440
+ }
441
+ }
442
+ return undefined
443
+ } catch {
444
+ return undefined
445
+ }
446
+ }
447
+
448
+ /** Todo completion stats for a sub-agent session.
449
+ * `done` counts completed + cancelled items. */
450
+ const readSessionTodo = (sid: string): { total: number; done: number } | undefined => {
451
+ if (!sid) return undefined
452
+ try {
453
+ const todos = props.api.state.session.todo(sid)
454
+ if (!todos || todos.length === 0) return undefined
455
+ let done = 0
456
+ for (const t of todos) {
457
+ if (t.status === "completed" || t.status === "cancelled") done++
458
+ }
459
+ return { total: todos.length, done }
460
+ } catch {
461
+ return undefined
462
+ }
463
+ }
464
+
465
+ // ── upsert ──
466
+ const upsertEntry = (
467
+ partial: Omit<SubEntry, "startedAt" | "endedAt"> & { startedAt?: number }
468
+ ) => {
469
+ setEntryMap((prev) => {
470
+ const existing = prev.get(partial.id)
471
+ const next = new Map(prev)
472
+ const nowTs = Date.now()
473
+ const e = partial.status
474
+ const ended = e === "done" || e === "error"
475
+ next.set(partial.id, {
476
+ ...(existing ?? { startedAt: nowTs }),
477
+ ...partial,
478
+ startedAt: existing?.startedAt || partial.startedAt || nowTs,
479
+ endedAt: ended ? (existing?.endedAt || nowTs) : undefined,
480
+ })
481
+ return next
482
+ })
483
+ }
484
+
485
+ // ── event handlers ──
486
+ const handlePartUpdated = (event: unknown) => {
487
+ const e = event as Record<string, unknown>
488
+ const props_ = e.properties as Record<string, unknown> | undefined
489
+ const part = props_?.part as Record<string, unknown> | undefined
490
+ if (!part) return
491
+
492
+ // SubtaskPart
493
+ if (part.type === "subtask") {
494
+ const agent = String(part.agent ?? "?")
495
+ const prompt = String(part.prompt ?? "")
496
+ const desc = String(part.description ?? "")
497
+ const title = desc || truncate(prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim(), 40)
498
+
499
+ const id = `sub:${String(part.id ?? crypto.randomUUID())}`
500
+ const subSid = part.sessionID !== undefined ? String(part.sessionID) : undefined
501
+ const partModel = part.model as { modelID?: string } | undefined
502
+ const modelId = partModel?.modelID ? String(partModel.modelID) : undefined
503
+ upsertEntry({ id, title, agent, prompt, sessionId: subSid, status: "running", model: modelId })
504
+ }
505
+
506
+ // ToolPart
507
+ if (part.type === "tool") {
508
+ const tool = String(part.tool ?? "")
509
+ if (!SUBAGENT_TOOLS.has(tool)) return
510
+ const st = part.state as Record<string, unknown> | undefined
511
+ const rawStatus = String(st?.status ?? "")
512
+
513
+ // Only create entries for tool calls that actually entered execution.
514
+ // "pending" / empty state unknown yet, wait for next event
515
+ if (rawStatus === "pending" || rawStatus === "") return
516
+
517
+ // "error" tool call failed, sub-agent never spawned.
518
+ // Only update an existing entry (e.g. previously running → now error),
519
+ // never create a new one.
520
+ if (rawStatus === "error") {
521
+ const id = `tool:${String(part.id ?? "")}`
522
+ if (!part.id) return
523
+ const existing = entryMap().get(id)
524
+ if (existing) {
525
+ upsertEntry({ id, title: existing.title, agent: existing.agent, prompt: existing.prompt, status: "error" })
526
+ }
527
+ return
528
+ }
529
+
530
+ // rawStatus is "running" or "completed" tool entered execution, track it.
531
+ const input = st?.input as Record<string, unknown> | undefined
532
+ let status: SubStatus = "running"
533
+ if (rawStatus === "completed") status = "done"
534
+ // Background tasks: tool completion ≠ agent completion — keep running until session.idle
535
+ // Only keep running if state metadata confirms a child session was spawned;
536
+ // otherwise (failed spawn, invalid agent) mark as done so the entry isn't stuck forever.
537
+ if (input?.run_in_background === true && status === "done") {
538
+ const stMetaCheck = st?.metadata as Record<string, unknown> | undefined
539
+ const hasChild = stMetaCheck?.session_id !== undefined || stMetaCheck?.sessionId !== undefined
540
+ if (hasChild) status = "running"
541
+ }
542
+
543
+ const agent = String((part as any).subagent_type ?? input?.subagent_type ?? input?.category ?? tool)
544
+ const prompt = String(input?.prompt ?? (part as any).description ?? "")
545
+ const desc = input?.description !== undefined ? String(input.description) : ""
546
+ const title = desc || truncate(prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim(), 40)
547
+
548
+ const id = `tool:${String(part.id ?? crypto.randomUUID())}`
549
+ // Child session ID lives in state-level metadata (ToolStateCompleted.metadata),
550
+ // injected by the tool executor. ToolPart.sessionID is the parent session.
551
+ const stMeta = st?.metadata as Record<string, unknown> | undefined
552
+ const subSid = stMeta?.session_id !== undefined ? String(stMeta.session_id)
553
+ : stMeta?.sessionId !== undefined ? String(stMeta.sessionId)
554
+ : undefined
555
+ upsertEntry({ id, title, agent, prompt, sessionId: subSid, status })
556
+ }
557
+ }
558
+
559
+ const handleSessionEnd = (event: unknown, status: SubStatus) => {
560
+ const e = event as Record<string, unknown>
561
+ const props_ = e.properties as Record<string, unknown> | undefined
562
+ const sid = String(props_?.sessionID ?? "")
563
+ if (!sid) return
564
+
565
+ const sessionTokens = readSessionTokens(sid)
566
+ const sessionCost = readSessionCost(sid)
567
+ const sessionModel = readSessionModel(sid)
568
+ const sessionTodo = readSessionTodo(sid)
569
+ let sessionAgent: string | undefined
570
+ let errorMsg: string | undefined
571
+ try {
572
+ const s = props.api.state.session.get(sid)
573
+ sessionAgent = s?.agent
574
+ if (status === "error") {
575
+ const evtErr = props_?.error as Record<string, unknown> | undefined
576
+ errorMsg = safeErrorMsg(evtErr) || safeErrorMsg(props_?.message)
577
+ if (!errorMsg) {
578
+ const msgs = props.api.state.session.messages(sid)
579
+ if (msgs) {
580
+ for (let i = (msgs as any[]).length - 1; i >= 0; i--) {
581
+ const m = (msgs as any[])[i]
582
+ if (m.role === "assistant" && m.error) {
583
+ errorMsg = safeErrorMsg(m.error)
584
+ break
585
+ }
586
+ }
587
+ }
588
+ }
589
+ }
590
+ } catch {}
591
+
592
+ // 在给定的 entries Map 中查找并更新匹配的子代理 entry。
593
+ // 返回 true 表示找到并更新了,false 表示未找到。
594
+ const tryMatchAndUpdate = (
595
+ entriesMap: Map<string, SubEntry>,
596
+ targetSid: string,
597
+ targetStatus: SubStatus,
598
+ nowTs: number,
599
+ ): boolean => {
600
+ // 精确匹配:sessionId 对得上 + 状态为 running
601
+ for (const [, entry] of entriesMap) {
602
+ if (entry.sessionId === targetSid && entry.status === "running") {
603
+ entry.status = targetStatus
604
+ entry.endedAt = nowTs
605
+ entry.tokens = entry.tokens ?? sessionTokens
606
+ entry.cost = entry.cost ?? sessionCost
607
+ entry.model = entry.model ?? sessionModel
608
+ entry.todoTotal = entry.todoTotal ?? sessionTodo?.total
609
+ entry.todoDone = entry.todoDone ?? sessionTodo?.done
610
+ entry.error = errorMsg || entry.error
611
+ return true
612
+ }
613
+ }
614
+ // 回退:sessionId 未关联但 agent 名匹配 + 状态为 running
615
+ if (sessionAgent) {
616
+ const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]/g, "")
617
+ const saNorm = normalize(sessionAgent)
618
+ let best: { entry: SubEntry; gap: number } | null = null
619
+ for (const [, entry] of entriesMap) {
620
+ if (entry.status !== "running") continue
621
+ const eaNorm = normalize(entry.agent)
622
+ if (!eaNorm || !saNorm) continue
623
+ if (!eaNorm.includes(saNorm) && !saNorm.includes(eaNorm)) continue
624
+ const gap = nowTs - (entry.startedAt || 0)
625
+ if (!best || gap > best.gap) best = { entry, gap }
626
+ }
627
+ if (!best) {
628
+ for (const [, entry] of entriesMap) {
629
+ if (entry.status !== "running") continue
630
+ if (entry.sessionId) continue
631
+ const gap = nowTs - (entry.startedAt || 0)
632
+ if (!best || gap > best.gap) best = { entry, gap }
633
+ }
634
+ }
635
+ if (best) {
636
+ best.entry.status = targetStatus
637
+ best.entry.endedAt = nowTs
638
+ best.entry.tokens = best.entry.tokens ?? sessionTokens
639
+ best.entry.cost = best.entry.cost ?? sessionCost
640
+ best.entry.model = best.entry.model ?? sessionModel
641
+ best.entry.todoTotal = best.entry.todoTotal ?? sessionTodo?.total
642
+ best.entry.todoDone = best.entry.todoDone ?? sessionTodo?.done
643
+ best.entry.sessionId = targetSid
644
+ best.entry.error = errorMsg || best.entry.error
645
+ return true
646
+ }
647
+ }
648
+ return false
649
+ }
650
+
651
+ setEntryMap((prev) => {
652
+ let changed = false
653
+ const next = new Map(prev)
654
+ for (const [id, entry] of next) {
655
+ if (entry.sessionId !== sid) continue
656
+ if (entry.status !== "running" && entry.status !== "done") continue
657
+ // Skip parent session idle subagent entries belong to child sessions only
658
+ if (sid === props.sessionId) continue
659
+ // For "done" entries (sync tasks completed before session.idle), only backfill tokens/cost
660
+ const alreadySettled = entry.status !== "running"
661
+ next.set(id, {
662
+ ...entry,
663
+ ...(alreadySettled ? {} : { status, endedAt: Date.now() }),
664
+ tokens: entry.tokens ?? sessionTokens,
665
+ cost: entry.cost ?? sessionCost,
666
+ model: entry.model ?? sessionModel,
667
+ todoTotal: entry.todoTotal ?? sessionTodo?.total,
668
+ todoDone: entry.todoDone ?? sessionTodo?.done,
669
+ error: errorMsg || entry.error,
670
+ })
671
+ changed = true
672
+ }
673
+ if (!changed && sessionAgent) {
674
+ const nowTs = Date.now()
675
+ const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]/g, "")
676
+ const saNorm = normalize(sessionAgent)
677
+ let best: { id: string; gap: number } | null = null
678
+
679
+ // Phase 1: try matching by agent name(agent 名有交集)
680
+ for (const [id, entry] of next) {
681
+ if (entry.status !== "running") continue
682
+ const eaNorm = normalize(entry.agent)
683
+ if (!eaNorm || !saNorm) continue
684
+ if (!eaNorm.includes(saNorm) && !saNorm.includes(eaNorm)) continue
685
+ const gap = nowTs - (entry.startedAt || 0)
686
+ if (!best || gap > best.gap) best = { id, gap }
687
+ }
688
+
689
+ // Phase 2: if agent name has no overlap (e.g. category calls: agent="deep" vs sessionAgent="Sisyphus-Junior"),
690
+ // fall back to time proximity for entries that have no sessionId yet
691
+ if (!best) {
692
+ for (const [id, entry] of next) {
693
+ if (entry.status !== "running") continue
694
+ if (entry.sessionId) continue
695
+ const gap = nowTs - (entry.startedAt || 0)
696
+ if (!best || gap > best.gap) best = { id, gap }
697
+ }
698
+ }
699
+
700
+ if (best) {
701
+ const entry = next.get(best.id)!
702
+ next.set(best.id, {
703
+ ...entry, status, endedAt: nowTs,
704
+ tokens: sessionTokens || entry.tokens,
705
+ cost: sessionCost || entry.cost,
706
+ sessionId: sid,
707
+ error: errorMsg || entry.error,
708
+ })
709
+ changed = true
710
+ }
711
+ }
712
+ return changed ? next : prev
713
+ })
714
+
715
+ // 跨 session 完成事件:用户正在查看子 session 内部时,其他子代理 done。
716
+ // 上面的 setEntryMap 在子 session 的 entries 中找不到父 session 的 entry。
717
+ // 通过全局缓存(切换前保留的父 session entries)查找并更新,再同步回 KV。
718
+ try {
719
+ const sessionObj = props.api.state.session.get(sid)
720
+ const parentSid = sessionObj?.parentID
721
+ if (parentSid && parentSid !== props.sessionId) {
722
+ // 优先全局缓存——切换 session 时不会被替换,始终保留父 session 的最新 entries
723
+ const parentCache = globalEntryCache.get(parentSid)
724
+ const nowTs = Date.now()
725
+ let found = false
726
+
727
+ if (parentCache) {
728
+ found = tryMatchAndUpdate(parentCache, sid, status, nowTs)
729
+ }
730
+
731
+ // 缓存未命中时回退到 KV(极端情况:session 切换前缓存未建立)
732
+ if (!found) {
733
+ const data = loadSessionData()
734
+ const rec = data[parentSid]
735
+ if (rec?.entries) {
736
+ const fallbackMap = new Map(rec.entries.map((e: SubEntry) => [e.id, e]))
737
+ found = tryMatchAndUpdate(fallbackMap, sid, status, nowTs)
738
+ if (found) {
739
+ // 回退命中了也要写回 KV,并回填缓存
740
+ data[parentSid] = { ...rec, ts: nowTs, entries: [...fallbackMap.values()] }
741
+ saveSessionData(data)
742
+ globalEntryCache.set(parentSid, fallbackMap)
743
+ }
744
+ }
745
+ }
746
+
747
+ // KV 同步:以全局缓存(最新的内存状态)为准写回 KV
748
+ if (found && parentCache) {
749
+ const data = loadSessionData()
750
+ data[parentSid] = { ...data[parentSid], ts: nowTs, entries: [...parentCache.values()] }
751
+ saveSessionData(data)
752
+ }
753
+ }
754
+ } catch {}
755
+
756
+ // Delayed backfill: re-read data after state sync catches up, to capture the final
757
+ // token/cost values that may not have been available when session.idle fired.
758
+ setTimeout(() => {
759
+ if (disposed) return
760
+ const finalTokens = readSessionTokens(sid)
761
+ const finalCost = readSessionCost(sid)
762
+ const finalModel = readSessionModel(sid)
763
+ const finalTodo = readSessionTodo(sid)
764
+ setEntryMap((prev) => {
765
+ let changed = false
766
+ const next = new Map(prev)
767
+ for (const [id, entry] of next) {
768
+ if (entry.sessionId !== sid) continue
769
+ const t = finalTokens ?? entry.tokens
770
+ const c = finalCost ?? entry.cost
771
+ const m = finalModel ?? entry.model
772
+ const tt = finalTodo?.total ?? entry.todoTotal
773
+ const td = finalTodo?.done ?? entry.todoDone
774
+ if (t !== entry.tokens || c !== entry.cost || m !== entry.model ||
775
+ tt !== entry.todoTotal || td !== entry.todoDone) {
776
+ next.set(id, { ...entry, tokens: t, cost: c, model: m, todoTotal: tt, todoDone: td })
777
+ changed = true
778
+ }
779
+ }
780
+ return changed ? next : prev
781
+ })
782
+ bump()
783
+ }, 150)
784
+ }
785
+
786
+ // ── bumpRenderTick: force re-render (visual-cache pattern) ──
787
+ const bump = () => setRenderTick((v) => v + 1)
788
+
789
+ onMount(() => {
790
+ // Fast clock for smooth time display, separate from token polling
791
+ const clock = setInterval(() => { setNow(Date.now()); bump() }, 100)
792
+ // Token poll runs every 500ms for running entries
793
+ const tokenTimer = setInterval(() => {
794
+ untrack(() => {
795
+ setEntryMapRaw((prev) => {
796
+ let changed = false
797
+ const next = new Map(prev)
798
+ for (const [id, entry] of next) {
799
+ if (entry.status === "running" && entry.sessionId) {
800
+ // Only read from child sessions, never the parent
801
+ let isChild = false
802
+ try {
803
+ const s = props.api.state.session.get(entry.sessionId)
804
+ isChild = s?.parentID === props.sessionId
805
+ } catch {}
806
+ if (!isChild) continue
807
+ const total = readSessionTokens(entry.sessionId)
808
+ const todo = readSessionTodo(entry.sessionId)
809
+ const model = entry.model ?? readSessionModel(entry.sessionId)
810
+ const nextEntry: SubEntry = { ...entry }
811
+ if (total !== undefined && total !== entry.tokens) { nextEntry.tokens = total; changed = true }
812
+ if (todo !== undefined) {
813
+ if (todo.total !== entry.todoTotal || todo.done !== entry.todoDone) {
814
+ nextEntry.todoTotal = todo.total; nextEntry.todoDone = todo.done; changed = true
815
+ }
816
+ }
817
+ if (model && !entry.model) { nextEntry.model = model; changed = true }
818
+ if (changed) next.set(id, nextEntry)
819
+ }
820
+ }
821
+ return changed ? next : prev
822
+ })
823
+ })
824
+ bump()
825
+ }, 500)
826
+ bump()
827
+
828
+ const unsubPart = props.api.event.on("message.part.updated", (e) => {
829
+ handlePartUpdated(e)
830
+ bump()
831
+ })
832
+ const unsubMsg = props.api.event.on("message.updated", () => bump())
833
+ const unsubIdle = props.api.event.on("session.idle", (e) => {
834
+ handleSessionEnd(e, "done")
835
+ bump()
836
+ })
837
+ const unsubError = props.api.event.on("session.error", (e) => {
838
+ handleSessionEnd(e, "error")
839
+ bump()
840
+ })
841
+
842
+ onCleanup(() => {
843
+ disposed = true
844
+ clearInterval(clock)
845
+ clearInterval(tokenTimer)
846
+ unsubPart()
847
+ unsubMsg()
848
+ unsubIdle()
849
+ unsubError()
850
+ })
851
+ })
852
+
853
+ // ── session‑switch & initial‑load scan ──
854
+ // On session change: load from kv (entries survive component unmount), then scan+merge.
855
+ // On same session: only scan+merge (keep event‑driven running entries).
856
+ let lastSid = props.sessionId
857
+ createEffect(() => {
858
+ const sid = props.sessionId
859
+ const switched = sid !== lastSid
860
+ lastSid = sid
861
+ const t = setTimeout(() => {
862
+ untrack(() => {
863
+ if (switched) {
864
+ const saved = loadSessionData()[sid]?.scroll ?? 0
865
+ setScrollOffset(saved)
866
+ }
867
+ // scan uses setEntryMapRaw ephemeral data, not persisted to kv.
868
+ // Only event-driven changes (handlePartUpdated, handleSessionEnd) persist.
869
+ setEntryMapRaw((prev) => {
870
+ const next = switched ? loadEntries(sid) : new Map(prev)
871
+ try {
872
+ const msgs = props.api.state.session.messages(sid)
873
+ if (msgs && (msgs as any[]).length) {
874
+ for (const msg of msgs) {
875
+ const parts = props.api.state.part(msg.id) ?? []
876
+ for (const partRaw of parts) {
877
+ const part = partRaw as Record<string, unknown>
878
+
879
+ // Subtask entries are purely event-driven — never created by scan.
880
+ // (SubtaskPart exists from spawn, not completion, so we cannot infer status.)
881
+ if (part.type === "tool") {
882
+ const tool = String((part as any).tool ?? "")
883
+ if (!SUBAGENT_TOOLS.has(tool)) continue
884
+ const id = `tool:${String(part.id ?? "")}`
885
+ if (!part.id) continue
886
+
887
+ const st = (part as any).state as Record<string, unknown> | undefined
888
+ const rawStatus = String(st?.status ?? "")
889
+ const exists = next.get(id)
890
+
891
+ // Only create entries for tool calls that entered execution.
892
+ // "pending" / empty: skip new entries; allow heuristics for existing ones below.
893
+ if ((rawStatus === "pending" || rawStatus === "") && !exists) continue
894
+
895
+ // "error": only update existing, never create a new entry
896
+ if (rawStatus === "error") {
897
+ if (exists && exists.status === "running") {
898
+ next.set(id, { ...exists, status: "error", endedAt: Date.now() })
899
+ }
900
+ continue
901
+ }
902
+
903
+ let status: SubStatus = "running"
904
+ if (rawStatus === "completed") status = "done"
905
+ // Background tasks: tool completion agent completion keep running until session.idle
906
+ // Only keep running if state metadata confirms a child session was spawned.
907
+ if ((st?.input as Record<string, unknown> | undefined)?.run_in_background === true && status === "done") {
908
+ const scanStMeta = st?.metadata as Record<string, unknown> | undefined
909
+ const scanHasChild = scanStMeta?.session_id !== undefined || scanStMeta?.sessionId !== undefined
910
+ if (scanHasChild) status = "running"
911
+ }
912
+
913
+ // Already settled → skip
914
+ if (exists && exists.status !== "running") continue
915
+ // Running entry with no explicit status improvement from part:
916
+ // try message-level heuristics first, then time-based fallback.
917
+ if (exists && status === "running") {
918
+ if (!rawStatus) {
919
+ const msgTokens = (msg as any)?.tokens as Record<string, unknown> | undefined
920
+ if (msgTokens && (Number(msgTokens.input) > 0 || Number(msgTokens.output) > 0)) {
921
+ status = "done" // LLM returned tokens agent completed
922
+ } else if (Date.now() - exists.startedAt > 30 * 60 * 1000) {
923
+ status = "done" // >30 min idle assume completed
924
+ } else {
925
+ continue
926
+ }
927
+ } else {
928
+ continue
929
+ }
930
+ }
931
+
932
+ // If already tracked as running but tool state says completed/error → update
933
+ // If not tracked → add fresh
934
+
935
+ const input = st?.input as Record<string, unknown> | undefined
936
+ const agent = String((part as any).subagent_type ?? input?.subagent_type ?? tool)
937
+ const prompt = String(input?.prompt ?? (part as any).description ?? "")
938
+ const desc = input?.description !== undefined ? String(input.description) : ""
939
+ const title = desc || truncate(prompt.replace(/\n/g, " ").trim(), 40)
940
+
941
+ let tokens: number | undefined
942
+ const scanStMeta2 = st?.metadata as Record<string, unknown> | undefined
943
+ const scanSubSid = scanStMeta2?.session_id !== undefined ? String(scanStMeta2.session_id)
944
+ : scanStMeta2?.sessionId !== undefined ? String(scanStMeta2.sessionId)
945
+ : undefined
946
+ if (scanSubSid) tokens = readSessionTokens(scanSubSid)
947
+
948
+ const ended = status === "done" // "error" handled above, never reaches here
949
+ next.set(id, {
950
+ id, title, agent, prompt,
951
+ // Preserve existing values (from handleSessionEnd / KV) scan must not overwrite
952
+ tokens: exists?.tokens ?? tokens,
953
+ sessionId: exists?.sessionId ?? scanSubSid,
954
+ status,
955
+ startedAt: exists?.startedAt || Date.now(),
956
+ endedAt: ended ? (exists?.endedAt || Date.now()) : undefined,
957
+ })
958
+ }
959
+ }
960
+ }
961
+ }
962
+ } catch {}
963
+ return next
964
+ })
965
+ // Reconcile: check running entries against live child session status.
966
+ // Covers session.idle events missed while user was inside a child session.
967
+ setEntryMapRaw((prev) => {
968
+ let changed = false
969
+ const next = new Map(prev)
970
+ for (const [id, entry] of next) {
971
+ if (entry.status !== "running" || !entry.sessionId) continue
972
+ try {
973
+ const st = props.api.state.session.status(entry.sessionId)
974
+ if (!st || st.type !== "idle") continue
975
+ const tokens = readSessionTokens(entry.sessionId)
976
+ const cost = readSessionCost(entry.sessionId)
977
+ next.set(id, {
978
+ ...entry, status: "done" as SubStatus, endedAt: Date.now(),
979
+ tokens: tokens ?? entry.tokens,
980
+ cost: cost ?? entry.cost,
981
+ })
982
+ changed = true
983
+ } catch {}
984
+ }
985
+ return changed ? next : prev
986
+ })
987
+ bump()
988
+ })
989
+ }, 150)
990
+ onCleanup(() => clearTimeout(t))
991
+ })
992
+
993
+ // ── palette ──
994
+ const pal = createMemo(() => {
995
+ const th = props.theme as Record<string, unknown>
996
+ const sat = (k: string, fb: string) => desaturateTo(th[k], MAX_SAT, fb)
997
+ return {
998
+ primary: sat("primary", FALLBACK.primary),
999
+ text: sat("text", FALLBACK.text),
1000
+ muted: sat("textMuted", FALLBACK.muted),
1001
+ success: sat("success", FALLBACK.success),
1002
+ warning: sat("warning", FALLBACK.warning),
1003
+ error: sat("error", FALLBACK.error),
1004
+ border: sat("border", FALLBACK.border),
1005
+ }
1006
+ })
1007
+
1008
+ // ── derived signals ──
1009
+ // Stable list — only changes when entryMap changes
1010
+ const entryList = createMemo(() => {
1011
+ return [...entryMap().values()].sort((a, b) => b.startedAt - a.startedAt)
1012
+ })
1013
+
1014
+ const max = props.maxEntries
1015
+ const clampedOffset = createMemo(() => {
1016
+ const total = entryList().length
1017
+ const m = max()
1018
+ if (total <= m) return 0
1019
+ return Math.min(scrollOffset(), total - m)
1020
+ })
1021
+ const visibleList = createMemo(() => entryList().slice(clampedOffset(), clampedOffset() + max()))
1022
+ const hiddenAbove = createMemo(() => clampedOffset())
1023
+ const hiddenBelow = createMemo(() => Math.max(0, entryList().length - clampedOffset() - max()))
1024
+
1025
+ const entries = createMemo(() => {
1026
+ const nowVal = now()
1027
+ return entryList().map((e) => ({
1028
+ ...e,
1029
+ elapsed: (e.endedAt ?? nowVal) - e.startedAt,
1030
+ }))
1031
+ })
1032
+
1033
+ const doneCount = createMemo(() => entryList().filter((e) => e.status === "done").length)
1034
+ const runningCount = createMemo(() => entryList().filter((e) => e.status === "running").length)
1035
+ const errCount = createMemo(() => entryList().filter((e) => e.status === "error").length)
1036
+ const anyEntry = () => entryList().length > 0
1037
+
1038
+ const totalTokens = createMemo(() => {
1039
+ let sum = 0
1040
+ for (const e of entryList()) { if (e.tokens) sum += e.tokens }
1041
+ return sum
1042
+ })
1043
+
1044
+ const totalCost = createMemo(() => {
1045
+ let sum = 0
1046
+ for (const e of entryList()) { if (e.cost) sum += e.cost }
1047
+ return sum
1048
+ })
1049
+
1050
+ const toggleExpand = (id: string) => {
1051
+ setExpanded((prev) => {
1052
+ const next = prev === id ? undefined : id
1053
+ try { persistExpanded(props.sessionId, next ?? "") } catch {}
1054
+ return next
1055
+ })
1056
+ }
1057
+
1058
+ const sep = () => "\u2500".repeat(Math.max(1, panelWidth()))
1059
+
1060
+ // ── expanded detail right-align ──
1061
+ const expandedMaxLabelW = createMemo(() => {
1062
+ const labels = [
1063
+ t("agent.label"), t("time.label"), t("tokens.label"),
1064
+ t("error.label"), t("cost.label"), t("model.label"), t("todo.label"),
1065
+ ]
1066
+ return Math.max(...labels.map(l => visualWidth(l + ": ")))
1067
+ })
1068
+
1069
+ const expandedPad = (label: string) => Math.max(0, expandedMaxLabelW() - visualWidth(label + ": "))
1070
+
1071
+ const expandedValAvail = () => Math.max(6, panelWidth() - INDENT - expandedMaxLabelW())
1072
+
1073
+ // ── header parts for colored spans ──
1074
+ const summaryParts = createMemo(() => {
1075
+ if (!anyEntry()) return null
1076
+ const dot = "\u25cf"
1077
+ const cost = totalCost()
1078
+ return {
1079
+ done: `${dot}${doneCount()}`,
1080
+ running: runningCount() > 0 ? `${dot}${runningCount()}` : null,
1081
+ err: errCount() > 0 ? `${dot}${errCount()}` : null,
1082
+ duration: totalTokens() > 0 ? fmtTokens(totalTokens()) : "",
1083
+ cost: cost > 0 ? `$${cost.toFixed(2)}` : "",
1084
+ }
1085
+ })
1086
+
1087
+ const summaryCols = createMemo(() => {
1088
+ const p = summaryParts()
1089
+ if (!p) return 0
1090
+ let w = visualWidth(p.done)
1091
+ if (p.running) w += 1 + visualWidth(p.running)
1092
+ if (p.err) w += 1 + visualWidth(p.err)
1093
+ w += p.duration ? 1 + visualWidth(p.duration) : 0
1094
+ w += p.cost ? 1 + visualWidth(p.cost) : 0
1095
+ return w
1096
+ })
1097
+
1098
+ const versionText = ` v${PLUGIN_VERSION}`
1099
+ const versionW = visualWidth(versionText)
1100
+
1101
+ const showVersion = createMemo(() => {
1102
+ if (!open()) return false
1103
+ const icon = "\u25bc"
1104
+ const need = visualWidth(icon) + 1 + visualWidth(t("panel.title")) + versionW + summaryCols()
1105
+ return need <= panelWidth()
1106
+ })
1107
+
1108
+ const leftCols = createMemo(() => {
1109
+ const icon = open() ? "\u25bc" : "\u25b6"
1110
+ let w = visualWidth(icon) + 1 + visualWidth(t("panel.title"))
1111
+ if (showVersion()) w += versionW
1112
+ return w
1113
+ })
1114
+
1115
+ const spacerCols = createMemo(() => {
1116
+ if (!anyEntry()) return 0
1117
+ return Math.max(0, panelWidth() - leftCols() - summaryCols())
1118
+ })
1119
+
1120
+ const valueCols = (label: string) =>
1121
+ Math.max(4, panelWidth() - INDENT - visualWidth(label + ": "))
1122
+
1123
+ // ── render ──
1124
+ return (
1125
+ <box
1126
+ border={false}
1127
+ paddingTop={0} paddingBottom={0} paddingLeft={0} paddingRight={0}
1128
+ flexDirection="column" gap={0}
1129
+ ref={boxEl}
1130
+ onSizeChange={() => {
1131
+ const w = boxEl ? Math.max(20, boxEl.width ?? 0) : 28
1132
+ setPanelWidth((prev) => (prev === w ? prev : w))
1133
+ }}
1134
+ >
1135
+ {/* ── header: same pattern as visual-cache's fold toggle ── */}
1136
+ {/* renderTick in span forces the text element to re-evaluate */}
1137
+ <text
1138
+ onMouseUp={() => {
1139
+ setOpen((o) => {
1140
+ const n = !o
1141
+ try { props.api.kv.set(`${KV_PREFIX}.open`, n) } catch {}
1142
+ return n
1143
+ })
1144
+ bump()
1145
+ }}
1146
+ >
1147
+ <span style={{ fg: pal().muted }}>{renderTick() >= 0 && open() ? "\u25bc " : "\u25b6 "}</span>
1148
+ <span style={{ fg: pal().primary }}>{t("panel.title")}</span>
1149
+ <Show when={showVersion()}><span style={{ fg: dimColor(pal().muted, 0.75) }}>{versionText}</span></Show>
1150
+ {anyEntry() ? (
1151
+ <>
1152
+ <span style={{ fg: pal().muted }}>{" ".repeat(spacerCols())}</span>
1153
+ <span style={{ fg: pal().success }}>{summaryParts()!.done}</span>
1154
+ {runningCount() > 0 && (
1155
+ <span style={{ fg: pal().warning }}> {summaryParts()!.running}</span>
1156
+ )}
1157
+ {errCount() > 0 && (
1158
+ <span style={{ fg: pal().error }}> {summaryParts()!.err}</span>
1159
+ )}
1160
+ {summaryParts()!.duration ? (
1161
+ <span style={{ fg: pal().muted }}> {summaryParts()!.duration}</span>
1162
+ ) : null}
1163
+ {summaryParts()!.cost ? (
1164
+ <span style={{ fg: pal().warning }}> {summaryParts()!.cost}</span>
1165
+ ) : null}
1166
+ </>
1167
+ ) : null}
1168
+ </text>
1169
+
1170
+ {/* ── panel body ── */}
1171
+ <Show when={open()}>
1172
+ <text fg={pal().muted}>{sep()}</text>
1173
+
1174
+ <Show
1175
+ when={anyEntry()}
1176
+ fallback={
1177
+ <text style={{ fg: pal().muted }}>
1178
+ {" "}&gt; {t("status.none")} {/* empty indent kept for visual balance */}
1179
+ </text>
1180
+ }
1181
+ >
1182
+ <box
1183
+ onMouseScroll={(e) => {
1184
+ const total = entryList().length
1185
+ const m = max()
1186
+ if (total <= m) return
1187
+ const dir = e.button === 0 ? 1 : -1
1188
+ setScrollOffset((prev) => {
1189
+ const next = Math.max(0, Math.min(prev + dir, total - m))
1190
+ try { persistScroll(props.sessionId, next) } catch {}
1191
+ return next
1192
+ })
1193
+ }}
1194
+ >
1195
+ <Show when={hiddenAbove() > 0}>
1196
+ <text style={{ fg: pal().muted }}>
1197
+ {" "}&uarr; {hiddenAbove()} {t("scroll.more")}
1198
+ </text>
1199
+ </Show>
1200
+ <For each={visibleList()}>
1201
+ {(entry) => {
1202
+ const isExpanded = () => expanded() === entry.id
1203
+ const isRunning = entry.status === "running"
1204
+ const isError = entry.status === "error"
1205
+ const elapsed = () => (entry.endedAt ?? now()) - entry.startedAt
1206
+
1207
+ const statusDot = () => "\u25cf"
1208
+ const statusColor = () => {
1209
+ if (!isRunning) return isError ? pal().error : pal().success
1210
+ const t = (Math.sin(((now() % 2000) / 2000) * Math.PI * 2 - Math.PI / 2) + 1) / 2
1211
+ const a = rgb(pal().muted), b = rgb(pal().warning)
1212
+ if (!a || !b) return pal().warning
1213
+ const r = Math.round(a.r + (b.r - a.r) * t)
1214
+ const g = Math.round(a.g + (b.g - a.g) * t)
1215
+ const bl = Math.round(a.b + (b.b - a.b) * t)
1216
+ return "#" + [r, g, bl].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
1217
+ }
1218
+
1219
+ const timeColor = () =>
1220
+ isRunning ? pal().warning : isError ? pal().error : pal().muted
1221
+
1222
+ // Entry label: collapsed shows title only, expanded shows title only too
1223
+ const tokenText = () =>
1224
+ !isExpanded() && entry.tokens !== undefined && entry.tokens > 0
1225
+ ? ` ${fmtTokens(entry.tokens!)}`
1226
+ : ""
1227
+ const timeText = () =>
1228
+ !isExpanded() && (elapsed() >= 2000 || entry.endedAt !== undefined)
1229
+ ? fmtDurationShort(elapsed(), isRunning)
1230
+ : ""
1231
+ const suffixW = () => {
1232
+ let w = 0
1233
+ const t = timeText()
1234
+ if (t) w += 1 + visualWidth(t)
1235
+ const tk = tokenText()
1236
+ if (tk) w += visualWidth(tk)
1237
+ return w
1238
+ }
1239
+ const labelAvail = () => Math.max(6, panelWidth() - LEFT_PAD - suffixW())
1240
+ const labelText = () => {
1241
+ const max = labelAvail()
1242
+ const text = entry.title || entry.agent
1243
+ const truncated = truncate(text, max)
1244
+ const pad = Math.max(0, max - visualWidth(truncated))
1245
+ return truncated + " ".repeat(pad)
1246
+ }
1247
+
1248
+ return (
1249
+ <>
1250
+ {/* entry line — left-aligned */}
1251
+ <text onMouseUp={() => toggleExpand(entry.id)}>
1252
+ <span style={{ fg: pal().muted }}>
1253
+ {isExpanded() ? "\u25bc" : "\u25b6"}
1254
+ </span>
1255
+ {" "}
1256
+ <span style={{ fg: statusColor() }}>{statusDot()}</span>
1257
+ {" "}
1258
+ <span style={{ fg: pal().text }}>{labelText()}</span>
1259
+ {timeText() ? (
1260
+ <>
1261
+ {" "}
1262
+ <span style={{ fg: timeColor() }}>{timeText()}</span>
1263
+ </>
1264
+ ) : null}
1265
+ {tokenText() ? (
1266
+ <span style={{ fg: pal().muted }}>{tokenText()}</span>
1267
+ ) : null}
1268
+ </text>
1269
+
1270
+ {/* expanded detail — right-aligned values */}
1271
+ <Show when={isExpanded()}>
1272
+ <text>
1273
+ {" "}
1274
+ <span style={{ fg: pal().primary }}>{t("agent.label")}: </span>
1275
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("agent.label")))}</span>
1276
+ <span style={{ fg: pal().muted }}>{entry.agent}</span>
1277
+ </text>
1278
+ <Show when={elapsed() >= 2000 || entry.endedAt !== undefined}>
1279
+ <text>
1280
+ {" "}
1281
+ <span style={{ fg: pal().primary }}>{t("time.label")}: </span>
1282
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("time.label")))}</span>
1283
+ <span style={{ fg: pal().muted }}>
1284
+ {fmtDurationShort(elapsed(), isRunning)}
1285
+ </span>
1286
+ </text>
1287
+ </Show>
1288
+ <Show when={entry.tokens !== undefined}>
1289
+ <text>
1290
+ {" "}
1291
+ <span style={{ fg: pal().primary }}>{t("tokens.label")}: </span>
1292
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("tokens.label")))}</span>
1293
+ <span style={{ fg: pal().muted }}>{fmtTokens(entry.tokens!)}</span>
1294
+ </text>
1295
+ </Show>
1296
+ <Show when={entry.error}>
1297
+ <text>
1298
+ {" "}
1299
+ <span style={{ fg: pal().error }}>{t("error.label")}: </span>
1300
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("error.label")))}</span>
1301
+ <span style={{ fg: pal().error }}>{truncate(String(entry.error), expandedValAvail())}</span>
1302
+ </text>
1303
+ </Show>
1304
+ <Show when={entry.cost !== undefined}>
1305
+ <text>
1306
+ {" "}
1307
+ <span style={{ fg: pal().primary }}>{t("cost.label")}: </span>
1308
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("cost.label")))}</span>
1309
+ <span style={{ fg: pal().muted }}>${entry.cost!.toFixed(4)}</span>
1310
+ </text>
1311
+ </Show>
1312
+ <Show when={entry.model}>
1313
+ <text>
1314
+ {" "}
1315
+ <span style={{ fg: pal().primary }}>{t("model.label")}: </span>
1316
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("model.label")))}</span>
1317
+ <span style={{ fg: pal().muted }}>{truncate(entry.model!, expandedValAvail())}</span>
1318
+ </text>
1319
+ </Show>
1320
+ <Show when={entry.todoTotal !== undefined}>
1321
+ <text>
1322
+ {" "}
1323
+ <span style={{ fg: pal().primary }}>{t("todo.label")}: </span>
1324
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("todo.label")))}</span>
1325
+ <span style={{ fg: pal().muted }}>{entry.todoDone}/{entry.todoTotal}</span>
1326
+ </text>
1327
+ </Show>
1328
+ <Show when={entry.sessionId}>
1329
+ <text
1330
+ onMouseOver={() => setHoveredOpen(entry.id)}
1331
+ onMouseOut={() => setHoveredOpen(undefined)}
1332
+ onMouseUp={() => {
1333
+ if (entry.sessionId) {
1334
+ props.api.route.navigate("session", { sessionID: entry.sessionId })
1335
+ }
1336
+ }}
1337
+ >
1338
+ {" "}
1339
+ <span style={{ fg: hoveredOpen() === entry.id ? pal().warning : pal().primary }}>{"\u2192 "}</span>
1340
+ <span style={{ fg: hoveredOpen() === entry.id ? pal().warning : pal().primary }}>{t("open.label")}</span>
1341
+ </text>
1342
+ </Show>
1343
+ </Show>
1344
+ </>
1345
+ )
1346
+ }}
1347
+ </For>
1348
+ <Show when={hiddenBelow() > 0 || scrollOffset() > 0}>
1349
+ {(() => {
1350
+ const showMore = hiddenBelow() > 0
1351
+ const showTop = scrollOffset() > 0
1352
+ const left = showMore ? ` \u2193 ${hiddenBelow()} ${t("scroll.more")}` : " "
1353
+ const right = `\u2191 ${t("scroll.top")}`
1354
+ const pad = showTop ? Math.max(1, panelWidth() - visualWidth(left) - visualWidth(right)) : 0
1355
+ return (
1356
+ <box flexDirection="row">
1357
+ <text style={{ fg: pal().muted }}>{left}</text>
1358
+ {showTop ? (
1359
+ <>
1360
+ <text style={{ fg: pal().muted }}>{" ".repeat(pad)}</text>
1361
+ <text
1362
+ onMouseOver={() => setHoveredTop(true)}
1363
+ onMouseOut={() => setHoveredTop(false)}
1364
+ onMouseUp={() => { setScrollOffset(0); setHoveredTop(false) }}
1365
+ >
1366
+ <span style={{ fg: hoveredTop() ? pal().warning : pal().muted }}>{right}</span>
1367
+ </text>
1368
+ </>
1369
+ ) : null}
1370
+ </box>
1371
+ )
1372
+ })()}
1373
+ </Show>
1374
+ </box>
1375
+ </Show>
1376
+ </Show>
1377
+ </box>
1378
+ )
1379
+ }
1380
+
1381
+ // ===================================================================
1382
+ // Plugin entry
1383
+ // ===================================================================
1384
+
1385
+ interface SharedSignals {
1386
+ lang: () => Lang
1387
+ setLang: (l: Lang) => void
1388
+ maxEntries: () => number
1389
+ setMaxEntries: (n: number) => void
1390
+ sessionId: string
1391
+ }
1392
+
1393
+ function createSidebarSlot(api: TuiPluginApi, sig: SharedSignals): TuiSlotPlugin {
1394
+ return {
1395
+ order: 60,
1396
+ slots: {
1397
+ sidebar_content(ctx: TuiSlotContext, input: { session_id: string }): JSX.Element {
1398
+ sig.sessionId = input.session_id
1399
+ return (
1400
+ <SubAgentPanel
1401
+ theme={ctx.theme.current}
1402
+ api={api}
1403
+ lang={sig.lang}
1404
+ maxEntries={sig.maxEntries}
1405
+ sessionId={input.session_id}
1406
+ />
1407
+ )
1408
+ },
1409
+ },
1410
+ }
1411
+ }
1412
+
1413
+ const KV_PREFIX = "subagent_magazine"
1414
+
1415
+ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1416
+ // ── language ──
1417
+ const stored = String(api.kv.get(`${KV_PREFIX}.lang`, ""))
1418
+ const initialLang: Lang =
1419
+ stored === "zh" || stored === "en" ? stored : detectLang()
1420
+ const [lang, setLang] = createSignal<Lang>(initialLang)
1421
+ const [maxEntries, setMaxEntries] = createSignal(
1422
+ parseInt(String(api.kv.get(`${KV_PREFIX}.max_entries`, "10")), 10) || 10
1423
+ )
1424
+
1425
+ const signals: SharedSignals = { lang, setLang, maxEntries, setMaxEntries, sessionId: "" }
1426
+
1427
+ api.slots.register(createSidebarSlot(api, signals))
1428
+
1429
+ // ── slash command: /subagent-lang ──
1430
+ api.command?.register(() => [
1431
+ {
1432
+ title: "SubAgent Magazine: Language",
1433
+ value: "subagent-lang",
1434
+ description: "Switch display language (中文 / English)",
1435
+ slash: { name: "subagent-lang" },
1436
+ onSelect: (dialog) => {
1437
+ dialog?.replace(() => (
1438
+ <api.ui.DialogSelect
1439
+ title="Language / 语言"
1440
+ options={[
1441
+ { title: "中文", value: "zh" },
1442
+ { title: "English", value: "en" },
1443
+ ]}
1444
+ onSelect={(opt) => {
1445
+ const l = opt.value as Lang
1446
+ setLang(l)
1447
+ api.kv.set(`${KV_PREFIX}.lang`, l)
1448
+ api.ui.toast({
1449
+ message: l === "zh" ? "语言: 中文" : "Language: English",
1450
+ })
1451
+ dialog?.clear()
1452
+ }}
1453
+ />
1454
+ ))
1455
+ },
1456
+ },
1457
+ {
1458
+ title: "SubAgent Magazine: Max Entries",
1459
+ value: "subagent-max",
1460
+ description: "Set max visible sub-agent entries in sidebar",
1461
+ slash: { name: "subagent-max" },
1462
+ onSelect: (dialog) => {
1463
+ dialog?.replace(() => (
1464
+ <api.ui.DialogPrompt
1465
+ title="Max Visible Entries"
1466
+ description={() => (
1467
+ <text>Number of entries to show in the sidebar (1–50)</text>
1468
+ )}
1469
+ value={String(maxEntries())}
1470
+ onConfirm={(val) => {
1471
+ const n = Math.max(1, Math.min(50, parseInt(val, 10) || 10))
1472
+ setMaxEntries(n)
1473
+ api.kv.set(`${KV_PREFIX}.max_entries`, n)
1474
+ api.ui.toast({ message: `Max entries: ${n}` })
1475
+ dialog?.clear()
1476
+ }}
1477
+ />
1478
+ ))
1479
+ },
1480
+ },
1481
+ {
1482
+ title: "SubAgent Magazine: Version",
1483
+ value: "subagent-version",
1484
+ description: "Show plugin version",
1485
+ slash: { name: "subagent-version" },
1486
+ onSelect: (dialog) => {
1487
+ api.ui.toast({ message: `opencode-subagent-magazine v${PLUGIN_VERSION}` })
1488
+ dialog?.clear()
1489
+ },
1490
+ },
1491
+ {
1492
+ title: "SubAgent Magazine: Session",
1493
+ value: "subagent-session",
1494
+ description: "Show current session ID",
1495
+ slash: { name: "subagent-session" },
1496
+ onSelect: (dialog) => {
1497
+ api.ui.toast({ message: `Session: ${signals.sessionId}` })
1498
+ dialog?.clear()
1499
+ },
1500
+ },
1501
+ ])
1502
+ }
1503
+
1504
+ const mod: TuiPluginModule & { id: string } = {
1505
+ id: "opencode-subagent-magazine",
1506
+ tui,
1507
+ }
1508
+
1509
+ export default mod