opencode-subagent-magazine 1.5.2 → 1.6.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +192 -192
  3. package/dist/_version.d.ts +1 -1
  4. package/dist/_version.js +1 -1
  5. package/dist/core/color.d.ts +20 -0
  6. package/dist/core/color.js +68 -0
  7. package/dist/core/format.d.ts +5 -0
  8. package/dist/core/format.js +68 -0
  9. package/dist/core/index.d.ts +6 -0
  10. package/dist/core/index.js +6 -0
  11. package/dist/core/kv.d.ts +20 -0
  12. package/dist/core/kv.js +30 -0
  13. package/dist/core/state-machine.d.ts +17 -0
  14. package/dist/core/state-machine.js +27 -0
  15. package/dist/core/types.d.ts +51 -0
  16. package/dist/core/types.js +2 -0
  17. package/dist/core/usage.d.ts +15 -0
  18. package/dist/core/usage.js +1 -0
  19. package/dist/index.js +148 -1484
  20. package/dist/panel/SubAgentPanel.d.ts +13 -0
  21. package/dist/panel/SubAgentPanel.js +1237 -0
  22. package/dist/panel/panel-api.d.ts +67 -0
  23. package/dist/panel/panel-api.js +1 -0
  24. package/dist/panel/store.d.ts +5 -0
  25. package/dist/panel/store.js +5 -0
  26. package/dist/tui.js +388 -292
  27. package/dist/v2/commands.d.ts +7 -0
  28. package/dist/v2/commands.js +263 -0
  29. package/dist/v2/index.d.ts +8 -0
  30. package/dist/v2/index.js +62 -0
  31. package/dist/v2/theme.d.ts +3 -0
  32. package/dist/v2/theme.js +12 -0
  33. package/dist/v2/types.d.ts +231 -0
  34. package/dist/v2/types.js +6 -0
  35. package/dist/v2/v2-panel-api.d.ts +12 -0
  36. package/dist/v2/v2-panel-api.js +350 -0
  37. package/dist/v2.js +3008 -0
  38. package/install.mjs +103 -103
  39. package/package.json +64 -63
  40. package/src/_version.ts +1 -1
  41. package/src/clipboard.ts +134 -134
  42. package/src/core/color.ts +70 -0
  43. package/src/core/format.ts +61 -0
  44. package/src/core/index.ts +6 -0
  45. package/src/core/kv.ts +36 -0
  46. package/src/core/state-machine.ts +46 -0
  47. package/src/core/types.ts +58 -0
  48. package/src/core/usage.ts +16 -0
  49. package/src/i18n.ts +261 -261
  50. package/src/index.tsx +124 -1750
  51. package/src/panel/SubAgentPanel.tsx +1465 -0
  52. package/src/panel/panel-api.ts +72 -0
  53. package/src/panel/store.ts +8 -0
  54. package/src/server.ts +10 -10
  55. package/src/v2/commands.ts +251 -0
  56. package/src/v2/index.tsx +98 -0
  57. package/src/v2/theme.ts +14 -0
  58. package/src/v2/types.ts +165 -0
  59. package/src/v2/v2-panel-api.ts +306 -0
  60. package/tui/index.js +11 -0
@@ -0,0 +1,1465 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import type { JSX } from "@opentui/solid"
4
+ import {
5
+ createMemo,
6
+ createSignal,
7
+ createEffect,
8
+ onMount,
9
+ onCleanup,
10
+ untrack,
11
+ Show,
12
+ For,
13
+ } from "solid-js"
14
+ import { PLUGIN_VERSION } from "../_version"
15
+ import { copyText } from "../clipboard"
16
+ import { createT } from "../i18n"
17
+ import type { Lang, SortOrder, ScrollMode, SubEntry, SubStatus, SessionRecord } from "../core/types"
18
+ import { SUBAGENT_TOOLS } from "../core/types"
19
+ import { visualWidth, truncate, fmtDurationShort, fmtTokens, safeErrorMsg } from "../core/format"
20
+ import { rgb, desaturateTo, dimColor, FALLBACK, MAX_SAT } from "../core/color"
21
+ import { KV_PREFIX } from "../core/kv"
22
+ import type { PanelApi, PanelEvent } from "./panel-api"
23
+ import { globalEntryCache, clearTick } from "./store"
24
+
25
+ /** Entry line left prefix: icon + space + status dot + space */
26
+ const LEFT_PAD = 4
27
+ /** Detail row indent: two spaces */
28
+ const INDENT = 2
29
+ // ===================================================================
30
+ // Sidebar component
31
+ // ===================================================================
32
+
33
+ export function SubAgentPanel(props: {
34
+ api: PanelApi
35
+ theme: Record<string, unknown>
36
+ lang: () => Lang
37
+ maxEntries: () => number
38
+ sortOrder: () => SortOrder
39
+ scrollMode: () => ScrollMode
40
+ sessionId: string
41
+ }): JSX.Element {
42
+ const t = createT(() => props.lang())
43
+
44
+ // ── session data (single-key, true deletion on cleanup) ──
45
+ const SESSION_DATA_KEY = `${KV_PREFIX}.session_data`
46
+ const ttlDaysRaw = parseInt(String(props.api.kv.get(`${KV_PREFIX}.ttl_days`, "3")), 10)
47
+ const ttlDays = Number.isNaN(ttlDaysRaw) ? 3 : ttlDaysRaw
48
+ const TTL_MS = ttlDays * 24 * 60 * 60 * 1000
49
+
50
+ interface ChildRecord {
51
+ scroll: number
52
+ expanded: string
53
+ entries: SubEntry[]
54
+ clearedIds?: string[]
55
+ }
56
+
57
+ interface SessionRecord {
58
+ ts: number
59
+ entries: SubEntry[]
60
+ scroll: number
61
+ expanded: string
62
+ children: Record<string, ChildRecord>
63
+ clearedIds?: string[]
64
+ }
65
+
66
+ const loadSessionData = (): Record<string, SessionRecord> => {
67
+ try {
68
+ const raw = props.api.kv.get(SESSION_DATA_KEY, "{}")
69
+ return JSON.parse(String(raw))
70
+ } catch { return {} }
71
+ }
72
+
73
+ const saveSessionData = (data: Record<string, SessionRecord>) => {
74
+ try { props.api.kv.set(SESSION_DATA_KEY, JSON.stringify(data)) } catch {}
75
+ }
76
+
77
+ /** 将任意 session ID 解析为父会话 ID + 是否子会话。
78
+ * 通过 SDK session.get(sid).parentID 判断,无 parentID 即为主会话。 */
79
+ const resolveParent = (sid: string): { parentSid: string; isChild: boolean } => {
80
+ try {
81
+ const session = props.api.session.get(sid)
82
+ const parentID = (session as any)?.parentID as string | undefined
83
+ if (parentID) return { parentSid: parentID, isChild: true }
84
+ } catch {}
85
+ return { parentSid: sid, isChild: false }
86
+ }
87
+
88
+ const loadEntries = (sid: string): Map<string, SubEntry> => {
89
+ const m = new Map<string, SubEntry>()
90
+ try {
91
+ const { parentSid, isChild } = resolveParent(sid)
92
+ const rec = loadSessionData()[parentSid]
93
+ if (rec) {
94
+ const source = isChild ? rec.children?.[sid]?.entries : rec.entries
95
+ if (source) {
96
+ for (const e of source) m.set(e.id, e)
97
+ }
98
+ }
99
+ } catch {}
100
+ return m
101
+ }
102
+
103
+ let persistTimer: ReturnType<typeof setTimeout> | undefined
104
+ const persistEntries = (sid: string, entries: Map<string, SubEntry>) => {
105
+ clearTimeout(persistTimer)
106
+ persistTimer = setTimeout(() => {
107
+ try {
108
+ const data = loadSessionData()
109
+ const { parentSid, isChild } = resolveParent(sid)
110
+ if (isChild) {
111
+ if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
112
+ if (!data[parentSid].children) data[parentSid].children = {}
113
+ if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] }
114
+ data[parentSid].children[sid] = { ...data[parentSid].children[sid], entries: [...entries.values()] }
115
+ } else {
116
+ data[sid] = { ...data[sid], ts: Date.now(), entries: [...entries.values()], children: data[sid]?.children ?? {} }
117
+ }
118
+ saveSessionData(data)
119
+ } catch {}
120
+ }, 200)
121
+ }
122
+
123
+ const persistScroll = (sid: string, scroll: number) => {
124
+ try {
125
+ const data = loadSessionData()
126
+ const { parentSid, isChild } = resolveParent(sid)
127
+ if (isChild) {
128
+ if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
129
+ if (!data[parentSid].children) data[parentSid].children = {}
130
+ if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] }
131
+ data[parentSid].children[sid] = { ...data[parentSid].children[sid], scroll }
132
+ } else {
133
+ data[sid] = { ...data[sid], ts: Date.now(), scroll, children: data[sid]?.children ?? {} }
134
+ }
135
+ saveSessionData(data)
136
+ } catch {}
137
+ }
138
+
139
+ const persistExpanded = (sid: string, expanded: string) => {
140
+ try {
141
+ const data = loadSessionData()
142
+ const { parentSid, isChild } = resolveParent(sid)
143
+ if (isChild) {
144
+ if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
145
+ if (!data[parentSid].children) data[parentSid].children = {}
146
+ if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] }
147
+ data[parentSid].children[sid] = { ...data[parentSid].children[sid], expanded }
148
+ } else {
149
+ data[sid] = { ...data[sid], ts: Date.now(), expanded, children: data[sid]?.children ?? {} }
150
+ }
151
+ saveSessionData(data)
152
+ } catch {}
153
+ }
154
+
155
+ const cleanupOldSessions = () => {
156
+ if (ttlDays <= 0) return // 无期限,跳过清理
157
+ try {
158
+ const data = loadSessionData()
159
+ const cutoff = Date.now() - TTL_MS
160
+ let changed = false
161
+ for (const sid of Object.keys(data)) {
162
+ if (data[sid].ts < cutoff) {
163
+ delete data[sid]
164
+ changed = true
165
+ }
166
+ }
167
+ if (changed) saveSessionData(data)
168
+ } catch {}
169
+ }
170
+
171
+ cleanupOldSessions()
172
+
173
+ const [entryMap, setEntryMapRaw] = createSignal(loadEntries(props.sessionId))
174
+
175
+ // Wrapped setter — also persists to kv on every mutation
176
+ const setEntryMap = (
177
+ arg: Map<string, SubEntry> | ((prev: Map<string, SubEntry>) => Map<string, SubEntry>),
178
+ ) => {
179
+ setEntryMapRaw((prev) => {
180
+ const next = typeof arg === "function" ? (arg as Function)(prev) : arg
181
+
182
+ // entry 状态落定(done/error)时立即持久化到 KV,跳过常规 debounce,
183
+ // 确保跨视图的状态一致性。
184
+ let needsImmediateFlush = false
185
+ for (const [id, entry] of next) {
186
+ const prevEntry = prev.get(id)
187
+ if (prevEntry?.status === "running" && (entry.status === "done" || entry.status === "error" || entry.status === "cancelled")) {
188
+ needsImmediateFlush = true
189
+ break
190
+ }
191
+ }
192
+
193
+ if (needsImmediateFlush) {
194
+ clearTimeout(persistTimer)
195
+ try {
196
+ const data = loadSessionData()
197
+ const { parentSid, isChild } = resolveParent(props.sessionId)
198
+ if (isChild) {
199
+ if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} }
200
+ if (!data[parentSid].children) data[parentSid].children = {}
201
+ if (!data[parentSid].children[props.sessionId]) data[parentSid].children[props.sessionId] = { scroll: 0, expanded: "", entries: [] }
202
+ data[parentSid].children[props.sessionId] = { ...data[parentSid].children[props.sessionId], entries: [...next.values()] }
203
+ } else {
204
+ data[props.sessionId] = { ...data[props.sessionId], ts: Date.now(), entries: [...next.values()], children: data[props.sessionId]?.children ?? {} }
205
+ }
206
+ saveSessionData(data)
207
+ } catch {}
208
+ } else {
209
+ persistEntries(props.sessionId, next)
210
+ }
211
+
212
+ // 同步到模块级缓存,供其他视图读取当前 session 的最新状态
213
+ globalEntryCache.set(props.sessionId, new Map(next))
214
+
215
+ return next
216
+ })
217
+ }
218
+
219
+ const [panelWidth, setPanelWidth] = createSignal(28)
220
+ const [open, setOpen] = createSignal(
221
+ (() => { try { return props.api.kv.get(`${KV_PREFIX}.open`, true) as boolean } catch { return true } })()
222
+ )
223
+ const [expanded, setExpanded] = createSignal<string | undefined>(
224
+ (() => {
225
+ try {
226
+ const { parentSid, isChild } = resolveParent(props.sessionId)
227
+ const rec = loadSessionData()[parentSid]
228
+ if (rec) return isChild ? rec.children?.[props.sessionId]?.expanded || undefined : rec.expanded || undefined
229
+ } catch {}
230
+ return undefined
231
+ })(),
232
+ )
233
+ const [hoveredOpen, setHoveredOpen] = createSignal<string | undefined>(undefined)
234
+ const [hoveredDismiss, setHoveredDismiss] = createSignal<string | undefined>(undefined)
235
+ const [hoveredCancel, setHoveredCancel] = createSignal<string | undefined>(undefined)
236
+ const [hoveredTop, setHoveredTop] = createSignal(false)
237
+ const [hoveredMoreAbove, setHoveredMoreAbove] = createSignal(false)
238
+ const [hoveredMoreBelow, setHoveredMoreBelow] = createSignal(false)
239
+ const [scrollOffset, setScrollOffset] = createSignal(
240
+ (() => {
241
+ try {
242
+ const { parentSid, isChild } = resolveParent(props.sessionId)
243
+ const rec = loadSessionData()[parentSid]
244
+ return isChild ? rec?.children?.[props.sessionId]?.scroll ?? 0 : rec?.scroll ?? 0
245
+ } catch { return 0 }
246
+ })(),
247
+ )
248
+ const [now, setNow] = createSignal(Date.now())
249
+ const [renderTick, setRenderTick] = createSignal(0)
250
+
251
+ let boxEl: any
252
+ let disposed = false
253
+
254
+ // ── upsert ──
255
+ const upsertEntry = (
256
+ partial: Omit<SubEntry, "startedAt" | "endedAt"> & { startedAt?: number }
257
+ ) => {
258
+ setEntryMap((prev) => {
259
+ const existing = prev.get(partial.id)
260
+ const next = new Map(prev)
261
+ const nowTs = Date.now()
262
+ const e = partial.status
263
+ const ended = e === "done" || e === "error" || e === "cancelled"
264
+ next.set(partial.id, {
265
+ ...(existing ?? { startedAt: nowTs }),
266
+ ...partial,
267
+ startedAt: existing?.startedAt || partial.startedAt || nowTs,
268
+ endedAt: ended ? (existing?.endedAt || nowTs) : undefined,
269
+ })
270
+ return next
271
+ })
272
+ }
273
+
274
+ // ── cancel helpers ──
275
+ const isDescendantOf = (childId: string, rootId: string): boolean => {
276
+ const visited = new Set<string>()
277
+ try {
278
+ let current = props.api.session.get(childId) as any
279
+ while (current?.parentID) {
280
+ if (visited.has(current.id)) return false
281
+ visited.add(current.id)
282
+ if (current.parentID === rootId) return true
283
+ current = props.api.session.get(current.parentID) as any
284
+ }
285
+ } catch {}
286
+ return false
287
+ }
288
+
289
+ const settleOnIdle = (entry: SubEntry): SubStatus => {
290
+ if (entry.status === "cancel_requested" && entry.abortAccepted) return "cancelled"
291
+ return "done"
292
+ }
293
+
294
+ const cancelEntry = async (entry: SubEntry) => {
295
+ const childId = entry.sessionId
296
+ if (!childId) {
297
+ props.api.ui.toast(t("cancel.label") + ": " + t("cancel.no_session"), { title: entry.title || entry.agent })
298
+ return
299
+ }
300
+
301
+ try {
302
+ const child = props.api.session.get(childId) as any
303
+ if (!child?.parentID) {
304
+ props.api.ui.toast(t("cancel.label") + ": " + t("cancel.not_child"), { title: entry.title || entry.agent })
305
+ return
306
+ }
307
+ } catch {
308
+ props.api.ui.toast(t("cancel.label") + ": " + t("cancel.read_error"), { title: entry.title || entry.agent })
309
+ return
310
+ }
311
+
312
+ if (!isDescendantOf(childId, props.sessionId)) {
313
+ props.api.ui.toast(t("cancel.label") + ": " + t("cancel.outside_tree"), { title: entry.title || entry.agent })
314
+ return
315
+ }
316
+
317
+ try {
318
+ const st = props.api.session.status(childId)
319
+ if (st?.type !== "busy") {
320
+ const tokens = props.api.usage.readSessionTokens(childId)
321
+ const cost = props.api.usage.readSessionCost(childId)
322
+ upsertEntry({
323
+ id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt,
324
+ status: "done", sessionId: entry.sessionId,
325
+ tokens, cost,
326
+ })
327
+ return
328
+ }
329
+ } catch {
330
+ props.api.ui.toast(t("cancel.label") + ": " + t("cancel.status_error"), { title: entry.title || entry.agent })
331
+ return
332
+ }
333
+
334
+ upsertEntry({
335
+ id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt,
336
+ status: "cancel_requested", sessionId: entry.sessionId,
337
+ cancelRequestedAt: Date.now(), abortAccepted: false, cancelReason: "manual",
338
+ } as any)
339
+
340
+ try {
341
+ await props.api.client.abort({ sessionID: childId })
342
+ upsertEntry({
343
+ id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt,
344
+ status: "cancel_requested", sessionId: entry.sessionId,
345
+ abortAccepted: true,
346
+ } as any)
347
+ props.api.ui.toast(t("cancel.label") + ": " + t("cancel.sent"))
348
+ } catch (err) {
349
+ upsertEntry({
350
+ id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt,
351
+ status: "error", sessionId: entry.sessionId,
352
+ error: String(err),
353
+ })
354
+ props.api.ui.toast(t("cancel.label") + ": " + t("cancel.failed"), { title: entry.title || entry.agent })
355
+ }
356
+ }
357
+
358
+ // ── event handlers ──
359
+ const handlePartUpdated = (event: PanelEvent) => {
360
+ const part = event.payload?.part as Record<string, unknown> | undefined
361
+ if (!part) return
362
+
363
+ // V2 事件流是全局的——若 payload 带发起会话 ID,则只归账到正在查看的会话,
364
+ // 避免其他会话的子代理写进当前侧边栏;V1 宿主已按会话 scope,不传 sessionID。
365
+ const eventSid = event.payload?.sessionID !== undefined ? String(event.payload.sessionID) : undefined
366
+ if (eventSid !== undefined && eventSid !== props.sessionId) return
367
+
368
+ // SubtaskPart
369
+ if (part.type === "subtask") {
370
+ const agent = String(part.agent ?? "?")
371
+ const prompt = String(part.prompt ?? "")
372
+ const desc = String(part.description ?? "")
373
+ const title = desc || truncate(prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim(), 40)
374
+
375
+ const id = `sub:${String(part.id ?? crypto.randomUUID())}`
376
+ const subSid = part.sessionID !== undefined ? String(part.sessionID) : undefined
377
+ const partModel = part.model as { modelID?: string } | undefined
378
+ const modelId = partModel?.modelID ? String(partModel.modelID) : undefined
379
+ upsertEntry({ id, title, agent, prompt, sessionId: subSid, status: "running", model: modelId })
380
+ }
381
+
382
+ // ToolPart
383
+ if (part.type === "tool") {
384
+ const tool = String(part.tool ?? "")
385
+ if (!SUBAGENT_TOOLS.has(tool)) return
386
+ const st = part.state as Record<string, unknown> | undefined
387
+ const rawStatus = String(st?.status ?? "")
388
+
389
+ // Only create entries for tool calls that actually entered execution.
390
+ // "pending" / empty → state unknown yet, wait for next event
391
+ if (rawStatus === "pending" || rawStatus === "") return
392
+
393
+ // "error" → tool call failed, sub-agent never spawned.
394
+ // Only update an existing entry (e.g. previously running → now error),
395
+ // never create a new one.
396
+ if (rawStatus === "error") {
397
+ const id = `tool:${String(part.id ?? "")}`
398
+ if (!part.id) return
399
+ const existing = entryMap().get(id)
400
+ if (existing) {
401
+ upsertEntry({ id, title: existing.title, agent: existing.agent, prompt: existing.prompt, status: "error" })
402
+ }
403
+ return
404
+ }
405
+
406
+ // rawStatus is "running" or "completed" — tool entered execution, track it.
407
+ const input = st?.input as Record<string, unknown> | undefined
408
+ let status: SubStatus = "running"
409
+ if (rawStatus === "completed") status = "done"
410
+ // Background tasks: tool completion ≠ agent completion — keep running until session.idle
411
+ // Only keep running if state metadata confirms a child session was spawned;
412
+ // otherwise (failed spawn, invalid agent) mark as done so the entry isn't stuck forever.
413
+ if ((input?.run_in_background === true || input?.background === true) && status === "done") {
414
+ const stMetaCheck = st?.metadata as Record<string, unknown> | undefined
415
+ const hasChild = stMetaCheck?.session_id !== undefined || stMetaCheck?.sessionId !== undefined
416
+ if (hasChild) status = "running"
417
+ }
418
+
419
+ const agent = String((part as any).subagent_type ?? input?.agent ?? input?.subagent_type ?? input?.category ?? tool)
420
+ const prompt = String(input?.prompt ?? (part as any).description ?? "")
421
+ const desc = input?.description !== undefined ? String(input.description) : ""
422
+ const title = desc || truncate(prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim(), 40)
423
+
424
+ const id = `tool:${String(part.id ?? crypto.randomUUID())}`
425
+ // Child session ID lives in state-level metadata (ToolStateCompleted.metadata),
426
+ // injected by the tool executor. ToolPart.sessionID is the parent session.
427
+ const stMeta = st?.metadata as Record<string, unknown> | undefined
428
+ const subSid = stMeta?.session_id !== undefined ? String(stMeta.session_id)
429
+ : stMeta?.sessionId !== undefined ? String(stMeta.sessionId)
430
+ : undefined
431
+ upsertEntry({ id, title, agent, prompt, sessionId: subSid, status })
432
+ }
433
+ }
434
+
435
+ const handleSessionEnd = (event: PanelEvent, status: SubStatus) => {
436
+ const props_ = event.payload
437
+ const sid = String(props_?.sessionID ?? "")
438
+ if (!sid) return
439
+
440
+ const sessionTokens = props.api.usage.readSessionTokens(sid)
441
+ const sessionCost = props.api.usage.readSessionCost(sid)
442
+ const sessionModel = props.api.usage.readSessionModel(sid)
443
+ const sessionTodo = props.api.usage.readSessionTodo(sid)
444
+ let sessionAgent: string | undefined
445
+ let errorMsg: string | undefined
446
+ try {
447
+ const s = props.api.session.get(sid)
448
+ sessionAgent = s?.agent
449
+ if (status === "error") {
450
+ const evtErr = props_?.error as Record<string, unknown> | undefined
451
+ errorMsg = safeErrorMsg(evtErr) || safeErrorMsg(props_?.message)
452
+ if (!errorMsg) {
453
+ const msgs = props.api.session.messages(sid)
454
+ if (msgs) {
455
+ for (let i = (msgs as any[]).length - 1; i >= 0; i--) {
456
+ const m = (msgs as any[])[i]
457
+ if (m.role === "assistant" && m.error) {
458
+ errorMsg = safeErrorMsg(m.error)
459
+ break
460
+ }
461
+ }
462
+ }
463
+ }
464
+ }
465
+ } catch {}
466
+
467
+ // 在给定的 entries Map 中查找并更新匹配的子代理 entry。
468
+ // 返回 true 表示找到并更新了,false 表示未找到。
469
+ const tryMatchAndUpdate = (
470
+ entriesMap: Map<string, SubEntry>,
471
+ targetSid: string,
472
+ targetStatus: SubStatus,
473
+ nowTs: number,
474
+ ): boolean => {
475
+ // 精确匹配:sessionId 对得上 + 状态为 running / cancel_requested
476
+ for (const [, entry] of entriesMap) {
477
+ if (entry.sessionId === targetSid && (entry.status === "running" || entry.status === "cancel_requested")) {
478
+ const finalStatus = targetStatus === "error" ? "error" : settleOnIdle(entry)
479
+ entry.status = finalStatus
480
+ entry.endedAt = nowTs
481
+ entry.tokens = entry.tokens ?? sessionTokens
482
+ entry.cost = entry.cost ?? sessionCost
483
+ entry.model = entry.model ?? sessionModel
484
+ entry.todoTotal = entry.todoTotal ?? sessionTodo?.total
485
+ entry.todoDone = entry.todoDone ?? sessionTodo?.done
486
+ entry.error = errorMsg || entry.error
487
+ return true
488
+ }
489
+ }
490
+ // 回退:sessionId 未关联但 agent 名匹配 + 状态为 running / cancel_requested
491
+ if (sessionAgent) {
492
+ const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]/g, "")
493
+ const saNorm = normalize(sessionAgent)
494
+ let best: { entry: SubEntry; gap: number } | null = null
495
+ for (const [, entry] of entriesMap) {
496
+ if (entry.status !== "running" && entry.status !== "cancel_requested") continue
497
+ const eaNorm = normalize(entry.agent)
498
+ if (!eaNorm || !saNorm) continue
499
+ if (!eaNorm.includes(saNorm) && !saNorm.includes(eaNorm)) continue
500
+ const gap = nowTs - (entry.startedAt || 0)
501
+ if (!best || gap > best.gap) best = { entry, gap }
502
+ }
503
+ if (!best) {
504
+ for (const [, entry] of entriesMap) {
505
+ if (entry.status !== "running" && entry.status !== "cancel_requested") continue
506
+ if (entry.sessionId) continue
507
+ const gap = nowTs - (entry.startedAt || 0)
508
+ if (!best || gap > best.gap) best = { entry, gap }
509
+ }
510
+ }
511
+ if (best) {
512
+ const finalStatus = targetStatus === "error" ? "error" : settleOnIdle(best.entry)
513
+ best.entry.status = finalStatus
514
+ best.entry.endedAt = nowTs
515
+ best.entry.tokens = best.entry.tokens ?? sessionTokens
516
+ best.entry.cost = best.entry.cost ?? sessionCost
517
+ best.entry.model = best.entry.model ?? sessionModel
518
+ best.entry.todoTotal = best.entry.todoTotal ?? sessionTodo?.total
519
+ best.entry.todoDone = best.entry.todoDone ?? sessionTodo?.done
520
+ best.entry.sessionId = targetSid
521
+ best.entry.error = errorMsg || best.entry.error
522
+ return true
523
+ }
524
+ }
525
+ return false
526
+ }
527
+
528
+ setEntryMap((prev) => {
529
+ let changed = false
530
+ const next = new Map(prev)
531
+ for (const [id, entry] of next) {
532
+ if (entry.sessionId !== sid) continue
533
+ if (entry.status !== "running" && entry.status !== "done" && entry.status !== "cancel_requested") continue
534
+ // Skip parent session idle — subagent entries belong to child sessions only
535
+ if (sid === props.sessionId) continue
536
+ // For "done" entries (sync tasks completed before session.idle), only backfill tokens/cost
537
+ const alreadySettled = entry.status !== "running" && entry.status !== "cancel_requested"
538
+ const finalStatus = status === "error" ? "error" : settleOnIdle(entry)
539
+ next.set(id, {
540
+ ...entry,
541
+ ...(alreadySettled ? {} : { status: finalStatus, endedAt: Date.now() }),
542
+ tokens: entry.tokens ?? sessionTokens,
543
+ cost: entry.cost ?? sessionCost,
544
+ model: entry.model ?? sessionModel,
545
+ todoTotal: entry.todoTotal ?? sessionTodo?.total,
546
+ todoDone: entry.todoDone ?? sessionTodo?.done,
547
+ error: errorMsg || entry.error,
548
+ })
549
+ changed = true
550
+ }
551
+ if (!changed && sessionAgent) {
552
+ const nowTs = Date.now()
553
+ const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]/g, "")
554
+ const saNorm = normalize(sessionAgent)
555
+ let best: { id: string; gap: number } | null = null
556
+
557
+ // Phase 1: try matching by agent name(agent 名有交集)
558
+ for (const [id, entry] of next) {
559
+ if (entry.status !== "running" && entry.status !== "cancel_requested") continue
560
+ const eaNorm = normalize(entry.agent)
561
+ if (!eaNorm || !saNorm) continue
562
+ if (!eaNorm.includes(saNorm) && !saNorm.includes(eaNorm)) continue
563
+ const gap = nowTs - (entry.startedAt || 0)
564
+ if (!best || gap > best.gap) best = { id, gap }
565
+ }
566
+
567
+ // Phase 2: if agent name has no overlap (e.g. category calls: agent="deep" vs sessionAgent="Sisyphus-Junior"),
568
+ // fall back to time proximity for entries that have no sessionId yet
569
+ if (!best) {
570
+ for (const [id, entry] of next) {
571
+ if (entry.status !== "running" && entry.status !== "cancel_requested") continue
572
+ if (entry.sessionId) continue
573
+ const gap = nowTs - (entry.startedAt || 0)
574
+ if (!best || gap > best.gap) best = { id, gap }
575
+ }
576
+ }
577
+
578
+ if (best) {
579
+ const entry = next.get(best.id)!
580
+ const finalStatus = status === "error" ? "error" : settleOnIdle(entry)
581
+ next.set(best.id, {
582
+ ...entry, status: finalStatus, endedAt: nowTs,
583
+ tokens: sessionTokens || entry.tokens,
584
+ cost: sessionCost || entry.cost,
585
+ sessionId: sid,
586
+ error: errorMsg || entry.error,
587
+ })
588
+ changed = true
589
+ }
590
+ }
591
+ return changed ? next : prev
592
+ })
593
+
594
+ // 当子代理所属的父 session 与当前视图不同时,通过模块级缓存定位
595
+ // 并更新父 session 的 entry 状态,随后写回 KV。
596
+ try {
597
+ const sessionObj = props.api.session.get(sid)
598
+ const parentSid = sessionObj?.parentID
599
+ if (parentSid && parentSid !== props.sessionId) {
600
+ // 优先从模块级缓存获取父 session 的 entries,不受当前视图切换影响
601
+ const parentCache = globalEntryCache.get(parentSid)
602
+ const nowTs = Date.now()
603
+ let found = false
604
+
605
+ if (parentCache) {
606
+ found = tryMatchAndUpdate(parentCache, sid, status, nowTs)
607
+ }
608
+
609
+ // 缓存未命中时回退到 KV 读取
610
+ if (!found) {
611
+ const data = loadSessionData()
612
+ const rec = data[parentSid]
613
+ if (rec?.entries) {
614
+ const fallbackMap = new Map(rec.entries.map((e: SubEntry) => [e.id, e]))
615
+ found = tryMatchAndUpdate(fallbackMap, sid, status, nowTs)
616
+ if (found) {
617
+ // 回退命中后写入 KV 并回填缓存
618
+ data[parentSid] = { ...rec, ts: nowTs, entries: [...fallbackMap.values()] }
619
+ saveSessionData(data)
620
+ globalEntryCache.set(parentSid, fallbackMap)
621
+ }
622
+ }
623
+ }
624
+
625
+ // 将模块级缓存中的最新状态同步到 KV
626
+ if (found && parentCache) {
627
+ const data = loadSessionData()
628
+ data[parentSid] = { ...data[parentSid], ts: nowTs, entries: [...parentCache.values()] }
629
+ saveSessionData(data)
630
+ }
631
+ }
632
+ } catch {}
633
+
634
+ // Delayed backfill: re-read data after state sync catches up, to capture the final
635
+ // token/cost values that may not have been available when session.idle fired.
636
+ setTimeout(() => {
637
+ if (disposed) return
638
+ const finalTokens = props.api.usage.readSessionTokens(sid)
639
+ const finalCost = props.api.usage.readSessionCost(sid)
640
+ const finalModel = props.api.usage.readSessionModel(sid)
641
+ const finalTodo = props.api.usage.readSessionTodo(sid)
642
+ setEntryMap((prev) => {
643
+ let changed = false
644
+ const next = new Map(prev)
645
+ for (const [id, entry] of next) {
646
+ if (entry.sessionId !== sid) continue
647
+ const t = finalTokens ?? entry.tokens
648
+ const c = finalCost ?? entry.cost
649
+ const m = finalModel ?? entry.model
650
+ const tt = finalTodo?.total ?? entry.todoTotal
651
+ const td = finalTodo?.done ?? entry.todoDone
652
+ if (t !== entry.tokens || c !== entry.cost || m !== entry.model ||
653
+ tt !== entry.todoTotal || td !== entry.todoDone) {
654
+ next.set(id, { ...entry, tokens: t, cost: c, model: m, todoTotal: tt, todoDone: td })
655
+ changed = true
656
+ }
657
+ }
658
+ return changed ? next : prev
659
+ })
660
+ bump()
661
+ }, 150)
662
+ }
663
+
664
+ // ── bumpRenderTick: force re-render (visual-cache pattern) ──
665
+ const bump = () => setRenderTick((v) => v + 1)
666
+
667
+ onMount(() => {
668
+ // Fast clock for smooth time display, separate from token polling
669
+ const clock = setInterval(() => { setNow(Date.now()); bump() }, 100)
670
+ // Token poll — runs every 500ms for running entries
671
+ const tokenTimer = setInterval(() => {
672
+ untrack(() => {
673
+ setEntryMapRaw((prev) => {
674
+ let changed = false
675
+ const next = new Map(prev)
676
+ for (const [id, entry] of next) {
677
+ if (entry.status === "running" && entry.sessionId) {
678
+ // Only read from child sessions, never the parent
679
+ let isChild = false
680
+ try {
681
+ const s = props.api.session.get(entry.sessionId)
682
+ isChild = s?.parentID === props.sessionId
683
+ } catch {}
684
+ if (!isChild) continue
685
+ const total = props.api.usage.readSessionTokens(entry.sessionId)
686
+ const todo = props.api.usage.readSessionTodo(entry.sessionId)
687
+ const model = entry.model ?? props.api.usage.readSessionModel(entry.sessionId)
688
+ const nextEntry: SubEntry = { ...entry }
689
+ if (total !== undefined && total !== entry.tokens) { nextEntry.tokens = total; changed = true }
690
+ if (todo !== undefined) {
691
+ if (todo.total !== entry.todoTotal || todo.done !== entry.todoDone) {
692
+ nextEntry.todoTotal = todo.total; nextEntry.todoDone = todo.done; changed = true
693
+ }
694
+ }
695
+ if (model && !entry.model) { nextEntry.model = model; changed = true }
696
+ if (changed) next.set(id, nextEntry)
697
+ }
698
+ }
699
+ return changed ? next : prev
700
+ })
701
+ })
702
+ bump()
703
+ }, 500)
704
+ bump()
705
+
706
+ const unsubPart = props.api.event.on("part.updated", (e) => {
707
+ handlePartUpdated(e)
708
+ bump()
709
+ })
710
+ const unsubMsg = props.api.event.on("message.updated", () => bump())
711
+ const unsubIdle = props.api.event.on("session.idle", (e) => {
712
+ handleSessionEnd(e, "done")
713
+ bump()
714
+ })
715
+ const unsubError = props.api.event.on("session.error", (e) => {
716
+ handleSessionEnd(e, "error")
717
+ bump()
718
+ })
719
+
720
+ onCleanup(() => {
721
+ disposed = true
722
+ clearInterval(clock)
723
+ clearInterval(tokenTimer)
724
+ unsubPart()
725
+ unsubMsg()
726
+ unsubIdle()
727
+ unsubError()
728
+ })
729
+ })
730
+
731
+ // ── session‑switch & initial‑load scan ──
732
+ // On session change: load from kv (entries survive component unmount), then scan+merge.
733
+ // On same session: only scan+merge (keep event‑driven running entries).
734
+ let lastSid = props.sessionId
735
+ let lastTick = 0
736
+ createEffect(() => {
737
+ const sid = props.sessionId
738
+ const switched = sid !== lastSid
739
+ lastSid = sid
740
+ const tick = clearTick() // 外部触发清除时 +1,effect 重跑
741
+ const forceReload = tick !== lastTick && !switched
742
+ lastTick = tick
743
+ const t = setTimeout(() => {
744
+ untrack(() => {
745
+ if (switched) {
746
+ const { parentSid, isChild } = resolveParent(sid)
747
+ const data = loadSessionData()
748
+ const saved = isChild
749
+ ? data[parentSid]?.children?.[sid]?.scroll ?? 0
750
+ : data[sid]?.scroll ?? 0
751
+ setScrollOffset(saved)
752
+ // 刷新父会话的访问时间 TTL,防止活跃会话的数据过期
753
+ if (!isChild && data[sid]?.entries?.length) {
754
+ data[sid].ts = Date.now()
755
+ saveSessionData(data)
756
+ }
757
+ }
758
+ // scan uses setEntryMapRaw — ephemeral data, not persisted to kv.
759
+ // Only event-driven changes (handlePartUpdated, handleSessionEnd) persist.
760
+ setEntryMapRaw((prev) => {
761
+ // 优先从模块级缓存加载,KV 仅作缓存未命中时的回退
762
+ const next = (switched || forceReload)
763
+ ? new Map(globalEntryCache.get(sid) ?? loadEntries(sid))
764
+ : new Map(prev)
765
+ // 从 KV 加载当前会话的清除名单,扫描时跳过被手动清除的历史条目
766
+ const { parentSid: scanPSid, isChild: scanChild } = resolveParent(sid)
767
+ const scanRec = loadSessionData()[scanPSid]
768
+ const clearedIds = new Set(scanChild ? scanRec?.children?.[sid]?.clearedIds : scanRec?.clearedIds)
769
+ try {
770
+ const msgs = props.api.session.messages(sid)
771
+ if (msgs && (msgs as any[]).length) {
772
+ for (const msg of msgs) {
773
+ const parts = props.api.session.part((msg as any).id) ?? []
774
+ for (const partRaw of parts) {
775
+ const part = partRaw as Record<string, unknown>
776
+
777
+ // Subtask entries are purely event-driven — never created by scan.
778
+ // (SubtaskPart exists from spawn, not completion, so we cannot infer status.)
779
+ if (part.type === "tool") {
780
+ const tool = String((part as any).tool ?? "")
781
+ if (!SUBAGENT_TOOLS.has(tool)) continue
782
+ const id = `tool:${String(part.id ?? "")}`
783
+ if (!part.id) continue
784
+
785
+ const st = (part as any).state as Record<string, unknown> | undefined
786
+ const rawStatus = String(st?.status ?? "")
787
+ const exists = next.get(id)
788
+
789
+ // 已手动清除的条目:scan 发现但不在内存 → 跳过重建
790
+ if (!exists && clearedIds.has(id)) continue
791
+
792
+ // Only create entries for tool calls that entered execution.
793
+ // "pending" / empty: skip new entries; allow heuristics for existing ones below.
794
+ if ((rawStatus === "pending" || rawStatus === "") && !exists) continue
795
+
796
+ // "error": only update existing, never create a new entry
797
+ if (rawStatus === "error") {
798
+ if (exists && exists.status === "running") {
799
+ next.set(id, { ...exists, status: "error", endedAt: Date.now() })
800
+ }
801
+ continue
802
+ }
803
+
804
+ let status: SubStatus = "running"
805
+ if (rawStatus === "completed") status = "done"
806
+ // Background tasks: tool completion ≠ agent completion — keep running until session.idle
807
+ // Only keep running if state metadata confirms a child session was spawned.
808
+ if (((st?.input as Record<string, unknown> | undefined)?.run_in_background === true || (st?.input as Record<string, unknown> | undefined)?.background === true) && status === "done") {
809
+ const scanStMeta = st?.metadata as Record<string, unknown> | undefined
810
+ const scanHasChild = scanStMeta?.session_id !== undefined || scanStMeta?.sessionId !== undefined
811
+ if (scanHasChild) status = "running"
812
+ }
813
+
814
+ // Already settled → skip
815
+ if (exists && exists.status !== "running" && exists.status !== "cancel_requested") continue
816
+ // Running entry with no explicit status improvement from part:
817
+ // try message-level heuristics first, then time-based fallback.
818
+ if (exists && status === "running") {
819
+ if (!rawStatus) {
820
+ const msgTokens = (msg as any)?.tokens as Record<string, unknown> | undefined
821
+ if (msgTokens && (Number(msgTokens.input) > 0 || Number(msgTokens.output) > 0)) {
822
+ status = "done" // LLM returned tokens → agent completed
823
+ } else if (Date.now() - exists.startedAt > 30 * 60 * 1000) {
824
+ status = "done" // >30 min idle → assume completed
825
+ } else {
826
+ continue
827
+ }
828
+ } else {
829
+ continue
830
+ }
831
+ }
832
+
833
+ // If already tracked as running but tool state says completed/error → update
834
+ // If not tracked → add fresh
835
+
836
+ const input = st?.input as Record<string, unknown> | undefined
837
+ const agent = String((part as any).subagent_type ?? input?.agent ?? input?.subagent_type ?? tool)
838
+ const prompt = String(input?.prompt ?? (part as any).description ?? "")
839
+ const desc = input?.description !== undefined ? String(input.description) : ""
840
+ const title = desc || truncate(prompt.replace(/\n/g, " ").trim(), 40)
841
+
842
+ let tokens: number | undefined
843
+ const scanStMeta2 = st?.metadata as Record<string, unknown> | undefined
844
+ const scanSubSid = scanStMeta2?.session_id !== undefined ? String(scanStMeta2.session_id)
845
+ : scanStMeta2?.sessionId !== undefined ? String(scanStMeta2.sessionId)
846
+ : undefined
847
+ if (scanSubSid) tokens = props.api.usage.readSessionTokens(scanSubSid)
848
+
849
+ const ended = status === "done" // "error" handled above, never reaches here
850
+ next.set(id, {
851
+ id, title, agent, prompt,
852
+ // Preserve existing values (from handleSessionEnd / KV) — scan must not overwrite
853
+ tokens: exists?.tokens ?? tokens,
854
+ sessionId: exists?.sessionId ?? scanSubSid,
855
+ status,
856
+ startedAt: exists?.startedAt || Date.now(),
857
+ endedAt: ended ? (exists?.endedAt || Date.now()) : undefined,
858
+ })
859
+ }
860
+ }
861
+ }
862
+ }
863
+ } catch {}
864
+ return next
865
+ })
866
+ // Reconcile: check running entries against live child session status.
867
+ // Covers session.idle events missed while user was inside a child session.
868
+ setEntryMapRaw((prev) => {
869
+ let changed = false
870
+ const next = new Map(prev)
871
+ for (const [id, entry] of next) {
872
+ if ((entry.status !== "running" && entry.status !== "cancel_requested") || !entry.sessionId) continue
873
+ try {
874
+ const st = props.api.session.status(entry.sessionId)
875
+ if (!st || st.type !== "idle") continue
876
+ const tokens = props.api.usage.readSessionTokens(entry.sessionId)
877
+ const cost = props.api.usage.readSessionCost(entry.sessionId)
878
+ const finalStatus = entry.status === "cancel_requested" && entry.abortAccepted
879
+ ? "cancelled" as SubStatus
880
+ : "done" as SubStatus
881
+ next.set(id, {
882
+ ...entry, status: finalStatus, endedAt: Date.now(),
883
+ tokens: tokens ?? entry.tokens,
884
+ cost: cost ?? entry.cost,
885
+ })
886
+ changed = true
887
+ } catch {}
888
+ }
889
+ return changed ? next : prev
890
+ })
891
+ bump()
892
+ })
893
+ }, 150)
894
+ onCleanup(() => clearTimeout(t))
895
+ })
896
+
897
+ // ── palette ──
898
+ const pal = createMemo(() => {
899
+ const th = props.theme as Record<string, unknown>
900
+ const sat = (k: string, fb: string) => desaturateTo(th[k], MAX_SAT, fb)
901
+ return {
902
+ primary: sat("primary", FALLBACK.primary),
903
+ text: sat("text", FALLBACK.text),
904
+ muted: sat("textMuted", FALLBACK.muted),
905
+ success: sat("success", FALLBACK.success),
906
+ warning: sat("warning", FALLBACK.warning),
907
+ error: sat("error", FALLBACK.error),
908
+ border: sat("border", FALLBACK.border),
909
+ }
910
+ })
911
+
912
+ // ── derived signals ──
913
+ // Stable list — only changes when entryMap changes
914
+ const entryList = createMemo(() => {
915
+ const entries = [...entryMap().values()]
916
+ if (props.sortOrder() === "desc") {
917
+ return entries.sort((a, b) => b.startedAt - a.startedAt)
918
+ }
919
+ return entries.sort((a, b) => a.startedAt - b.startedAt)
920
+ })
921
+
922
+ const max = props.maxEntries
923
+ const clampedOffset = createMemo(() => {
924
+ const total = entryList().length
925
+ const m = max()
926
+ if (total <= m) return 0
927
+ return Math.min(scrollOffset(), total - m)
928
+ })
929
+ const visibleList = createMemo(() => entryList().slice(clampedOffset(), clampedOffset() + max()))
930
+ const hiddenAbove = createMemo(() => clampedOffset())
931
+ const hiddenBelow = createMemo(() => Math.max(0, entryList().length - clampedOffset() - max()))
932
+
933
+ // Drop hover state when ↑ more disappears (hiddenAbove hits zero)
934
+ createEffect(() => {
935
+ if (hiddenAbove() === 0) setHoveredMoreAbove(false)
936
+ })
937
+
938
+ // Reset scroll on sort order change: jump to newest in view
939
+ let sortInitialized = false
940
+ createEffect(() => {
941
+ props.sortOrder()
942
+ if (!sortInitialized) { sortInitialized = true; return }
943
+ const total = untrack(() => entryList().length)
944
+ const m = untrack(() => max())
945
+ const target = props.sortOrder() === "desc" ? 0 : Math.max(0, total - m)
946
+ setScrollOffset(target)
947
+ setTimeout(() => {
948
+ try { persistScroll(props.sessionId, target) } catch {}
949
+ }, 0)
950
+ })
951
+
952
+ // When new entries arrive while viewing the newest end, keep the view at newest
953
+ let prevEntryCount = 0
954
+ createEffect(() => {
955
+ const total = entryList().length
956
+ if (prevEntryCount === 0) { prevEntryCount = total; return }
957
+ if (total === prevEntryCount) return
958
+
959
+ const m = max()
960
+ const wasAtNewest = props.sortOrder() === "desc"
961
+ ? untrack(() => scrollOffset() === 0)
962
+ : untrack(() => scrollOffset() >= prevEntryCount - m)
963
+
964
+ prevEntryCount = total
965
+
966
+ if (wasAtNewest) {
967
+ const target = props.sortOrder() === "desc" ? 0 : Math.max(0, total - m)
968
+ setScrollOffset(target)
969
+ setTimeout(() => {
970
+ try { persistScroll(props.sessionId, target) } catch {}
971
+ }, 0)
972
+ }
973
+ })
974
+
975
+ const entries = createMemo(() => {
976
+ const nowVal = now()
977
+ return entryList().map((e) => ({
978
+ ...e,
979
+ elapsed: (e.endedAt ?? nowVal) - e.startedAt,
980
+ }))
981
+ })
982
+
983
+ const doneCount = createMemo(() => entryList().filter((e) => e.status === "done" || e.status === "cancelled").length)
984
+ const runningCount = createMemo(() => entryList().filter((e) => e.status === "running" || e.status === "cancel_requested").length)
985
+ const errCount = createMemo(() => entryList().filter((e) => e.status === "error").length)
986
+ const anyEntry = () => entryList().length > 0
987
+
988
+ const totalTokens = createMemo(() => {
989
+ let sum = 0
990
+ for (const e of entryList()) { if (e.tokens) sum += e.tokens }
991
+ return sum
992
+ })
993
+
994
+ const totalCost = createMemo(() => {
995
+ let sum = 0
996
+ for (const e of entryList()) { if (e.cost) sum += e.cost }
997
+ return sum
998
+ })
999
+
1000
+ const toggleExpand = (id: string) => {
1001
+ setExpanded((prev) => {
1002
+ const next = prev === id ? undefined : id
1003
+ try { persistExpanded(props.sessionId, next ?? "") } catch {}
1004
+ return next
1005
+ })
1006
+ }
1007
+
1008
+ const sep = () => "\u2500".repeat(Math.max(1, panelWidth()))
1009
+
1010
+ // ── expanded detail right-align ──
1011
+ const expandedMaxLabelW = createMemo(() => {
1012
+ const labels = [
1013
+ t("agent.label"), t("status.label"), t("time.label"), t("tokens.label"),
1014
+ t("error.label"), t("cost.label"), t("model.label"), t("todo.label"), t("session.label"),
1015
+ ]
1016
+ return Math.max(...labels.map(l => visualWidth(l + ": ")))
1017
+ })
1018
+
1019
+ const expandedPad = (label: string) => Math.max(0, expandedMaxLabelW() - visualWidth(label + ": "))
1020
+
1021
+ const expandedValAvail = () => Math.max(6, panelWidth() - INDENT - expandedMaxLabelW())
1022
+
1023
+ // ── header parts for colored spans ──
1024
+ const summaryParts = createMemo(() => {
1025
+ if (!anyEntry()) return null
1026
+ const dot = "\u25cf"
1027
+ const cost = totalCost()
1028
+ return {
1029
+ done: `${dot}${doneCount()}`,
1030
+ running: runningCount() > 0 ? `${dot}${runningCount()}` : null,
1031
+ err: errCount() > 0 ? `${dot}${errCount()}` : null,
1032
+ duration: totalTokens() > 0 ? fmtTokens(totalTokens()) : "",
1033
+ cost: cost > 0 ? `$${cost.toFixed(2)}` : "",
1034
+ }
1035
+ })
1036
+
1037
+ const summaryCols = createMemo(() => {
1038
+ const p = summaryParts()
1039
+ if (!p) return 0
1040
+ let w = visualWidth(p.done)
1041
+ if (p.running) w += 1 + visualWidth(p.running)
1042
+ if (p.err) w += 1 + visualWidth(p.err)
1043
+ w += p.duration ? 1 + visualWidth(p.duration) : 0
1044
+ w += p.cost ? 1 + visualWidth(p.cost) : 0
1045
+ return w
1046
+ })
1047
+
1048
+ const versionText = ` v${PLUGIN_VERSION}`
1049
+ const versionW = visualWidth(versionText)
1050
+
1051
+ const showVersion = createMemo(() => {
1052
+ if (!open()) return false
1053
+ const icon = "\u25bc"
1054
+ const need = visualWidth(icon) + 1 + visualWidth(t("panel.title")) + versionW + summaryCols()
1055
+ return need <= panelWidth()
1056
+ })
1057
+
1058
+ const leftCols = createMemo(() => {
1059
+ const icon = open() ? "\u25bc" : "\u25b6"
1060
+ let w = visualWidth(icon) + 1 + visualWidth(t("panel.title"))
1061
+ if (showVersion()) w += versionW
1062
+ return w
1063
+ })
1064
+
1065
+ const spacerCols = createMemo(() => {
1066
+ if (!anyEntry()) return 0
1067
+ return Math.max(0, panelWidth() - leftCols() - summaryCols())
1068
+ })
1069
+
1070
+ const valueCols = (label: string) =>
1071
+ Math.max(4, panelWidth() - INDENT - visualWidth(label + ": "))
1072
+
1073
+ // ── render ──
1074
+ return (
1075
+ <box
1076
+ border={false}
1077
+ paddingTop={0} paddingBottom={0} paddingLeft={0} paddingRight={0}
1078
+ flexDirection="column" gap={0}
1079
+ ref={boxEl}
1080
+ onSizeChange={() => {
1081
+ const w = boxEl ? Math.max(20, boxEl.width ?? 0) : 28
1082
+ setPanelWidth((prev) => (prev === w ? prev : w))
1083
+ }}
1084
+ >
1085
+ {/* ── header: same pattern as visual-cache's fold toggle ── */}
1086
+ {/* renderTick in span forces the text element to re-evaluate */}
1087
+ <text
1088
+ onMouseUp={() => {
1089
+ setOpen((o) => {
1090
+ const n = !o
1091
+ try { props.api.kv.set(`${KV_PREFIX}.open`, n) } catch {}
1092
+ return n
1093
+ })
1094
+ bump()
1095
+ }}
1096
+ >
1097
+ <span style={{ fg: pal().muted }}>{renderTick() >= 0 && open() ? "\u25bc " : "\u25b6 "}</span>
1098
+ <span style={{ fg: pal().primary }}>{t("panel.title")}</span>
1099
+ <Show when={showVersion()}><span style={{ fg: dimColor(pal().muted, 0.75) }}>{versionText}</span></Show>
1100
+ {anyEntry() ? (
1101
+ <>
1102
+ <span style={{ fg: pal().muted }}>{" ".repeat(spacerCols())}</span>
1103
+ <span style={{ fg: pal().success }}>{summaryParts()!.done}</span>
1104
+ {runningCount() > 0 && (
1105
+ <span style={{ fg: pal().warning }}> {summaryParts()!.running}</span>
1106
+ )}
1107
+ {errCount() > 0 && (
1108
+ <span style={{ fg: pal().error }}> {summaryParts()!.err}</span>
1109
+ )}
1110
+ {summaryParts()!.duration ? (
1111
+ <span style={{ fg: pal().muted }}> {summaryParts()!.duration}</span>
1112
+ ) : null}
1113
+ {summaryParts()!.cost ? (
1114
+ <span style={{ fg: pal().warning }}> {summaryParts()!.cost}</span>
1115
+ ) : null}
1116
+ </>
1117
+ ) : null}
1118
+ </text>
1119
+
1120
+ {/* ── panel body ── */}
1121
+ <Show when={open()}>
1122
+ <text fg={pal().muted}>{sep()}</text>
1123
+
1124
+ <Show
1125
+ when={anyEntry()}
1126
+ fallback={
1127
+ <text style={{ fg: pal().muted }}>
1128
+ {" "}&gt; {t("status.none")} {/* empty indent kept for visual balance */}
1129
+ </text>
1130
+ }
1131
+ >
1132
+ <box
1133
+ onMouseScroll={(e) => {
1134
+ if (props.scrollMode() === "click") return
1135
+ const total = entryList().length
1136
+ const m = max()
1137
+ if (total <= m) return
1138
+ const dir = e.button === 0 ? 1 : -1
1139
+ setScrollOffset((prev) => {
1140
+ const next = Math.max(0, Math.min(prev + dir, total - m))
1141
+ try { persistScroll(props.sessionId, next) } catch {}
1142
+ return next
1143
+ })
1144
+ }}
1145
+ >
1146
+ <Show when={hiddenAbove() > 0}>
1147
+ <text
1148
+ onMouseOver={() => setHoveredMoreAbove(true)}
1149
+ onMouseOut={() => setHoveredMoreAbove(false)}
1150
+ onMouseUp={() => {
1151
+ const total = entryList().length
1152
+ const m = max()
1153
+ if (total <= m) return
1154
+ const next = Math.max(0, scrollOffset() - m)
1155
+ if (next === 0) {
1156
+ setTimeout(() => {
1157
+ setScrollOffset(next)
1158
+ try { persistScroll(props.sessionId, next) } catch {}
1159
+ }, 0)
1160
+ } else {
1161
+ setScrollOffset(next)
1162
+ try { persistScroll(props.sessionId, next) } catch {}
1163
+ }
1164
+ }}
1165
+ >
1166
+ <span style={{ fg: hoveredMoreAbove() ? pal().warning : pal().muted }}>
1167
+ {" "}&uarr; {hiddenAbove()} {t("scroll.more")}
1168
+ </span>
1169
+ </text>
1170
+ </Show>
1171
+ <For each={visibleList()}>
1172
+ {(entry) => {
1173
+ const isExpanded = () => expanded() === entry.id
1174
+ const isRunning = entry.status === "running"
1175
+ const isCancelRequested = entry.status === "cancel_requested"
1176
+ const isCancelled = entry.status === "cancelled"
1177
+ const isError = entry.status === "error"
1178
+ const isActiveRunning = isRunning || isCancelRequested
1179
+ const elapsed = () => (entry.endedAt ?? now()) - entry.startedAt
1180
+
1181
+ const statusDot = () => "\u25cf"
1182
+ const statusColor = () => {
1183
+ if (isCancelled) return pal().muted
1184
+ if (!isActiveRunning) return isError ? pal().error : pal().success
1185
+ const t = (Math.sin(((now() % 2000) / 2000) * Math.PI * 2 - Math.PI / 2) + 1) / 2
1186
+ const a = rgb(pal().muted), b = rgb(pal().warning)
1187
+ if (!a || !b) return pal().warning
1188
+ const r = Math.round(a.r + (b.r - a.r) * t)
1189
+ const g = Math.round(a.g + (b.g - a.g) * t)
1190
+ const bl = Math.round(a.b + (b.b - a.b) * t)
1191
+ return "#" + [r, g, bl].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
1192
+ }
1193
+
1194
+ const timeColor = () =>
1195
+ isActiveRunning ? pal().warning : isError ? pal().error : pal().muted
1196
+
1197
+ // Entry label: collapsed shows title only, expanded shows title only too
1198
+ const tokenText = () =>
1199
+ !isExpanded() && entry.tokens !== undefined && entry.tokens > 0
1200
+ ? ` ${fmtTokens(entry.tokens!)}`
1201
+ : ""
1202
+ const timeText = () =>
1203
+ !isExpanded() && (elapsed() >= 2000 || entry.endedAt !== undefined)
1204
+ ? fmtDurationShort(elapsed(), isActiveRunning)
1205
+ : ""
1206
+ const suffixW = () => {
1207
+ let w = 0
1208
+ const t = timeText()
1209
+ if (t) w += 1 + visualWidth(t)
1210
+ const tk = tokenText()
1211
+ if (tk) w += visualWidth(tk)
1212
+ return w
1213
+ }
1214
+ const labelAvail = () => Math.max(6, panelWidth() - LEFT_PAD - suffixW())
1215
+ const labelText = () => {
1216
+ const max = labelAvail()
1217
+ const text = entry.title || entry.agent
1218
+ const truncated = truncate(text, max)
1219
+ const pad = Math.max(0, max - visualWidth(truncated))
1220
+ return truncated + " ".repeat(pad)
1221
+ }
1222
+
1223
+ return (
1224
+ <>
1225
+ {/* entry line — left-aligned */}
1226
+ <text onMouseUp={() => toggleExpand(entry.id)}>
1227
+ <span style={{ fg: pal().muted }}>
1228
+ {isExpanded() ? "\u25bc" : "\u25b6"}
1229
+ </span>
1230
+ {" "}
1231
+ <span style={{ fg: statusColor() }}>{statusDot()}</span>
1232
+ {" "}
1233
+ <span style={{ fg: pal().text }}>{labelText()}</span>
1234
+ {timeText() ? (
1235
+ <>
1236
+ {" "}
1237
+ <span style={{ fg: timeColor() }}>{timeText()}</span>
1238
+ </>
1239
+ ) : null}
1240
+ {tokenText() ? (
1241
+ <span style={{ fg: pal().muted }}>{tokenText()}</span>
1242
+ ) : null}
1243
+ </text>
1244
+
1245
+ {/* expanded detail — right-aligned values */}
1246
+ <Show when={isExpanded()}>
1247
+ <text>
1248
+ {" "}
1249
+ <span style={{ fg: pal().primary }}>{t("agent.label")}: </span>
1250
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("agent.label")))}</span>
1251
+ <span style={{ fg: pal().muted }}>{entry.agent}</span>
1252
+ </text>
1253
+ <text>
1254
+ {" "}
1255
+ <span style={{ fg: pal().primary }}>{t("status.label")}: </span>
1256
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("status.label")))}</span>
1257
+ <span style={{ fg: isActiveRunning ? pal().warning : isCancelled ? pal().muted : isError ? pal().error : pal().success }}>
1258
+ {isActiveRunning ? t("status.running") : isCancelled ? t("status.cancelled") : isError ? t("status.error") : t("status.done")}
1259
+ </span>
1260
+ </text>
1261
+ <Show when={elapsed() >= 2000 || entry.endedAt !== undefined}>
1262
+ <text>
1263
+ {" "}
1264
+ <span style={{ fg: pal().primary }}>{t("time.label")}: </span>
1265
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("time.label")))}</span>
1266
+ <span style={{ fg: pal().muted }}>
1267
+ {fmtDurationShort(elapsed(), isActiveRunning)}
1268
+ </span>
1269
+ </text>
1270
+ </Show>
1271
+ <Show when={entry.tokens !== undefined}>
1272
+ <text>
1273
+ {" "}
1274
+ <span style={{ fg: pal().primary }}>{t("tokens.label")}: </span>
1275
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("tokens.label")))}</span>
1276
+ <span style={{ fg: pal().muted }}>{fmtTokens(entry.tokens!)}</span>
1277
+ </text>
1278
+ </Show>
1279
+ <Show when={entry.error}>
1280
+ <text>
1281
+ {" "}
1282
+ <span style={{ fg: pal().error }}>{t("error.label")}: </span>
1283
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("error.label")))}</span>
1284
+ <span style={{ fg: pal().error }}>{truncate(String(entry.error), expandedValAvail())}</span>
1285
+ </text>
1286
+ </Show>
1287
+ <Show when={entry.cost !== undefined}>
1288
+ {(() => {
1289
+ const cost = entry.cost!
1290
+ return (
1291
+ <text>
1292
+ {" "}
1293
+ <span style={{ fg: pal().primary }}>{t("cost.label")}: </span>
1294
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("cost.label")))}</span>
1295
+ <span style={{ fg: pal().muted }}>${cost.toFixed(4)}</span>
1296
+ </text>
1297
+ )
1298
+ })()}
1299
+ </Show>
1300
+ <Show when={entry.model}>
1301
+ <text>
1302
+ {" "}
1303
+ <span style={{ fg: pal().primary }}>{t("model.label")}: </span>
1304
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("model.label")))}</span>
1305
+ <span style={{ fg: pal().muted }}>{truncate(entry.model!, expandedValAvail())}</span>
1306
+ </text>
1307
+ </Show>
1308
+ <Show when={entry.todoTotal !== undefined}>
1309
+ <text>
1310
+ {" "}
1311
+ <span style={{ fg: pal().primary }}>{t("todo.label")}: </span>
1312
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("todo.label")))}</span>
1313
+ <span style={{ fg: pal().muted }}>{entry.todoDone}/{entry.todoTotal}</span>
1314
+ </text>
1315
+ </Show>
1316
+ <Show when={entry.sessionId}>
1317
+ <text
1318
+ onMouseUp={async () => {
1319
+ const sessionId = entry.sessionId
1320
+ if (!sessionId) return
1321
+
1322
+ const result = await copyText(sessionId)
1323
+
1324
+ if (result.copied) {
1325
+ props.api.ui.toast(t("session.toast.copied"), {
1326
+ variant: "success",
1327
+ title: entry.title || entry.agent,
1328
+ duration: 2500,
1329
+ })
1330
+ return
1331
+ }
1332
+
1333
+ props.api.ui.toast(`${sessionId}\n\n${t("session.toast.copy_failed")}`, {
1334
+ variant: "warning",
1335
+ title: entry.title || entry.agent,
1336
+ duration: 8000,
1337
+ })
1338
+ }}
1339
+ >
1340
+ {" "}
1341
+ <span style={{ fg: pal().primary }}>{t("session.label")}: </span>
1342
+ <span style={{ fg: pal().muted }}>{" ".repeat(expandedPad(t("session.label")))}</span>
1343
+ <span style={{ fg: pal().muted }}>{truncate(entry.sessionId!, expandedValAvail() - visualWidth(" ⎘"))}</span>
1344
+ <span style={{ fg: pal().warning }}> ⎘</span>
1345
+ </text>
1346
+ </Show>
1347
+ {/* 进入会话 + 取消任务 + 仅清除显示:同排左右两端 */}
1348
+ <Show when={entry.sessionId || isRunning}>
1349
+ {(() => {
1350
+ const openPrefix = () => " \u2192 "
1351
+ const openFull = () => entry.sessionId ? openPrefix() + t("open.label") : ""
1352
+ const openW = () => entry.sessionId ? visualWidth(openFull()) : 0
1353
+ const cancelLabel = () => ` ${t("cancel.label")}`
1354
+ const dismissLabel = () => ` ${t("dismiss.label")}`
1355
+ const rightW = (isRunning ? visualWidth(dismissLabel()) : 0) + (isRunning && entry.sessionId ? visualWidth(cancelLabel()) : 0)
1356
+ const spacerW = () => Math.max(1, panelWidth() - openW() - rightW - 2)
1357
+ return (
1358
+ <box flexDirection="row">
1359
+ <Show when={entry.sessionId}>
1360
+ <text
1361
+ onMouseOver={() => setHoveredOpen(entry.id)}
1362
+ onMouseOut={() => setHoveredOpen(undefined)}
1363
+ onMouseUp={() => {
1364
+ if (entry.sessionId) {
1365
+ props.api.route.navigateSession(entry.sessionId)
1366
+ }
1367
+ }}
1368
+ >
1369
+ <span style={{ fg: hoveredOpen() === entry.id ? pal().warning : pal().primary }}>{openPrefix()}</span>
1370
+ <span style={{ fg: hoveredOpen() === entry.id ? pal().warning : pal().primary }}>{t("open.label")}</span>
1371
+ </text>
1372
+ </Show>
1373
+ <text style={{ fg: pal().muted }}>{" ".repeat(spacerW())}</text>
1374
+ <Show when={isRunning && entry.sessionId}>
1375
+ <text
1376
+ onMouseOver={() => setHoveredCancel(entry.id)}
1377
+ onMouseOut={() => setHoveredCancel(undefined)}
1378
+ onMouseUp={() => cancelEntry(entry)}
1379
+ >
1380
+ <span style={{ fg: hoveredCancel() === entry.id ? pal().warning : pal().error }}>{cancelLabel()}</span>
1381
+ </text>
1382
+ </Show>
1383
+ <Show when={isRunning}>
1384
+ <text
1385
+ onMouseOver={() => setHoveredDismiss(entry.id)}
1386
+ onMouseOut={() => setHoveredDismiss(undefined)}
1387
+ onMouseUp={() => {
1388
+ upsertEntry({ id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt, status: "done" })
1389
+ }}
1390
+ >
1391
+ <span style={{ fg: hoveredDismiss() === entry.id ? pal().warning : pal().muted }}>{dismissLabel()}</span>
1392
+ </text>
1393
+ </Show>
1394
+ </box>
1395
+ )
1396
+ })()}
1397
+ </Show>
1398
+ </Show>
1399
+ </>
1400
+ )
1401
+ }}
1402
+ </For>
1403
+ <Show when={hiddenBelow() > 0 || (props.sortOrder() === "desc" ? scrollOffset() > 0 : entryList().length > max() && clampedOffset() < entryList().length - max())}>
1404
+ {(() => {
1405
+ const showMore = hiddenBelow() > 0
1406
+ const showTop = props.sortOrder() === "desc"
1407
+ ? scrollOffset() > 0
1408
+ : entryList().length > max() && clampedOffset() < entryList().length - max()
1409
+ const left = showMore ? ` \u2193 ${hiddenBelow()} ${t("scroll.more")}` : " "
1410
+ const right = props.sortOrder() === "desc"
1411
+ ? `\u2191 ${t("scroll.top")}`
1412
+ : `\u2193 ${t("scroll.bottom")}`
1413
+ const pad = showTop ? Math.max(1, panelWidth() - visualWidth(left) - visualWidth(right)) : 0
1414
+ return (
1415
+ <box flexDirection="row">
1416
+ <text
1417
+ onMouseOver={() => showMore && setHoveredMoreBelow(true)}
1418
+ onMouseOut={() => setHoveredMoreBelow(false)}
1419
+ onMouseUp={() => {
1420
+ if (!showMore) return
1421
+ const total = entryList().length
1422
+ const m = max()
1423
+ if (total <= m) return
1424
+ setScrollOffset((prev) => Math.min(total - m, prev + m))
1425
+ try { persistScroll(props.sessionId, scrollOffset()) } catch {}
1426
+ setHoveredMoreBelow(false)
1427
+ }}
1428
+ >
1429
+ <span style={{ fg: showMore && hoveredMoreBelow() ? pal().warning : pal().muted }}>
1430
+ {left}
1431
+ </span>
1432
+ </text>
1433
+ {showTop ? (
1434
+ <>
1435
+ <text style={{ fg: pal().muted }}>{" ".repeat(pad)}</text>
1436
+ <text
1437
+ onMouseOver={() => setHoveredTop(true)}
1438
+ onMouseOut={() => setHoveredTop(false)}
1439
+ onMouseUp={() => {
1440
+ const total = entryList().length
1441
+ const m = max()
1442
+ if (props.sortOrder() === "desc") {
1443
+ setScrollOffset(0)
1444
+ } else {
1445
+ setScrollOffset(Math.max(0, total - m))
1446
+ }
1447
+ setHoveredTop(false)
1448
+ }}
1449
+ >
1450
+ <span style={{ fg: hoveredTop() ? pal().warning : pal().muted }}>{right}</span>
1451
+ </text>
1452
+ </>
1453
+ ) : null}
1454
+ </box>
1455
+ )
1456
+ })()}
1457
+ </Show>
1458
+ </box>
1459
+ </Show>
1460
+ </Show>
1461
+ </box>
1462
+ )
1463
+ }
1464
+
1465
+ // ===================================================================