opencode-subagent-magazine 1.1.0 → 1.1.2

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