opencode-subagent-magazine 1.2.0 → 1.2.1-beta.0

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