opencode-subagent-magazine 1.7.1 → 1.7.3

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