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