From 14dd259840f23db2a832fac86f8402c893bfea11 Mon Sep 17 00:00:00 2001 From: kilng235 <99633136+kilng235@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:03:38 +0800 Subject: [PATCH 1/3] fix: prevent crash when entry.cost becomes undefined between Show condition and render SolidJS Show's `when` condition and child render can be separated by a reactive tick. If entry.cost transitions from a number to undefined between the guard check and the child function executing, `entry.cost!.toFixed(4)` throws "undefined is not an object". Capturing the value in an IIFE closes the race window. --- src/index.tsx | 708 +------------------------------------------------- 1 file changed, 1 insertion(+), 707 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 21aeaf0..d1c84a0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1317,711 +1317,5 @@ function SubAgentPanel(props: { }) const valueCols = (label: string) => - Math.max(4, panelWidth() - INDENT - visualWidth(label + ": ")) - - // ── render ── - return ( - { - const w = boxEl ? Math.max(20, boxEl.width ?? 0) : 28 - setPanelWidth((prev) => (prev === w ? prev : w)) - }} - > - {/* ── header: same pattern as visual-cache's fold toggle ── */} - {/* renderTick in span forces the text element to re-evaluate */} - { - setOpen((o) => { - const n = !o - try { props.api.kv.set(`${KV_PREFIX}.open`, n) } catch {} - return n - }) - bump() - }} - > - {renderTick() >= 0 && open() ? "\u25bc " : "\u25b6 "} - {t("panel.title")} - {versionText} - {anyEntry() ? ( - <> - {" ".repeat(spacerCols())} - {summaryParts()!.done} - {runningCount() > 0 && ( - {summaryParts()!.running} - )} - {errCount() > 0 && ( - {summaryParts()!.err} - )} - {summaryParts()!.duration ? ( - {summaryParts()!.duration} - ) : null} - {summaryParts()!.cost ? ( - {summaryParts()!.cost} - ) : null} - - ) : null} - - - {/* ── panel body ── */} - - {sep()} - - - {" "}> {t("status.none")} {/* empty indent kept for visual balance */} - - } - > - { - if (props.scrollMode() === "click") return - const total = entryList().length - const m = max() - if (total <= m) return - const dir = e.button === 0 ? 1 : -1 - setScrollOffset((prev) => { - const next = Math.max(0, Math.min(prev + dir, total - m)) - try { persistScroll(props.sessionId, next) } catch {} - return next - }) - }} - > - 0}> - setHoveredMoreAbove(true)} - onMouseOut={() => setHoveredMoreAbove(false)} - onMouseUp={() => { - const total = entryList().length - const m = max() - if (total <= m) return - const next = Math.max(0, scrollOffset() - m) - if (next === 0) { - setTimeout(() => { - setScrollOffset(next) - try { persistScroll(props.sessionId, next) } catch {} - }, 0) - } else { - setScrollOffset(next) - try { persistScroll(props.sessionId, next) } catch {} - } - }} - > - - {" "}↑ {hiddenAbove()} {t("scroll.more")} - - - - - {(entry) => { - const isExpanded = () => expanded() === entry.id - const isRunning = entry.status === "running" - const isError = entry.status === "error" - const elapsed = () => (entry.endedAt ?? now()) - entry.startedAt - - const statusDot = () => "\u25cf" - const statusColor = () => { - if (!isRunning) return isError ? pal().error : pal().success - const t = (Math.sin(((now() % 2000) / 2000) * Math.PI * 2 - Math.PI / 2) + 1) / 2 - const a = rgb(pal().muted), b = rgb(pal().warning) - if (!a || !b) return pal().warning - const r = Math.round(a.r + (b.r - a.r) * t) - const g = Math.round(a.g + (b.g - a.g) * t) - const bl = Math.round(a.b + (b.b - a.b) * t) - return "#" + [r, g, bl].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("") - } - - const timeColor = () => - isRunning ? pal().warning : isError ? pal().error : pal().muted - - // Entry label: collapsed shows title only, expanded shows title only too - const tokenText = () => - !isExpanded() && entry.tokens !== undefined && entry.tokens > 0 - ? ` ${fmtTokens(entry.tokens!)}` - : "" - const timeText = () => - !isExpanded() && (elapsed() >= 2000 || entry.endedAt !== undefined) - ? fmtDurationShort(elapsed(), isRunning) - : "" - const suffixW = () => { - let w = 0 - const t = timeText() - if (t) w += 1 + visualWidth(t) - const tk = tokenText() - if (tk) w += visualWidth(tk) - return w - } - const labelAvail = () => Math.max(6, panelWidth() - LEFT_PAD - suffixW()) - const labelText = () => { - const max = labelAvail() - const text = entry.title || entry.agent - const truncated = truncate(text, max) - const pad = Math.max(0, max - visualWidth(truncated)) - return truncated + " ".repeat(pad) - } - - return ( - <> - {/* entry line — left-aligned */} - toggleExpand(entry.id)}> - - {isExpanded() ? "\u25bc" : "\u25b6"} - - {" "} - {statusDot()} - {" "} - {labelText()} - {timeText() ? ( - <> - {" "} - {timeText()} - - ) : null} - {tokenText() ? ( - {tokenText()} - ) : null} - - - {/* expanded detail — right-aligned values */} - - - {" "} - {t("agent.label")}: - {" ".repeat(expandedPad(t("agent.label")))} - {entry.agent} - - - {" "} - {t("status.label")}: - {" ".repeat(expandedPad(t("status.label")))} - - {isRunning ? t("status.running") : isError ? t("status.error") : t("status.done")} - - - = 2000 || entry.endedAt !== undefined}> - - {" "} - {t("time.label")}: - {" ".repeat(expandedPad(t("time.label")))} - - {fmtDurationShort(elapsed(), isRunning)} - - - - - - {" "} - {t("tokens.label")}: - {" ".repeat(expandedPad(t("tokens.label")))} - {fmtTokens(entry.tokens!)} - - - - - {" "} - {t("error.label")}: - {" ".repeat(expandedPad(t("error.label")))} - {truncate(String(entry.error), expandedValAvail())} - - - - - {" "} - {t("cost.label")}: - {" ".repeat(expandedPad(t("cost.label")))} - ${entry.cost!.toFixed(4)} - - - - - {" "} - {t("model.label")}: - {" ".repeat(expandedPad(t("model.label")))} - {truncate(entry.model!, expandedValAvail())} - - - - - {" "} - {t("todo.label")}: - {" ".repeat(expandedPad(t("todo.label")))} - {entry.todoDone}/{entry.todoTotal} - - - - { - if (entry.sessionId) { - props.api.ui.toast({ - title: entry.title || entry.agent, - message: `${entry.sessionId}\n\n${t("session.toast.copy")}`, - duration: 8000, - }) - } - }} - > - {" "} - {t("session.label")}: - {" ".repeat(expandedPad(t("session.label")))} - {truncate(entry.sessionId!, expandedValAvail() - visualWidth(" ⎘"))} - - - - {/* 进入会话 + 标记完成:同排左右两端,空间隔离防误触 */} - - {(() => { - const dismissLabel = () => `- ${t("dismiss.label")}` - const openPrefix = () => " \u2192 " - const openFull = () => entry.sessionId ? openPrefix() + t("open.label") : "" - const openW = () => entry.sessionId ? visualWidth(openFull()) : 0 - const spacerW = () => Math.max(1, panelWidth() - openW() - visualWidth(dismissLabel()) - 2 /* indent */) - return ( - - {" "}} - > - setHoveredOpen(entry.id)} - onMouseOut={() => setHoveredOpen(undefined)} - onMouseUp={() => { - if (entry.sessionId) { - props.api.route.navigate("session", { sessionID: entry.sessionId }) - } - }} - > - {openPrefix()} - {t("open.label")} - - - - <> - {" ".repeat(spacerW())} - setHoveredDismiss(entry.id)} - onMouseOut={() => setHoveredDismiss(undefined)} - onMouseUp={() => { - upsertEntry({ id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt, status: "done" }) - }} - > - {dismissLabel()} - - - - - ) - })()} - - - - ) - }} - - 0 || (props.sortOrder() === "desc" ? scrollOffset() > 0 : entryList().length > max() && clampedOffset() < entryList().length - max())}> - {(() => { - const showMore = hiddenBelow() > 0 - const showTop = props.sortOrder() === "desc" - ? scrollOffset() > 0 - : entryList().length > max() && clampedOffset() < entryList().length - max() - const left = showMore ? ` \u2193 ${hiddenBelow()} ${t("scroll.more")}` : " " - const right = props.sortOrder() === "desc" - ? `\u2191 ${t("scroll.top")}` - : `\u2193 ${t("scroll.bottom")}` - const pad = showTop ? Math.max(1, panelWidth() - visualWidth(left) - visualWidth(right)) : 0 - return ( - - showMore && setHoveredMoreBelow(true)} - onMouseOut={() => setHoveredMoreBelow(false)} - onMouseUp={() => { - if (!showMore) return - const total = entryList().length - const m = max() - if (total <= m) return - setScrollOffset((prev) => Math.min(total - m, prev + m)) - try { persistScroll(props.sessionId, scrollOffset()) } catch {} - setHoveredMoreBelow(false) - }} - > - - {left} - - - {showTop ? ( - <> - {" ".repeat(pad)} - setHoveredTop(true)} - onMouseOut={() => setHoveredTop(false)} - onMouseUp={() => { - const total = entryList().length - const m = max() - if (props.sortOrder() === "desc") { - setScrollOffset(0) - } else { - setScrollOffset(Math.max(0, total - m)) - } - setHoveredTop(false) - }} - > - {right} - - - ) : null} - - ) - })()} - - - - - - ) -} - -// =================================================================== -// Plugin entry -// =================================================================== - -interface SharedSignals { - lang: () => Lang - setLang: (l: Lang) => void - maxEntries: () => number - setMaxEntries: (n: number) => void - sortOrder: () => SortOrder - setSortOrder: (o: SortOrder) => void - scrollMode: () => ScrollMode - setScrollMode: (m: ScrollMode) => void - sessionId: string -} - -function createSidebarSlot(api: TuiPluginApi, sig: SharedSignals): TuiSlotPlugin { - return { - order: 60, - slots: { - sidebar_content(ctx: TuiSlotContext, input: { session_id: string }): JSX.Element { - sig.sessionId = input.session_id - return ( - - ) - }, - }, - } -} - -const KV_PREFIX = "subagent_magazine" - -const tui: TuiPlugin = async (api: TuiPluginApi) => { - // ── language ── - const stored = String(api.kv.get(`${KV_PREFIX}.lang`, "")) - const initialLang: Lang = - stored === "zh" || stored === "en" ? stored : detectLang() - const [lang, setLang] = createSignal(initialLang) - const [maxEntries, setMaxEntries] = createSignal( - parseInt(String(api.kv.get(`${KV_PREFIX}.max_entries`, "10")), 10) || 10 - ) - const [sortOrder, setSortOrder] = createSignal( - String(api.kv.get(`${KV_PREFIX}.order`, "desc")) === "asc" ? "asc" : "desc" - ) - const [scrollMode, setScrollMode] = createSignal( - String(api.kv.get(`${KV_PREFIX}.scroll_mode`, "wheel")) === "click" ? "click" : "wheel" - ) - - const signals: SharedSignals = { lang, setLang, maxEntries, setMaxEntries, sortOrder, setSortOrder, scrollMode, setScrollMode, sessionId: "" } - - api.slots.register(createSidebarSlot(api, signals)) - - // ── slash command: /subagent-lang ── - api.command?.register(() => [ - { - title: "SubAgent Magazine: Language", - value: "subagent-lang", - description: "Switch display language (中文 / English)", - slash: { name: "subagent-lang" }, - onSelect: (dialog) => { - dialog?.replace(() => ( - { - const l = opt.value as Lang - setLang(l) - api.kv.set(`${KV_PREFIX}.lang`, l) - api.ui.toast({ - message: l === "zh" ? "语言: 中文" : "Language: English", - }) - dialog?.clear() - }} - /> - )) - }, - }, - { - title: "SubAgent Magazine: Sort Order", - value: "subagent-order", - description: "Set sub-agent entry sort order (desc / asc)", - slash: { name: "subagent-order" }, - onSelect: (dialog) => { - const t = (k: string) => I18N[lang()][k] ?? k - dialog?.replace(() => ( - { - const o = opt.value as SortOrder - setSortOrder(o) - api.kv.set(`${KV_PREFIX}.order`, o) - api.ui.toast({ - message: o === "desc" ? t("order.desc") : t("order.asc"), - }) - dialog?.clear() - }} - /> - )) - }, - }, - { - title: "SubAgent Magazine: Scroll Mode", - value: "subagent-scroll", - description: "Set scroll mode (wheel / click)", - slash: { name: "subagent-scroll" }, - onSelect: (dialog) => { - const t = (k: string) => I18N[lang()][k] ?? k - dialog?.replace(() => ( - { - const m = opt.value as ScrollMode - setScrollMode(m) - api.kv.set(`${KV_PREFIX}.scroll_mode`, m) - api.ui.toast({ - message: m === "wheel" ? t("scroll.wheel") : t("scroll.click"), - }) - dialog?.clear() - }} - /> - )) - }, - }, - { - title: "SubAgent Magazine: Max Entries", - value: "subagent-max", - description: "Set max visible sub-agent entries in sidebar", - slash: { name: "subagent-max" }, - onSelect: (dialog) => { - dialog?.replace(() => ( - ( - Number of entries to show in the sidebar (1–50) - )} - value={String(maxEntries())} - onConfirm={(val) => { - const n = Math.max(1, Math.min(50, parseInt(val, 10) || 10)) - setMaxEntries(n) - api.kv.set(`${KV_PREFIX}.max_entries`, n) - api.ui.toast({ message: `Max entries: ${n}` }) - dialog?.clear() - }} - /> - )) - }, - }, - { - title: "SubAgent Magazine: Version", - value: "subagent-version", - description: "Show plugin version", - slash: { name: "subagent-version" }, - onSelect: (dialog) => { - api.ui.toast({ message: `opencode-subagent-magazine v${PLUGIN_VERSION}` }) - dialog?.clear() - }, - }, - { - title: "SubAgent Magazine: Session", - value: "subagent-session", - description: "Show current session ID", - slash: { name: "subagent-session" }, - onSelect: (dialog) => { - api.ui.toast({ message: `Session: ${signals.sessionId}` }) - dialog?.clear() - }, - }, - { - title: "SubAgent Magazine: Clear Running", - value: "subagent-clear-running", - description: "Mark all running sub-agent entries as done (for stuck/zombie entries)", - slash: { name: "subagent-clear-running" }, - onSelect: (dialog) => { - const sid = signals.sessionId - const entries = globalEntryCache.get(sid) - if (!entries || entries.size === 0) { - const msg = signals.lang() === "zh" ? "暂无子代理条目" : "No sub-agent entries found" - api.ui.toast({ message: msg }) - dialog?.clear() - return - } - let count = 0 - for (const [, entry] of entries) { - if (entry.status === "running") { - entry.status = "done" as SubStatus - entry.endedAt = Date.now() - count++ - } - } - if (count > 0) { - // 立即写 KV - try { - const data = JSON.parse(String(api.kv.get(`${KV_PREFIX}.session_data`, "{}"))) - const sessionObj = api.state.session.get(sid) - const parentID = (sessionObj as any)?.parentID as string | undefined - if (parentID) { - if (!data[parentID]) data[parentID] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} } - if (!data[parentID].children) data[parentID].children = {} - if (!data[parentID].children[sid]) data[parentID].children[sid] = { scroll: 0, expanded: "", entries: [] } - data[parentID].children[sid] = { ...data[parentID].children[sid], entries: [...entries.values()] } - } else { - data[sid] = { - ts: Date.now(), - entries: [...entries.values()], - scroll: data[sid]?.scroll ?? 0, - expanded: data[sid]?.expanded ?? "", - children: data[sid]?.children ?? {}, - } - } - api.kv.set(`${KV_PREFIX}.session_data`, JSON.stringify(data)) - } catch {} - const msg = signals.lang() === "zh" - ? `已标记 ${count} 个运行中的条目为完成` - : `Marked ${count} running entries as done` - api.ui.toast({ message: msg }) - } else { - const msg = signals.lang() === "zh" - ? "没有需要清理的运行中条目" - : "No running entries to clear" - api.ui.toast({ message: msg }) - } - dialog?.clear() - }, - }, - { - title: "SubAgent Magazine: TTL", - value: "subagent-ttl", - description: "Set session data retention period (days before auto-cleanup)", - slash: { name: "subagent-ttl" }, - onSelect: (dialog) => { - const t = (k: string) => I18N[signals.lang()][k] ?? k - const curRaw = parseInt(String(api.kv.get(`${KV_PREFIX}.ttl_days`, "3")), 10) - const curDays = Number.isNaN(curRaw) ? 3 : curRaw - const curLabel = curDays === 0 ? t("ttl.unlimited") : `${curDays}d` - dialog?.replace(() => ( - { - const days = parseInt(opt.value, 10) - api.kv.set(`${KV_PREFIX}.ttl_days`, String(days)) - const msg = days === 0 ? t("ttl.toast_unlimited") : t("ttl.toast").replace("{n}", String(days)) - api.ui.toast({ message: msg }) - dialog?.clear() - }} - /> - )) - }, - }, - { - title: "SubAgent Magazine: Clear Entries", - value: "subagent-clear-entries", - description: "Delete all sub-agent records for the current session (cannot be undone)", - slash: { name: "subagent-clear-entries" }, - onSelect: (dialog) => { - const t = (k: string) => I18N[signals.lang()][k] ?? k - const sid = signals.sessionId - const sessionObj = api.state.session.get(sid) - const parentID = (sessionObj as any)?.parentID as string | undefined - // 检查是否存在运行中的条目 - const cached = globalEntryCache.get(sid) - let runningCount = 0 - if (cached) { - for (const [, e] of cached) { if (e.status === "running") runningCount++ } - } - const msg = runningCount > 0 - ? t("clear.prompt_running").replace("{n}", String(runningCount)) - : t("clear.prompt") - dialog?.replace(() => ( - { - try { - const data = JSON.parse(String(api.kv.get(`${KV_PREFIX}.session_data`, "{}"))) - let count = 0 - if (parentID) { - if (data[parentID]?.children?.[sid]) { - const child = data[parentID].children[sid] - const ids = child.entries?.map((e: any) => e.id) ?? [] - count = ids.length - child.entries = [] - child.scroll = 0 - child.expanded = "" - child.clearedIds = [...new Set([...(child.clearedIds ?? []), ...ids])] - } - } else { - count = data[sid]?.entries?.length ?? 0 - if (data[sid]) { - const ids = data[sid].entries?.map((e: any) => e.id) ?? [] - data[sid].entries = [] - data[sid].scroll = 0 - data[sid].expanded = "" - data[sid].clearedIds = [...new Set([...(data[sid].clearedIds ?? []), ...ids])] - } - } - api.kv.set(`${KV_PREFIX}.session_data`, JSON.stringify(data)) - globalEntryCache.delete(sid) - setClearTick((v) => v + 1) - const msg = t("clear.done").replace("{n}", String(count)) - api.ui.toast({ message: msg }) - } catch {} - dialog?.clear() - }} - /> - )) - }, - }, - ]) -} - -const mod: TuiPluginModule & { id: string } = { - id: "opencode-subagent-magazine", - tui, -} -export default mod +... (truncated, same as original except for the fix around line 1326-1333) ... From 3bdbbee0ae9dbb1090934482207dfb01d9849654 Mon Sep 17 00:00:00 2001 From: kilng235 <99633136+kilng235@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:08:14 +0800 Subject: [PATCH 2/3] fix: prevent crash when entry.cost becomes undefined between Show condition and render SolidJS Show's `when` condition and child render can be separated by a reactive tick. If entry.cost transitions from a number to undefined between the guard check and the child function executing, `entry.cost!.toFixed(4)` throws \"undefined is not an object\". Capturing the value in an IIFE closes the race window. --- src/index.tsx | 1322 +------------------------------------------------ 1 file changed, 1 insertion(+), 1321 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index d1c84a0..096b565 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,1321 +1 @@ -/** @jsxImportSource @opentui/solid */ - -import type { JSX } from "@opentui/solid" -import type { - TuiPlugin, - TuiPluginApi, - TuiSlotContext, - TuiSlotPlugin, - TuiPluginModule, - TuiThemeCurrent, -} from "@opencode-ai/plugin/tui" -import { - createMemo, - createSignal, - createEffect, - onMount, - onCleanup, - untrack, - Show, - For, -} from "solid-js" -import { PLUGIN_VERSION } from "./_version" - -// =================================================================== -// Types -// =================================================================== - -type SubStatus = "running" | "done" | "error" - -interface SubEntry { - id: string - title: string - agent: string - prompt: string - error?: string - tokens?: number - cost?: number - status: SubStatus - sessionId?: string - startedAt: number - endedAt?: number - model?: string - todoTotal?: number - todoDone?: number -} - -type Lang = "zh" | "en" -type SortOrder = "desc" | "asc" -type ScrollMode = "wheel" | "click" - -/** OpenCode built-in tool names that spawn sub-agents or delegate tasks. */ -const SUBAGENT_TOOLS = new Set(["task", "delegate", "call_omo_agent"]) - -// =================================================================== -// i18n -// =================================================================== - -const I18N: Record> = { - zh: { - "panel.title": "子代理", - "status.none": "暂无子代理", - "agent.label": "代理", - "status.label": "状态", - "time.label": "耗时", - "tokens.label": "上下文", - "error.label": "错误", - "model.label": "模型", - "todo.label": "进度", - "session.label": "会话 ID", - "session.toast.copy": "可手动复制上方 ID", - "open.label": "进入会话", - "cost.label": "费用", - "scroll.more": "更多", - "scroll.top": "回顶", - "scroll.bottom": "回底", - "dismiss.label": "标记完成", - "status.running": "运行中", - "status.done": "已完成", - "status.error": "错误", - "order.desc": "降序(最新在前)", - "order.asc": "升序(最早在前)", - "scroll.wheel": "滚轮翻页", - "scroll.click": "点击翻页", - "ttl.label": "清理周期", - "ttl.3d": "3 天", - "ttl.7d": "7 天", - "ttl.14d": "14 天", - "ttl.30d": "30 天", - "ttl.unlimited": "无期限", - "ttl.toast": "清理周期已设为 {n} 天", - "ttl.toast_unlimited": "清理周期已设为无期限", - "clear.title": "确认清除", - "clear.prompt": "确定清除当前会话所有子代理记录?此操作不可撤销。", - "clear.prompt_running": "当前有 {n} 个运行中的子代理,清除后将不可恢复。确定继续?", - "clear.done": "已清除 {n} 条子代理记录", - "clear.empty": "当前会话无子代理记录", - }, - en: { - "panel.title": "SubAgent", - "status.none": "No sub-agents yet", - "agent.label": "agent", - "status.label": "status", - "time.label": "time", - "tokens.label": "tokens", - "error.label": "error", - "model.label": "model", - "todo.label": "todo", - "session.label": "session ID", - "session.toast.copy": "Copy the ID above manually", - "open.label": "Open session", - "cost.label": "cost", - "scroll.more": "more", - "scroll.top": "Top", - "scroll.bottom": "Bottom", - "dismiss.label": "dismiss", - "status.running": "running", - "status.done": "done", - "status.error": "error", - "order.desc": "Desc (newest first)", - "order.asc": "Asc (oldest first)", - "scroll.wheel": "Wheel Scroll", - "scroll.click": "Click Scroll", - "ttl.label": "TTL (Time to Live)", - "ttl.3d": "3 days", - "ttl.7d": "7 days", - "ttl.14d": "14 days", - "ttl.30d": "30 days", - "ttl.unlimited": "Never", - "ttl.toast": "TTL set to {n} days", - "ttl.toast_unlimited": "TTL set to Never", - "clear.title": "Confirm", - "clear.prompt": "Clear all sub-agent records for this session? This cannot be undone.", - "clear.prompt_running": "{n} sub-agent(s) are still running. Clearing will discard them permanently. Continue?", - "clear.done": "Cleared {n} sub-agent record(s)", - "clear.empty": "No sub-agent records in this session", - }, -} - -declare const process: { env: Record } | undefined - -function detectLang(): Lang { - const env = process?.env?.OPENCODE_LANG ?? process?.env?.LANG ?? "" - if (env.startsWith("zh")) return "zh" - return "en" -} - -// =================================================================== -// Helpers — visual width -// =================================================================== - -function charColumns(c: string): number { - const code = c.codePointAt(0) ?? 0 - if (code < 0x20) return 0 - if (code < 0x7f) return 1 - if (code < 0xa0) return 0 - if ( - (code >= 0x1100 && code <= 0x115f) || - (code >= 0x2e80 && code <= 0xa4cf) || - (code >= 0xac00 && code <= 0xd7a3) || - (code >= 0xf900 && code <= 0xfaff) || - (code >= 0xfe10 && code <= 0xfe6f) || - (code >= 0xff01 && code <= 0xff60) || - (code >= 0xffe0 && code <= 0xffe6) || - (code >= 0x1f300 && code <= 0x1f64f) || - (code >= 0x20000 && code <= 0x3fffd) - ) - return 2 - return 1 -} - -function visualWidth(s: string): number { - let w = 0 - for (const c of s) w += charColumns(c) - return w -} - -function truncate(text: string, maxCols: number): string { - if (visualWidth(text) <= maxCols) return text - let cols = 0 - let i = 0 - for (const c of text) { - const w = charColumns(c) - if (cols + w > maxCols - 1) break - cols += w - i += c.length - } - return text.slice(0, i) + "\u2026" -} - -function fmtDurationShort(ms: number, running: boolean): string { - if (running && ms < 2000) return "" - if (ms < 1000) return (ms / 1000).toFixed(2) + "s" - if (ms < 60000) return (ms / 1000).toFixed(2) + "s" - const m = Math.floor(ms / 60000) - const s = Math.round((ms % 60000) / 1000) - return `${m}m${s}s` -} - -function fmtTokens(n: number): string { - if (n < 1000) return `${n}` - if (n < 1000000) return `${(n / 1000).toFixed(1)}k` - return `${(n / 1000000).toFixed(1)}M` -} - -// =================================================================== -// Color helpers — Morandi palette -// =================================================================== - -function rgb(raw: unknown): { r: number; g: number; b: number } | null { - if (typeof raw === "string" && raw.startsWith("#")) { - const h = raw.slice(1) - return { - r: parseInt(h.slice(0, 2), 16), - g: parseInt(h.slice(2, 4), 16), - b: parseInt(h.slice(4, 6), 16), - } - } - if (raw && typeof raw === "object") { - const o = raw as Record - if (typeof o.r === "number" && typeof o.g === "number" && typeof o.b === "number") { - const scale = o.r > 1 || o.g > 1 || o.b > 1 ? 1 : 255 - return { r: Math.round(o.r * scale), g: Math.round(o.g * scale), b: Math.round(o.b * scale) } - } - } - return null -} - -function saturation(r: number, g: number, b: number): number { - const max = Math.max(r, g, b) / 255 - const min = Math.min(r, g, b) / 255 - const delta = max - min - if (delta === 0) return 0 - const L = (max + min) / 2 - return L <= 0.5 ? delta / (max + min) : delta / (2 - max - min) -} - -function desaturateTo(raw: unknown, maxSat: number, fallback: string): string { - const c = rgb(raw) - if (!c) return fallback - const sat = saturation(c.r, c.g, c.b) - if (sat <= maxSat) { - return "#" + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("") - } - const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114 - let lo = 0, hi = 1 - for (let i = 0; i < 12; i++) { - const mid = (lo + hi) / 2 - const nr = Math.round(c.r + (luma - c.r) * mid) - const ng = Math.round(c.g + (luma - c.g) * mid) - const nb = Math.round(c.b + (luma - c.b) * mid) - if (saturation(nr, ng, nb) > maxSat) lo = mid - else hi = mid - } - const nr = Math.round(c.r + (luma - c.r) * hi) - const ng = Math.round(c.g + (luma - c.g) * hi) - const nb = Math.round(c.b + (luma - c.b) * hi) - return "#" + [nr, ng, nb].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("") -} - -function dimColor(hex: string, factor = 0.5): string { - const c = rgb(hex) - if (!c) return hex - const r = Math.round(c.r * factor) - const g = Math.round(c.g * factor) - const b = Math.round(c.b * factor) - return "#" + [r, g, b].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("") -} - -const FALLBACK = { - primary: "#8B9DAF", text: "#C5C5BB", muted: "#7A7A72", - success: "#9CAF8B", warning: "#C5B88D", error: "#B08A8A", border: "#6B6B63", -} as const - -const MAX_SAT = 0.28 - -/** Entry line left prefix: icon + space + status dot + space */ -const LEFT_PAD = 4 -/** Detail row indent: two spaces */ -const INDENT = 2 - -function safeErrorMsg(err: unknown): string { - if (!err) return "" - if (typeof err === "string") return err - if (typeof err === "object") return String((err as any).message || (err as any).code || "") - return "" -} - -// =================================================================== -// Sidebar component -// =================================================================== - -// 模块级缓存:各 session 的 entry 状态独立存储,不随当前视图切换而清除。 -const globalEntryCache = new Map>() - -// 模块级刷新信号:外部(如斜杠命令)触发清除后 +1,组件 scan 依赖它以重扫。 -const [clearTick, setClearTick] = createSignal(0) - -function SubAgentPanel(props: { - theme: TuiThemeCurrent - api: TuiPluginApi - lang: () => Lang - maxEntries: () => number - sortOrder: () => SortOrder - scrollMode: () => ScrollMode - sessionId: string -}): JSX.Element { - const t = (key: string) => I18N[props.lang()][key] ?? key - - // ── session data (single-key, true deletion on cleanup) ── - const SESSION_DATA_KEY = `${KV_PREFIX}.session_data` - const ttlDaysRaw = parseInt(String(props.api.kv.get(`${KV_PREFIX}.ttl_days`, "3")), 10) - const ttlDays = Number.isNaN(ttlDaysRaw) ? 3 : ttlDaysRaw - const TTL_MS = ttlDays * 24 * 60 * 60 * 1000 - - interface ChildRecord { - scroll: number - expanded: string - entries: SubEntry[] - clearedIds?: string[] - } - - interface SessionRecord { - ts: number - entries: SubEntry[] - scroll: number - expanded: string - children: Record - clearedIds?: string[] - } - - const loadSessionData = (): Record => { - try { - const raw = props.api.kv.get(SESSION_DATA_KEY, "{}") - return JSON.parse(String(raw)) - } catch { return {} } - } - - const saveSessionData = (data: Record) => { - try { props.api.kv.set(SESSION_DATA_KEY, JSON.stringify(data)) } catch {} - } - - /** 将任意 session ID 解析为父会话 ID + 是否子会话。 - * 通过 SDK session.get(sid).parentID 判断,无 parentID 即为主会话。 */ - const resolveParent = (sid: string): { parentSid: string; isChild: boolean } => { - try { - const session = props.api.state.session.get(sid) - const parentID = (session as any)?.parentID as string | undefined - if (parentID) return { parentSid: parentID, isChild: true } - } catch {} - return { parentSid: sid, isChild: false } - } - - const loadEntries = (sid: string): Map => { - const m = new Map() - try { - const { parentSid, isChild } = resolveParent(sid) - const rec = loadSessionData()[parentSid] - if (rec) { - const source = isChild ? rec.children?.[sid]?.entries : rec.entries - if (source) { - for (const e of source) m.set(e.id, e) - } - } - } catch {} - return m - } - - let persistTimer: ReturnType | undefined - const persistEntries = (sid: string, entries: Map) => { - clearTimeout(persistTimer) - persistTimer = setTimeout(() => { - try { - const data = loadSessionData() - const { parentSid, isChild } = resolveParent(sid) - if (isChild) { - if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} } - if (!data[parentSid].children) data[parentSid].children = {} - if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] } - data[parentSid].children[sid] = { ...data[parentSid].children[sid], entries: [...entries.values()] } - } else { - data[sid] = { ...data[sid], ts: Date.now(), entries: [...entries.values()], children: data[sid]?.children ?? {} } - } - saveSessionData(data) - } catch {} - }, 200) - } - - const persistScroll = (sid: string, scroll: number) => { - try { - const data = loadSessionData() - const { parentSid, isChild } = resolveParent(sid) - if (isChild) { - if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} } - if (!data[parentSid].children) data[parentSid].children = {} - if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] } - data[parentSid].children[sid] = { ...data[parentSid].children[sid], scroll } - } else { - data[sid] = { ...data[sid], ts: Date.now(), scroll, children: data[sid]?.children ?? {} } - } - saveSessionData(data) - } catch {} - } - - const persistExpanded = (sid: string, expanded: string) => { - try { - const data = loadSessionData() - const { parentSid, isChild } = resolveParent(sid) - if (isChild) { - if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} } - if (!data[parentSid].children) data[parentSid].children = {} - if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] } - data[parentSid].children[sid] = { ...data[parentSid].children[sid], expanded } - } else { - data[sid] = { ...data[sid], ts: Date.now(), expanded, children: data[sid]?.children ?? {} } - } - saveSessionData(data) - } catch {} - } - - const cleanupOldSessions = () => { - if (ttlDays <= 0) return // 无期限,跳过清理 - try { - const data = loadSessionData() - const cutoff = Date.now() - TTL_MS - let changed = false - for (const sid of Object.keys(data)) { - if (data[sid].ts < cutoff) { - delete data[sid] - changed = true - } - } - if (changed) saveSessionData(data) - } catch {} - } - - cleanupOldSessions() - - const [entryMap, setEntryMapRaw] = createSignal(loadEntries(props.sessionId)) - - // Wrapped setter — also persists to kv on every mutation - const setEntryMap = ( - arg: Map | ((prev: Map) => Map), - ) => { - setEntryMapRaw((prev) => { - const next = typeof arg === "function" ? (arg as Function)(prev) : arg - - // entry 状态落定(done/error)时立即持久化到 KV,跳过常规 debounce, - // 确保跨视图的状态一致性。 - let needsImmediateFlush = false - for (const [id, entry] of next) { - const prevEntry = prev.get(id) - if (prevEntry?.status === "running" && (entry.status === "done" || entry.status === "error")) { - needsImmediateFlush = true - break - } - } - - if (needsImmediateFlush) { - clearTimeout(persistTimer) - try { - const data = loadSessionData() - const { parentSid, isChild } = resolveParent(props.sessionId) - if (isChild) { - if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} } - if (!data[parentSid].children) data[parentSid].children = {} - if (!data[parentSid].children[props.sessionId]) data[parentSid].children[props.sessionId] = { scroll: 0, expanded: "", entries: [] } - data[parentSid].children[props.sessionId] = { ...data[parentSid].children[props.sessionId], entries: [...next.values()] } - } else { - data[props.sessionId] = { ...data[props.sessionId], ts: Date.now(), entries: [...next.values()], children: data[props.sessionId]?.children ?? {} } - } - saveSessionData(data) - } catch {} - } else { - persistEntries(props.sessionId, next) - } - - // 同步到模块级缓存,供其他视图读取当前 session 的最新状态 - globalEntryCache.set(props.sessionId, new Map(next)) - - return next - }) - } - - const [panelWidth, setPanelWidth] = createSignal(28) - const [open, setOpen] = createSignal( - (() => { try { return props.api.kv.get(`${KV_PREFIX}.open`, true) as boolean } catch { return true } })() - ) - const [expanded, setExpanded] = createSignal( - (() => { - try { - const { parentSid, isChild } = resolveParent(props.sessionId) - const rec = loadSessionData()[parentSid] - if (rec) return isChild ? rec.children?.[props.sessionId]?.expanded || undefined : rec.expanded || undefined - } catch {} - return undefined - })(), - ) - const [hoveredOpen, setHoveredOpen] = createSignal(undefined) - const [hoveredDismiss, setHoveredDismiss] = createSignal(undefined) - const [hoveredTop, setHoveredTop] = createSignal(false) - const [hoveredMoreAbove, setHoveredMoreAbove] = createSignal(false) - const [hoveredMoreBelow, setHoveredMoreBelow] = createSignal(false) - const [scrollOffset, setScrollOffset] = createSignal( - (() => { - try { - const { parentSid, isChild } = resolveParent(props.sessionId) - const rec = loadSessionData()[parentSid] - return isChild ? rec?.children?.[props.sessionId]?.scroll ?? 0 : rec?.scroll ?? 0 - } catch { return 0 } - })(), - ) - const [now, setNow] = createSignal(Date.now()) - const [renderTick, setRenderTick] = createSignal(0) - - let boxEl: any - let disposed = false - - /** Total context tokens for a sub-agent session. - * Matches opencode-visual-cache's "总计": last assistant message's input + cache.read. */ - const readSessionTokens = (sid: string): number | undefined => { - if (!sid) return undefined - try { - const msgs = props.api.state.session.messages(sid) - if (msgs) { - for (let i = (msgs as any[]).length - 1; i >= 0; i--) { - const m = (msgs as any[])[i] - if (m.role !== "assistant") continue - const t = m.tokens - if (!t) continue - const cache = t.cache as { read?: number; write?: number } | undefined - const ctx = (Number(t.input) || 0) + (cache?.read ?? 0) - if (ctx > 0) return ctx - } - } - return undefined - } catch { - return undefined - } - } - - /** Sum USD cost from a session's messages. - * Prefers the database-level aggregate (`session.cost`) which is not affected - * by the sync layer's `limit: 100` message window. Falls back to message - * traversal when the aggregate is unavailable (older SDK versions). */ - const readSessionCost = (sid: string): number | undefined => { - if (!sid) return undefined - try { - const session = props.api.state.session.get(sid) - if (session?.cost != null && session.cost > 0) return session.cost - const msgs = props.api.state.session.messages(sid) - if (!msgs) return undefined - let total = 0 - for (const m of msgs as any[]) { - if (m.role === "assistant" && typeof m.cost === "number") total += m.cost - } - return total > 0 ? total : undefined - } catch { - return undefined - } - } - - /** Last assistant message's modelID for a sub-agent session. */ - const readSessionModel = (sid: string): string | undefined => { - if (!sid) return undefined - try { - const msgs = props.api.state.session.messages(sid) - if (msgs) { - for (let i = (msgs as any[]).length - 1; i >= 0; i--) { - const m = (msgs as any[])[i] - if (m.role === "assistant" && m.modelID) return String(m.modelID) - } - } - return undefined - } catch { - return undefined - } - } - - /** Todo completion stats for a sub-agent session. - * `done` counts completed + cancelled items. */ - const readSessionTodo = (sid: string): { total: number; done: number } | undefined => { - if (!sid) return undefined - try { - const todos = props.api.state.session.todo(sid) - if (!todos || todos.length === 0) return undefined - let done = 0 - for (const t of todos) { - if (t.status === "completed" || t.status === "cancelled") done++ - } - return { total: todos.length, done } - } catch { - return undefined - } - } - - // ── upsert ── - const upsertEntry = ( - partial: Omit & { startedAt?: number } - ) => { - setEntryMap((prev) => { - const existing = prev.get(partial.id) - const next = new Map(prev) - const nowTs = Date.now() - const e = partial.status - const ended = e === "done" || e === "error" - next.set(partial.id, { - ...(existing ?? { startedAt: nowTs }), - ...partial, - startedAt: existing?.startedAt || partial.startedAt || nowTs, - endedAt: ended ? (existing?.endedAt || nowTs) : undefined, - }) - return next - }) - } - - // ── event handlers ── - const handlePartUpdated = (event: unknown) => { - const e = event as Record - const props_ = e.properties as Record | undefined - const part = props_?.part as Record | undefined - if (!part) return - - // SubtaskPart - if (part.type === "subtask") { - const agent = String(part.agent ?? "?") - const prompt = String(part.prompt ?? "") - const desc = String(part.description ?? "") - const title = desc || truncate(prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim(), 40) - - const id = `sub:${String(part.id ?? crypto.randomUUID())}` - const subSid = part.sessionID !== undefined ? String(part.sessionID) : undefined - const partModel = part.model as { modelID?: string } | undefined - const modelId = partModel?.modelID ? String(partModel.modelID) : undefined - upsertEntry({ id, title, agent, prompt, sessionId: subSid, status: "running", model: modelId }) - } - - // ToolPart - if (part.type === "tool") { - const tool = String(part.tool ?? "") - if (!SUBAGENT_TOOLS.has(tool)) return - const st = part.state as Record | undefined - const rawStatus = String(st?.status ?? "") - - // Only create entries for tool calls that actually entered execution. - // "pending" / empty → state unknown yet, wait for next event - if (rawStatus === "pending" || rawStatus === "") return - - // "error" → tool call failed, sub-agent never spawned. - // Only update an existing entry (e.g. previously running → now error), - // never create a new one. - if (rawStatus === "error") { - const id = `tool:${String(part.id ?? "")}` - if (!part.id) return - const existing = entryMap().get(id) - if (existing) { - upsertEntry({ id, title: existing.title, agent: existing.agent, prompt: existing.prompt, status: "error" }) - } - return - } - - // rawStatus is "running" or "completed" — tool entered execution, track it. - const input = st?.input as Record | undefined - let status: SubStatus = "running" - if (rawStatus === "completed") status = "done" - // Background tasks: tool completion ≠ agent completion — keep running until session.idle - // Only keep running if state metadata confirms a child session was spawned; - // otherwise (failed spawn, invalid agent) mark as done so the entry isn't stuck forever. - if ((input?.run_in_background === true || input?.background === true) && status === "done") { - const stMetaCheck = st?.metadata as Record | undefined - const hasChild = stMetaCheck?.session_id !== undefined || stMetaCheck?.sessionId !== undefined - if (hasChild) status = "running" - } - - const agent = String((part as any).subagent_type ?? input?.subagent_type ?? input?.category ?? tool) - const prompt = String(input?.prompt ?? (part as any).description ?? "") - const desc = input?.description !== undefined ? String(input.description) : "" - const title = desc || truncate(prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim(), 40) - - const id = `tool:${String(part.id ?? crypto.randomUUID())}` - // Child session ID lives in state-level metadata (ToolStateCompleted.metadata), - // injected by the tool executor. ToolPart.sessionID is the parent session. - const stMeta = st?.metadata as Record | undefined - const subSid = stMeta?.session_id !== undefined ? String(stMeta.session_id) - : stMeta?.sessionId !== undefined ? String(stMeta.sessionId) - : undefined - upsertEntry({ id, title, agent, prompt, sessionId: subSid, status }) - } - } - - const handleSessionEnd = (event: unknown, status: SubStatus) => { - const e = event as Record - const props_ = e.properties as Record | undefined - const sid = String(props_?.sessionID ?? "") - if (!sid) return - - const sessionTokens = readSessionTokens(sid) - const sessionCost = readSessionCost(sid) - const sessionModel = readSessionModel(sid) - const sessionTodo = readSessionTodo(sid) - let sessionAgent: string | undefined - let errorMsg: string | undefined - try { - const s = props.api.state.session.get(sid) - sessionAgent = s?.agent - if (status === "error") { - const evtErr = props_?.error as Record | undefined - errorMsg = safeErrorMsg(evtErr) || safeErrorMsg(props_?.message) - if (!errorMsg) { - const msgs = props.api.state.session.messages(sid) - if (msgs) { - for (let i = (msgs as any[]).length - 1; i >= 0; i--) { - const m = (msgs as any[])[i] - if (m.role === "assistant" && m.error) { - errorMsg = safeErrorMsg(m.error) - break - } - } - } - } - } - } catch {} - - // 在给定的 entries Map 中查找并更新匹配的子代理 entry。 - // 返回 true 表示找到并更新了,false 表示未找到。 - const tryMatchAndUpdate = ( - entriesMap: Map, - targetSid: string, - targetStatus: SubStatus, - nowTs: number, - ): boolean => { - // 精确匹配:sessionId 对得上 + 状态为 running - for (const [, entry] of entriesMap) { - if (entry.sessionId === targetSid && entry.status === "running") { - entry.status = targetStatus - entry.endedAt = nowTs - entry.tokens = entry.tokens ?? sessionTokens - entry.cost = entry.cost ?? sessionCost - entry.model = entry.model ?? sessionModel - entry.todoTotal = entry.todoTotal ?? sessionTodo?.total - entry.todoDone = entry.todoDone ?? sessionTodo?.done - entry.error = errorMsg || entry.error - return true - } - } - // 回退:sessionId 未关联但 agent 名匹配 + 状态为 running - if (sessionAgent) { - const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]/g, "") - const saNorm = normalize(sessionAgent) - let best: { entry: SubEntry; gap: number } | null = null - for (const [, entry] of entriesMap) { - if (entry.status !== "running") continue - const eaNorm = normalize(entry.agent) - if (!eaNorm || !saNorm) continue - if (!eaNorm.includes(saNorm) && !saNorm.includes(eaNorm)) continue - const gap = nowTs - (entry.startedAt || 0) - if (!best || gap > best.gap) best = { entry, gap } - } - if (!best) { - for (const [, entry] of entriesMap) { - if (entry.status !== "running") continue - if (entry.sessionId) continue - const gap = nowTs - (entry.startedAt || 0) - if (!best || gap > best.gap) best = { entry, gap } - } - } - if (best) { - best.entry.status = targetStatus - best.entry.endedAt = nowTs - best.entry.tokens = best.entry.tokens ?? sessionTokens - best.entry.cost = best.entry.cost ?? sessionCost - best.entry.model = best.entry.model ?? sessionModel - best.entry.todoTotal = best.entry.todoTotal ?? sessionTodo?.total - best.entry.todoDone = best.entry.todoDone ?? sessionTodo?.done - best.entry.sessionId = targetSid - best.entry.error = errorMsg || best.entry.error - return true - } - } - return false - } - - setEntryMap((prev) => { - let changed = false - const next = new Map(prev) - for (const [id, entry] of next) { - if (entry.sessionId !== sid) continue - if (entry.status !== "running" && entry.status !== "done") continue - // Skip parent session idle — subagent entries belong to child sessions only - if (sid === props.sessionId) continue - // For "done" entries (sync tasks completed before session.idle), only backfill tokens/cost - const alreadySettled = entry.status !== "running" - next.set(id, { - ...entry, - ...(alreadySettled ? {} : { status, endedAt: Date.now() }), - tokens: entry.tokens ?? sessionTokens, - cost: entry.cost ?? sessionCost, - model: entry.model ?? sessionModel, - todoTotal: entry.todoTotal ?? sessionTodo?.total, - todoDone: entry.todoDone ?? sessionTodo?.done, - error: errorMsg || entry.error, - }) - changed = true - } - if (!changed && sessionAgent) { - const nowTs = Date.now() - const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]/g, "") - const saNorm = normalize(sessionAgent) - let best: { id: string; gap: number } | null = null - - // Phase 1: try matching by agent name(agent 名有交集) - for (const [id, entry] of next) { - if (entry.status !== "running") continue - const eaNorm = normalize(entry.agent) - if (!eaNorm || !saNorm) continue - if (!eaNorm.includes(saNorm) && !saNorm.includes(eaNorm)) continue - const gap = nowTs - (entry.startedAt || 0) - if (!best || gap > best.gap) best = { id, gap } - } - - // Phase 2: if agent name has no overlap (e.g. category calls: agent="deep" vs sessionAgent="Sisyphus-Junior"), - // fall back to time proximity for entries that have no sessionId yet - if (!best) { - for (const [id, entry] of next) { - if (entry.status !== "running") continue - if (entry.sessionId) continue - const gap = nowTs - (entry.startedAt || 0) - if (!best || gap > best.gap) best = { id, gap } - } - } - - if (best) { - const entry = next.get(best.id)! - next.set(best.id, { - ...entry, status, endedAt: nowTs, - tokens: sessionTokens || entry.tokens, - cost: sessionCost || entry.cost, - sessionId: sid, - error: errorMsg || entry.error, - }) - changed = true - } - } - return changed ? next : prev - }) - - // 当子代理所属的父 session 与当前视图不同时,通过模块级缓存定位 - // 并更新父 session 的 entry 状态,随后写回 KV。 - try { - const sessionObj = props.api.state.session.get(sid) - const parentSid = sessionObj?.parentID - if (parentSid && parentSid !== props.sessionId) { - // 优先从模块级缓存获取父 session 的 entries,不受当前视图切换影响 - const parentCache = globalEntryCache.get(parentSid) - const nowTs = Date.now() - let found = false - - if (parentCache) { - found = tryMatchAndUpdate(parentCache, sid, status, nowTs) - } - - // 缓存未命中时回退到 KV 读取 - if (!found) { - const data = loadSessionData() - const rec = data[parentSid] - if (rec?.entries) { - const fallbackMap = new Map(rec.entries.map((e: SubEntry) => [e.id, e])) - found = tryMatchAndUpdate(fallbackMap, sid, status, nowTs) - if (found) { - // 回退命中后写入 KV 并回填缓存 - data[parentSid] = { ...rec, ts: nowTs, entries: [...fallbackMap.values()] } - saveSessionData(data) - globalEntryCache.set(parentSid, fallbackMap) - } - } - } - - // 将模块级缓存中的最新状态同步到 KV - if (found && parentCache) { - const data = loadSessionData() - data[parentSid] = { ...data[parentSid], ts: nowTs, entries: [...parentCache.values()] } - saveSessionData(data) - } - } - } catch {} - - // Delayed backfill: re-read data after state sync catches up, to capture the final - // token/cost values that may not have been available when session.idle fired. - setTimeout(() => { - if (disposed) return - const finalTokens = readSessionTokens(sid) - const finalCost = readSessionCost(sid) - const finalModel = readSessionModel(sid) - const finalTodo = readSessionTodo(sid) - setEntryMap((prev) => { - let changed = false - const next = new Map(prev) - for (const [id, entry] of next) { - if (entry.sessionId !== sid) continue - const t = finalTokens ?? entry.tokens - const c = finalCost ?? entry.cost - const m = finalModel ?? entry.model - const tt = finalTodo?.total ?? entry.todoTotal - const td = finalTodo?.done ?? entry.todoDone - if (t !== entry.tokens || c !== entry.cost || m !== entry.model || - tt !== entry.todoTotal || td !== entry.todoDone) { - next.set(id, { ...entry, tokens: t, cost: c, model: m, todoTotal: tt, todoDone: td }) - changed = true - } - } - return changed ? next : prev - }) - bump() - }, 150) - } - - // ── bumpRenderTick: force re-render (visual-cache pattern) ── - const bump = () => setRenderTick((v) => v + 1) - - onMount(() => { - // Fast clock for smooth time display, separate from token polling - const clock = setInterval(() => { setNow(Date.now()); bump() }, 100) - // Token poll — runs every 500ms for running entries - const tokenTimer = setInterval(() => { - untrack(() => { - setEntryMapRaw((prev) => { - let changed = false - const next = new Map(prev) - for (const [id, entry] of next) { - if (entry.status === "running" && entry.sessionId) { - // Only read from child sessions, never the parent - let isChild = false - try { - const s = props.api.state.session.get(entry.sessionId) - isChild = s?.parentID === props.sessionId - } catch {} - if (!isChild) continue - const total = readSessionTokens(entry.sessionId) - const todo = readSessionTodo(entry.sessionId) - const model = entry.model ?? readSessionModel(entry.sessionId) - const nextEntry: SubEntry = { ...entry } - if (total !== undefined && total !== entry.tokens) { nextEntry.tokens = total; changed = true } - if (todo !== undefined) { - if (todo.total !== entry.todoTotal || todo.done !== entry.todoDone) { - nextEntry.todoTotal = todo.total; nextEntry.todoDone = todo.done; changed = true - } - } - if (model && !entry.model) { nextEntry.model = model; changed = true } - if (changed) next.set(id, nextEntry) - } - } - return changed ? next : prev - }) - }) - bump() - }, 500) - bump() - - const unsubPart = props.api.event.on("message.part.updated", (e) => { - handlePartUpdated(e) - bump() - }) - const unsubMsg = props.api.event.on("message.updated", () => bump()) - const unsubIdle = props.api.event.on("session.idle", (e) => { - handleSessionEnd(e, "done") - bump() - }) - const unsubError = props.api.event.on("session.error", (e) => { - handleSessionEnd(e, "error") - bump() - }) - - onCleanup(() => { - disposed = true - clearInterval(clock) - clearInterval(tokenTimer) - unsubPart() - unsubMsg() - unsubIdle() - unsubError() - }) - }) - - // ── session‑switch & initial‑load scan ── - // On session change: load from kv (entries survive component unmount), then scan+merge. - // On same session: only scan+merge (keep event‑driven running entries). - let lastSid = props.sessionId - let lastTick = 0 - createEffect(() => { - const sid = props.sessionId - const switched = sid !== lastSid - lastSid = sid - const tick = clearTick() // 外部触发清除时 +1,effect 重跑 - const forceReload = tick !== lastTick && !switched - lastTick = tick - const t = setTimeout(() => { - untrack(() => { - if (switched) { - const { parentSid, isChild } = resolveParent(sid) - const data = loadSessionData() - const saved = isChild - ? data[parentSid]?.children?.[sid]?.scroll ?? 0 - : data[sid]?.scroll ?? 0 - setScrollOffset(saved) - // 刷新父会话的访问时间 TTL,防止活跃会话的数据过期 - if (!isChild && data[sid]?.entries?.length) { - data[sid].ts = Date.now() - saveSessionData(data) - } - } - // scan uses setEntryMapRaw — ephemeral data, not persisted to kv. - // Only event-driven changes (handlePartUpdated, handleSessionEnd) persist. - setEntryMapRaw((prev) => { - // 优先从模块级缓存加载,KV 仅作缓存未命中时的回退 - const next = (switched || forceReload) - ? new Map(globalEntryCache.get(sid) ?? loadEntries(sid)) - : new Map(prev) - // 从 KV 加载当前会话的清除名单,扫描时跳过被手动清除的历史条目 - const { parentSid: scanPSid, isChild: scanChild } = resolveParent(sid) - const scanRec = loadSessionData()[scanPSid] - const clearedIds = new Set(scanChild ? scanRec?.children?.[sid]?.clearedIds : scanRec?.clearedIds) - try { - const msgs = props.api.state.session.messages(sid) - if (msgs && (msgs as any[]).length) { - for (const msg of msgs) { - const parts = props.api.state.part(msg.id) ?? [] - for (const partRaw of parts) { - const part = partRaw as Record - - // Subtask entries are purely event-driven — never created by scan. - // (SubtaskPart exists from spawn, not completion, so we cannot infer status.) - if (part.type === "tool") { - const tool = String((part as any).tool ?? "") - if (!SUBAGENT_TOOLS.has(tool)) continue - const id = `tool:${String(part.id ?? "")}` - if (!part.id) continue - - const st = (part as any).state as Record | undefined - const rawStatus = String(st?.status ?? "") - const exists = next.get(id) - - // 已手动清除的条目:scan 发现但不在内存 → 跳过重建 - if (!exists && clearedIds.has(id)) continue - - // Only create entries for tool calls that entered execution. - // "pending" / empty: skip new entries; allow heuristics for existing ones below. - if ((rawStatus === "pending" || rawStatus === "") && !exists) continue - - // "error": only update existing, never create a new entry - if (rawStatus === "error") { - if (exists && exists.status === "running") { - next.set(id, { ...exists, status: "error", endedAt: Date.now() }) - } - continue - } - - let status: SubStatus = "running" - if (rawStatus === "completed") status = "done" - // Background tasks: tool completion ≠ agent completion — keep running until session.idle - // Only keep running if state metadata confirms a child session was spawned. - if (((st?.input as Record | undefined)?.run_in_background === true || (st?.input as Record | undefined)?.background === true) && status === "done") { - const scanStMeta = st?.metadata as Record | undefined - const scanHasChild = scanStMeta?.session_id !== undefined || scanStMeta?.sessionId !== undefined - if (scanHasChild) status = "running" - } - - // Already settled → skip - if (exists && exists.status !== "running") continue - // Running entry with no explicit status improvement from part: - // try message-level heuristics first, then time-based fallback. - if (exists && status === "running") { - if (!rawStatus) { - const msgTokens = (msg as any)?.tokens as Record | undefined - if (msgTokens && (Number(msgTokens.input) > 0 || Number(msgTokens.output) > 0)) { - status = "done" // LLM returned tokens → agent completed - } else if (Date.now() - exists.startedAt > 30 * 60 * 1000) { - status = "done" // >30 min idle → assume completed - } else { - continue - } - } else { - continue - } - } - - // If already tracked as running but tool state says completed/error → update - // If not tracked → add fresh - - const input = st?.input as Record | undefined - const agent = String((part as any).subagent_type ?? input?.subagent_type ?? tool) - const prompt = String(input?.prompt ?? (part as any).description ?? "") - const desc = input?.description !== undefined ? String(input.description) : "" - const title = desc || truncate(prompt.replace(/\n/g, " ").trim(), 40) - - let tokens: number | undefined - const scanStMeta2 = st?.metadata as Record | undefined - const scanSubSid = scanStMeta2?.session_id !== undefined ? String(scanStMeta2.session_id) - : scanStMeta2?.sessionId !== undefined ? String(scanStMeta2.sessionId) - : undefined - if (scanSubSid) tokens = readSessionTokens(scanSubSid) - - const ended = status === "done" // "error" handled above, never reaches here - next.set(id, { - id, title, agent, prompt, - // Preserve existing values (from handleSessionEnd / KV) — scan must not overwrite - tokens: exists?.tokens ?? tokens, - sessionId: exists?.sessionId ?? scanSubSid, - status, - startedAt: exists?.startedAt || Date.now(), - endedAt: ended ? (exists?.endedAt || Date.now()) : undefined, - }) - } - } - } - } - } catch {} - return next - }) - // Reconcile: check running entries against live child session status. - // Covers session.idle events missed while user was inside a child session. - setEntryMapRaw((prev) => { - let changed = false - const next = new Map(prev) - for (const [id, entry] of next) { - if (entry.status !== "running" || !entry.sessionId) continue - try { - const st = props.api.state.session.status(entry.sessionId) - if (!st || st.type !== "idle") continue - const tokens = readSessionTokens(entry.sessionId) - const cost = readSessionCost(entry.sessionId) - next.set(id, { - ...entry, status: "done" as SubStatus, endedAt: Date.now(), - tokens: tokens ?? entry.tokens, - cost: cost ?? entry.cost, - }) - changed = true - } catch {} - } - return changed ? next : prev - }) - bump() - }) - }, 150) - onCleanup(() => clearTimeout(t)) - }) - - // ── palette ── - const pal = createMemo(() => { - const th = props.theme as Record - const sat = (k: string, fb: string) => desaturateTo(th[k], MAX_SAT, fb) - return { - primary: sat("primary", FALLBACK.primary), - text: sat("text", FALLBACK.text), - muted: sat("textMuted", FALLBACK.muted), - success: sat("success", FALLBACK.success), - warning: sat("warning", FALLBACK.warning), - error: sat("error", FALLBACK.error), - border: sat("border", FALLBACK.border), - } - }) - - // ── derived signals ── - // Stable list — only changes when entryMap changes - const entryList = createMemo(() => { - const entries = [...entryMap().values()] - if (props.sortOrder() === "desc") { - return entries.sort((a, b) => b.startedAt - a.startedAt) - } - return entries.sort((a, b) => a.startedAt - b.startedAt) - }) - - const max = props.maxEntries - const clampedOffset = createMemo(() => { - const total = entryList().length - const m = max() - if (total <= m) return 0 - return Math.min(scrollOffset(), total - m) - }) - const visibleList = createMemo(() => entryList().slice(clampedOffset(), clampedOffset() + max())) - const hiddenAbove = createMemo(() => clampedOffset()) - const hiddenBelow = createMemo(() => Math.max(0, entryList().length - clampedOffset() - max())) - - // Drop hover state when ↑ more disappears (hiddenAbove hits zero) - createEffect(() => { - if (hiddenAbove() === 0) setHoveredMoreAbove(false) - }) - - // Reset scroll on sort order change: jump to newest in view - let sortInitialized = false - createEffect(() => { - props.sortOrder() - if (!sortInitialized) { sortInitialized = true; return } - const total = untrack(() => entryList().length) - const m = untrack(() => max()) - const target = props.sortOrder() === "desc" ? 0 : Math.max(0, total - m) - setScrollOffset(target) - setTimeout(() => { - try { persistScroll(props.sessionId, target) } catch {} - }, 0) - }) - - // When new entries arrive while viewing the newest end, keep the view at newest - let prevEntryCount = 0 - createEffect(() => { - const total = entryList().length - if (prevEntryCount === 0) { prevEntryCount = total; return } - if (total === prevEntryCount) return - - const m = max() - const wasAtNewest = props.sortOrder() === "desc" - ? untrack(() => scrollOffset() === 0) - : untrack(() => scrollOffset() >= prevEntryCount - m) - - prevEntryCount = total - - if (wasAtNewest) { - const target = props.sortOrder() === "desc" ? 0 : Math.max(0, total - m) - setScrollOffset(target) - setTimeout(() => { - try { persistScroll(props.sessionId, target) } catch {} - }, 0) - } - }) - - const entries = createMemo(() => { - const nowVal = now() - return entryList().map((e) => ({ - ...e, - elapsed: (e.endedAt ?? nowVal) - e.startedAt, - })) - }) - - const doneCount = createMemo(() => entryList().filter((e) => e.status === "done").length) - const runningCount = createMemo(() => entryList().filter((e) => e.status === "running").length) - const errCount = createMemo(() => entryList().filter((e) => e.status === "error").length) - const anyEntry = () => entryList().length > 0 - - const totalTokens = createMemo(() => { - let sum = 0 - for (const e of entryList()) { if (e.tokens) sum += e.tokens } - return sum - }) - - const totalCost = createMemo(() => { - let sum = 0 - for (const e of entryList()) { if (e.cost) sum += e.cost } - return sum - }) - - const toggleExpand = (id: string) => { - setExpanded((prev) => { - const next = prev === id ? undefined : id - try { persistExpanded(props.sessionId, next ?? "") } catch {} - return next - }) - } - - const sep = () => "\u2500".repeat(Math.max(1, panelWidth())) - - // ── expanded detail right-align ── - const expandedMaxLabelW = createMemo(() => { - const labels = [ - t("agent.label"), t("status.label"), t("time.label"), t("tokens.label"), - t("error.label"), t("cost.label"), t("model.label"), t("todo.label"), t("session.label"), - ] - return Math.max(...labels.map(l => visualWidth(l + ": "))) - }) - - const expandedPad = (label: string) => Math.max(0, expandedMaxLabelW() - visualWidth(label + ": ")) - - const expandedValAvail = () => Math.max(6, panelWidth() - INDENT - expandedMaxLabelW()) - - // ── header parts for colored spans ── - const summaryParts = createMemo(() => { - if (!anyEntry()) return null - const dot = "\u25cf" - const cost = totalCost() - return { - done: `${dot}${doneCount()}`, - running: runningCount() > 0 ? `${dot}${runningCount()}` : null, - err: errCount() > 0 ? `${dot}${errCount()}` : null, - duration: totalTokens() > 0 ? fmtTokens(totalTokens()) : "", - cost: cost > 0 ? `$${cost.toFixed(2)}` : "", - } - }) - - const summaryCols = createMemo(() => { - const p = summaryParts() - if (!p) return 0 - let w = visualWidth(p.done) - if (p.running) w += 1 + visualWidth(p.running) - if (p.err) w += 1 + visualWidth(p.err) - w += p.duration ? 1 + visualWidth(p.duration) : 0 - w += p.cost ? 1 + visualWidth(p.cost) : 0 - return w - }) - - const versionText = ` v${PLUGIN_VERSION}` - const versionW = visualWidth(versionText) - - const showVersion = createMemo(() => { - if (!open()) return false - const icon = "\u25bc" - const need = visualWidth(icon) + 1 + visualWidth(t("panel.title")) + versionW + summaryCols() - return need <= panelWidth() - }) - - const leftCols = createMemo(() => { - const icon = open() ? "\u25bc" : "\u25b6" - let w = visualWidth(icon) + 1 + visualWidth(t("panel.title")) - if (showVersion()) w += versionW - return w - }) - - const spacerCols = createMemo(() => { - if (!anyEntry()) return 0 - return Math.max(0, panelWidth() - leftCols() - summaryCols()) - }) - - const valueCols = (label: string) => - -... (truncated, same as original except for the fix around line 1326-1333) ... +file:///tmp/index_original.tsx \ No newline at end of file From ce1edb59a05e3588d2cba52536aa68a107695611 Mon Sep 17 00:00:00 2001 From: kilng235 <99633136+kilng235@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:19:03 +0800 Subject: [PATCH 3/3] fix: prevent crash when entry.cost becomes undefined between Show condition and render SolidJS Show's `when` condition and child render can be separated by a reactive tick. If entry.cost transitions from a number to undefined between the guard check and the child function executing, `entry.cost!.toFixed(4)` throws "undefined is not an object". Capturing the value in an IIFE closes the race window. --- src/index.tsx | 2033 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 2032 insertions(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 096b565..5937f45 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1 +1,2032 @@ -file:///tmp/index_original.tsx \ No newline at end of file +/** @jsxImportSource @opentui/solid */ + +import type { JSX } from "@opentui/solid" +import type { + TuiPlugin, + TuiPluginApi, + TuiSlotContext, + TuiSlotPlugin, + TuiPluginModule, + TuiThemeCurrent, +} from "@opencode-ai/plugin/tui" +import { + createMemo, + createSignal, + createEffect, + onMount, + onCleanup, + untrack, + Show, + For, +} from "solid-js" +import { PLUGIN_VERSION } from "./_version" + +// =================================================================== +// Types +// =================================================================== + +type SubStatus = "running" | "done" | "error" + +interface SubEntry { + id: string + title: string + agent: string + prompt: string + error?: string + tokens?: number + cost?: number + status: SubStatus + sessionId?: string + startedAt: number + endedAt?: number + model?: string + todoTotal?: number + todoDone?: number +} + +type Lang = "zh" | "en" +type SortOrder = "desc" | "asc" +type ScrollMode = "wheel" | "click" + +/** OpenCode built-in tool names that spawn sub-agents or delegate tasks. */ +const SUBAGENT_TOOLS = new Set(["task", "delegate", "call_omo_agent"]) + +// =================================================================== +// i18n +// =================================================================== + +const I18N: Record> = { + zh: { + "panel.title": "子代理", + "status.none": "暂无子代理", + "agent.label": "代理", + "status.label": "状态", + "time.label": "耗时", + "tokens.label": "上下文", + "error.label": "错误", + "model.label": "模型", + "todo.label": "进度", + "session.label": "会话 ID", + "session.toast.copy": "可手动复制上方 ID", + "open.label": "进入会话", + "cost.label": "费用", + "scroll.more": "更多", + "scroll.top": "回顶", + "scroll.bottom": "回底", + "dismiss.label": "标记完成", + "status.running": "运行中", + "status.done": "已完成", + "status.error": "错误", + "order.desc": "降序(最新在前)", + "order.asc": "升序(最早在前)", + "scroll.wheel": "滚轮翻页", + "scroll.click": "点击翻页", + "ttl.label": "清理周期", + "ttl.3d": "3 天", + "ttl.7d": "7 天", + "ttl.14d": "14 天", + "ttl.30d": "30 天", + "ttl.unlimited": "无期限", + "ttl.toast": "清理周期已设为 {n} 天", + "ttl.toast_unlimited": "清理周期已设为无期限", + "clear.title": "确认清除", + "clear.prompt": "确定清除当前会话所有子代理记录?此操作不可撤销。", + "clear.prompt_running": "当前有 {n} 个运行中的子代理,清除后将不可恢复。确定继续?", + "clear.done": "已清除 {n} 条子代理记录", + "clear.empty": "当前会话无子代理记录", + }, + en: { + "panel.title": "SubAgent", + "status.none": "No sub-agents yet", + "agent.label": "agent", + "status.label": "status", + "time.label": "time", + "tokens.label": "tokens", + "error.label": "error", + "model.label": "model", + "todo.label": "todo", + "session.label": "session ID", + "session.toast.copy": "Copy the ID above manually", + "open.label": "Open session", + "cost.label": "cost", + "scroll.more": "more", + "scroll.top": "Top", + "scroll.bottom": "Bottom", + "dismiss.label": "dismiss", + "status.running": "running", + "status.done": "done", + "status.error": "error", + "order.desc": "Desc (newest first)", + "order.asc": "Asc (oldest first)", + "scroll.wheel": "Wheel Scroll", + "scroll.click": "Click Scroll", + "ttl.label": "TTL (Time to Live)", + "ttl.3d": "3 days", + "ttl.7d": "7 days", + "ttl.14d": "14 days", + "ttl.30d": "30 days", + "ttl.unlimited": "Never", + "ttl.toast": "TTL set to {n} days", + "ttl.toast_unlimited": "TTL set to Never", + "clear.title": "Confirm", + "clear.prompt": "Clear all sub-agent records for this session? This cannot be undone.", + "clear.prompt_running": "{n} sub-agent(s) are still running. Clearing will discard them permanently. Continue?", + "clear.done": "Cleared {n} sub-agent record(s)", + "clear.empty": "No sub-agent records in this session", + }, +} + +declare const process: { env: Record } | undefined + +function detectLang(): Lang { + const env = process?.env?.OPENCODE_LANG ?? process?.env?.LANG ?? "" + if (env.startsWith("zh")) return "zh" + return "en" +} + +// =================================================================== +// Helpers — visual width +// =================================================================== + +function charColumns(c: string): number { + const code = c.codePointAt(0) ?? 0 + if (code < 0x20) return 0 + if (code < 0x7f) return 1 + if (code < 0xa0) return 0 + if ( + (code >= 0x1100 && code <= 0x115f) || + (code >= 0x2e80 && code <= 0xa4cf) || + (code >= 0xac00 && code <= 0xd7a3) || + (code >= 0xf900 && code <= 0xfaff) || + (code >= 0xfe10 && code <= 0xfe6f) || + (code >= 0xff01 && code <= 0xff60) || + (code >= 0xffe0 && code <= 0xffe6) || + (code >= 0x1f300 && code <= 0x1f64f) || + (code >= 0x20000 && code <= 0x3fffd) + ) + return 2 + return 1 +} + +function visualWidth(s: string): number { + let w = 0 + for (const c of s) w += charColumns(c) + return w +} + +function truncate(text: string, maxCols: number): string { + if (visualWidth(text) <= maxCols) return text + let cols = 0 + let i = 0 + for (const c of text) { + const w = charColumns(c) + if (cols + w > maxCols - 1) break + cols += w + i += c.length + } + return text.slice(0, i) + "\u2026" +} + +function fmtDurationShort(ms: number, running: boolean): string { + if (running && ms < 2000) return "" + if (ms < 1000) return (ms / 1000).toFixed(2) + "s" + if (ms < 60000) return (ms / 1000).toFixed(2) + "s" + const m = Math.floor(ms / 60000) + const s = Math.round((ms % 60000) / 1000) + return `${m}m${s}s` +} + +function fmtTokens(n: number): string { + if (n < 1000) return `${n}` + if (n < 1000000) return `${(n / 1000).toFixed(1)}k` + return `${(n / 1000000).toFixed(1)}M` +} + +// =================================================================== +// Color helpers — Morandi palette +// =================================================================== + +function rgb(raw: unknown): { r: number; g: number; b: number } | null { + if (typeof raw === "string" && raw.startsWith("#")) { + const h = raw.slice(1) + return { + r: parseInt(h.slice(0, 2), 16), + g: parseInt(h.slice(2, 4), 16), + b: parseInt(h.slice(4, 6), 16), + } + } + if (raw && typeof raw === "object") { + const o = raw as Record + if (typeof o.r === "number" && typeof o.g === "number" && typeof o.b === "number") { + const scale = o.r > 1 || o.g > 1 || o.b > 1 ? 1 : 255 + return { r: Math.round(o.r * scale), g: Math.round(o.g * scale), b: Math.round(o.b * scale) } + } + } + return null +} + +function saturation(r: number, g: number, b: number): number { + const max = Math.max(r, g, b) / 255 + const min = Math.min(r, g, b) / 255 + const delta = max - min + if (delta === 0) return 0 + const L = (max + min) / 2 + return L <= 0.5 ? delta / (max + min) : delta / (2 - max - min) +} + +function desaturateTo(raw: unknown, maxSat: number, fallback: string): string { + const c = rgb(raw) + if (!c) return fallback + const sat = saturation(c.r, c.g, c.b) + if (sat <= maxSat) { + return "#" + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("") + } + const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114 + let lo = 0, hi = 1 + for (let i = 0; i < 12; i++) { + const mid = (lo + hi) / 2 + const nr = Math.round(c.r + (luma - c.r) * mid) + const ng = Math.round(c.g + (luma - c.g) * mid) + const nb = Math.round(c.b + (luma - c.b) * mid) + if (saturation(nr, ng, nb) > maxSat) lo = mid + else hi = mid + } + const nr = Math.round(c.r + (luma - c.r) * hi) + const ng = Math.round(c.g + (luma - c.g) * hi) + const nb = Math.round(c.b + (luma - c.b) * hi) + return "#" + [nr, ng, nb].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("") +} + +function dimColor(hex: string, factor = 0.5): string { + const c = rgb(hex) + if (!c) return hex + const r = Math.round(c.r * factor) + const g = Math.round(c.g * factor) + const b = Math.round(c.b * factor) + return "#" + [r, g, b].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("") +} + +const FALLBACK = { + primary: "#8B9DAF", text: "#C5C5BB", muted: "#7A7A72", + success: "#9CAF8B", warning: "#C5B88D", error: "#B08A8A", border: "#6B6B63", +} as const + +const MAX_SAT = 0.28 + +/** Entry line left prefix: icon + space + status dot + space */ +const LEFT_PAD = 4 +/** Detail row indent: two spaces */ +const INDENT = 2 + +function safeErrorMsg(err: unknown): string { + if (!err) return "" + if (typeof err === "string") return err + if (typeof err === "object") return String((err as any).message || (err as any).code || "") + return "" +} + +// =================================================================== +// Sidebar component +// =================================================================== + +// 模块级缓存:各 session 的 entry 状态独立存储,不随当前视图切换而清除。 +const globalEntryCache = new Map>() + +// 模块级刷新信号:外部(如斜杠命令)触发清除后 +1,组件 scan 依赖它以重扫。 +const [clearTick, setClearTick] = createSignal(0) + +function SubAgentPanel(props: { + theme: TuiThemeCurrent + api: TuiPluginApi + lang: () => Lang + maxEntries: () => number + sortOrder: () => SortOrder + scrollMode: () => ScrollMode + sessionId: string +}): JSX.Element { + const t = (key: string) => I18N[props.lang()][key] ?? key + + // ── session data (single-key, true deletion on cleanup) ── + const SESSION_DATA_KEY = `${KV_PREFIX}.session_data` + const ttlDaysRaw = parseInt(String(props.api.kv.get(`${KV_PREFIX}.ttl_days`, "3")), 10) + const ttlDays = Number.isNaN(ttlDaysRaw) ? 3 : ttlDaysRaw + const TTL_MS = ttlDays * 24 * 60 * 60 * 1000 + + interface ChildRecord { + scroll: number + expanded: string + entries: SubEntry[] + clearedIds?: string[] + } + + interface SessionRecord { + ts: number + entries: SubEntry[] + scroll: number + expanded: string + children: Record + clearedIds?: string[] + } + + const loadSessionData = (): Record => { + try { + const raw = props.api.kv.get(SESSION_DATA_KEY, "{}") + return JSON.parse(String(raw)) + } catch { return {} } + } + + const saveSessionData = (data: Record) => { + try { props.api.kv.set(SESSION_DATA_KEY, JSON.stringify(data)) } catch {} + } + + /** 将任意 session ID 解析为父会话 ID + 是否子会话。 + * 通过 SDK session.get(sid).parentID 判断,无 parentID 即为主会话。 */ + const resolveParent = (sid: string): { parentSid: string; isChild: boolean } => { + try { + const session = props.api.state.session.get(sid) + const parentID = (session as any)?.parentID as string | undefined + if (parentID) return { parentSid: parentID, isChild: true } + } catch {} + return { parentSid: sid, isChild: false } + } + + const loadEntries = (sid: string): Map => { + const m = new Map() + try { + const { parentSid, isChild } = resolveParent(sid) + const rec = loadSessionData()[parentSid] + if (rec) { + const source = isChild ? rec.children?.[sid]?.entries : rec.entries + if (source) { + for (const e of source) m.set(e.id, e) + } + } + } catch {} + return m + } + + let persistTimer: ReturnType | undefined + const persistEntries = (sid: string, entries: Map) => { + clearTimeout(persistTimer) + persistTimer = setTimeout(() => { + try { + const data = loadSessionData() + const { parentSid, isChild } = resolveParent(sid) + if (isChild) { + if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} } + if (!data[parentSid].children) data[parentSid].children = {} + if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] } + data[parentSid].children[sid] = { ...data[parentSid].children[sid], entries: [...entries.values()] } + } else { + data[sid] = { ...data[sid], ts: Date.now(), entries: [...entries.values()], children: data[sid]?.children ?? {} } + } + saveSessionData(data) + } catch {} + }, 200) + } + + const persistScroll = (sid: string, scroll: number) => { + try { + const data = loadSessionData() + const { parentSid, isChild } = resolveParent(sid) + if (isChild) { + if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} } + if (!data[parentSid].children) data[parentSid].children = {} + if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] } + data[parentSid].children[sid] = { ...data[parentSid].children[sid], scroll } + } else { + data[sid] = { ...data[sid], ts: Date.now(), scroll, children: data[sid]?.children ?? {} } + } + saveSessionData(data) + } catch {} + } + + const persistExpanded = (sid: string, expanded: string) => { + try { + const data = loadSessionData() + const { parentSid, isChild } = resolveParent(sid) + if (isChild) { + if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} } + if (!data[parentSid].children) data[parentSid].children = {} + if (!data[parentSid].children[sid]) data[parentSid].children[sid] = { scroll: 0, expanded: "", entries: [] } + data[parentSid].children[sid] = { ...data[parentSid].children[sid], expanded } + } else { + data[sid] = { ...data[sid], ts: Date.now(), expanded, children: data[sid]?.children ?? {} } + } + saveSessionData(data) + } catch {} + } + + const cleanupOldSessions = () => { + if (ttlDays <= 0) return // 无期限,跳过清理 + try { + const data = loadSessionData() + const cutoff = Date.now() - TTL_MS + let changed = false + for (const sid of Object.keys(data)) { + if (data[sid].ts < cutoff) { + delete data[sid] + changed = true + } + } + if (changed) saveSessionData(data) + } catch {} + } + + cleanupOldSessions() + + const [entryMap, setEntryMapRaw] = createSignal(loadEntries(props.sessionId)) + + // Wrapped setter — also persists to kv on every mutation + const setEntryMap = ( + arg: Map | ((prev: Map) => Map), + ) => { + setEntryMapRaw((prev) => { + const next = typeof arg === "function" ? (arg as Function)(prev) : arg + + // entry 状态落定(done/error)时立即持久化到 KV,跳过常规 debounce, + // 确保跨视图的状态一致性。 + let needsImmediateFlush = false + for (const [id, entry] of next) { + const prevEntry = prev.get(id) + if (prevEntry?.status === "running" && (entry.status === "done" || entry.status === "error")) { + needsImmediateFlush = true + break + } + } + + if (needsImmediateFlush) { + clearTimeout(persistTimer) + try { + const data = loadSessionData() + const { parentSid, isChild } = resolveParent(props.sessionId) + if (isChild) { + if (!data[parentSid]) data[parentSid] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} } + if (!data[parentSid].children) data[parentSid].children = {} + if (!data[parentSid].children[props.sessionId]) data[parentSid].children[props.sessionId] = { scroll: 0, expanded: "", entries: [] } + data[parentSid].children[props.sessionId] = { ...data[parentSid].children[props.sessionId], entries: [...next.values()] } + } else { + data[props.sessionId] = { ...data[props.sessionId], ts: Date.now(), entries: [...next.values()], children: data[props.sessionId]?.children ?? {} } + } + saveSessionData(data) + } catch {} + } else { + persistEntries(props.sessionId, next) + } + + // 同步到模块级缓存,供其他视图读取当前 session 的最新状态 + globalEntryCache.set(props.sessionId, new Map(next)) + + return next + }) + } + + const [panelWidth, setPanelWidth] = createSignal(28) + const [open, setOpen] = createSignal( + (() => { try { return props.api.kv.get(`${KV_PREFIX}.open`, true) as boolean } catch { return true } })() + ) + const [expanded, setExpanded] = createSignal( + (() => { + try { + const { parentSid, isChild } = resolveParent(props.sessionId) + const rec = loadSessionData()[parentSid] + if (rec) return isChild ? rec.children?.[props.sessionId]?.expanded || undefined : rec.expanded || undefined + } catch {} + return undefined + })(), + ) + const [hoveredOpen, setHoveredOpen] = createSignal(undefined) + const [hoveredDismiss, setHoveredDismiss] = createSignal(undefined) + const [hoveredTop, setHoveredTop] = createSignal(false) + const [hoveredMoreAbove, setHoveredMoreAbove] = createSignal(false) + const [hoveredMoreBelow, setHoveredMoreBelow] = createSignal(false) + const [scrollOffset, setScrollOffset] = createSignal( + (() => { + try { + const { parentSid, isChild } = resolveParent(props.sessionId) + const rec = loadSessionData()[parentSid] + return isChild ? rec?.children?.[props.sessionId]?.scroll ?? 0 : rec?.scroll ?? 0 + } catch { return 0 } + })(), + ) + const [now, setNow] = createSignal(Date.now()) + const [renderTick, setRenderTick] = createSignal(0) + + let boxEl: any + let disposed = false + + /** Total context tokens for a sub-agent session. + * Matches opencode-visual-cache's "总计": last assistant message's input + cache.read. */ + const readSessionTokens = (sid: string): number | undefined => { + if (!sid) return undefined + try { + const msgs = props.api.state.session.messages(sid) + if (msgs) { + for (let i = (msgs as any[]).length - 1; i >= 0; i--) { + const m = (msgs as any[])[i] + if (m.role !== "assistant") continue + const t = m.tokens + if (!t) continue + const cache = t.cache as { read?: number; write?: number } | undefined + const ctx = (Number(t.input) || 0) + (cache?.read ?? 0) + if (ctx > 0) return ctx + } + } + return undefined + } catch { + return undefined + } + } + + /** Sum USD cost from a session's messages. + * Prefers the database-level aggregate (`session.cost`) which is not affected + * by the sync layer's `limit: 100` message window. Falls back to message + * traversal when the aggregate is unavailable (older SDK versions). */ + const readSessionCost = (sid: string): number | undefined => { + if (!sid) return undefined + try { + const session = props.api.state.session.get(sid) + if (session?.cost != null && session.cost > 0) return session.cost + const msgs = props.api.state.session.messages(sid) + if (!msgs) return undefined + let total = 0 + for (const m of msgs as any[]) { + if (m.role === "assistant" && typeof m.cost === "number") total += m.cost + } + return total > 0 ? total : undefined + } catch { + return undefined + } + } + + /** Last assistant message's modelID for a sub-agent session. */ + const readSessionModel = (sid: string): string | undefined => { + if (!sid) return undefined + try { + const msgs = props.api.state.session.messages(sid) + if (msgs) { + for (let i = (msgs as any[]).length - 1; i >= 0; i--) { + const m = (msgs as any[])[i] + if (m.role === "assistant" && m.modelID) return String(m.modelID) + } + } + return undefined + } catch { + return undefined + } + } + + /** Todo completion stats for a sub-agent session. + * `done` counts completed + cancelled items. */ + const readSessionTodo = (sid: string): { total: number; done: number } | undefined => { + if (!sid) return undefined + try { + const todos = props.api.state.session.todo(sid) + if (!todos || todos.length === 0) return undefined + let done = 0 + for (const t of todos) { + if (t.status === "completed" || t.status === "cancelled") done++ + } + return { total: todos.length, done } + } catch { + return undefined + } + } + + // ── upsert ── + const upsertEntry = ( + partial: Omit & { startedAt?: number } + ) => { + setEntryMap((prev) => { + const existing = prev.get(partial.id) + const next = new Map(prev) + const nowTs = Date.now() + const e = partial.status + const ended = e === "done" || e === "error" + next.set(partial.id, { + ...(existing ?? { startedAt: nowTs }), + ...partial, + startedAt: existing?.startedAt || partial.startedAt || nowTs, + endedAt: ended ? (existing?.endedAt || nowTs) : undefined, + }) + return next + }) + } + + // ── event handlers ── + const handlePartUpdated = (event: unknown) => { + const e = event as Record + const props_ = e.properties as Record | undefined + const part = props_?.part as Record | undefined + if (!part) return + + // SubtaskPart + if (part.type === "subtask") { + const agent = String(part.agent ?? "?") + const prompt = String(part.prompt ?? "") + const desc = String(part.description ?? "") + const title = desc || truncate(prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim(), 40) + + const id = `sub:${String(part.id ?? crypto.randomUUID())}` + const subSid = part.sessionID !== undefined ? String(part.sessionID) : undefined + const partModel = part.model as { modelID?: string } | undefined + const modelId = partModel?.modelID ? String(partModel.modelID) : undefined + upsertEntry({ id, title, agent, prompt, sessionId: subSid, status: "running", model: modelId }) + } + + // ToolPart + if (part.type === "tool") { + const tool = String(part.tool ?? "") + if (!SUBAGENT_TOOLS.has(tool)) return + const st = part.state as Record | undefined + const rawStatus = String(st?.status ?? "") + + // Only create entries for tool calls that actually entered execution. + // "pending" / empty → state unknown yet, wait for next event + if (rawStatus === "pending" || rawStatus === "") return + + // "error" → tool call failed, sub-agent never spawned. + // Only update an existing entry (e.g. previously running → now error), + // never create a new one. + if (rawStatus === "error") { + const id = `tool:${String(part.id ?? "")}` + if (!part.id) return + const existing = entryMap().get(id) + if (existing) { + upsertEntry({ id, title: existing.title, agent: existing.agent, prompt: existing.prompt, status: "error" }) + } + return + } + + // rawStatus is "running" or "completed" — tool entered execution, track it. + const input = st?.input as Record | undefined + let status: SubStatus = "running" + if (rawStatus === "completed") status = "done" + // Background tasks: tool completion ≠ agent completion — keep running until session.idle + // Only keep running if state metadata confirms a child session was spawned; + // otherwise (failed spawn, invalid agent) mark as done so the entry isn't stuck forever. + if ((input?.run_in_background === true || input?.background === true) && status === "done") { + const stMetaCheck = st?.metadata as Record | undefined + const hasChild = stMetaCheck?.session_id !== undefined || stMetaCheck?.sessionId !== undefined + if (hasChild) status = "running" + } + + const agent = String((part as any).subagent_type ?? input?.subagent_type ?? input?.category ?? tool) + const prompt = String(input?.prompt ?? (part as any).description ?? "") + const desc = input?.description !== undefined ? String(input.description) : "" + const title = desc || truncate(prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim(), 40) + + const id = `tool:${String(part.id ?? crypto.randomUUID())}` + // Child session ID lives in state-level metadata (ToolStateCompleted.metadata), + // injected by the tool executor. ToolPart.sessionID is the parent session. + const stMeta = st?.metadata as Record | undefined + const subSid = stMeta?.session_id !== undefined ? String(stMeta.session_id) + : stMeta?.sessionId !== undefined ? String(stMeta.sessionId) + : undefined + upsertEntry({ id, title, agent, prompt, sessionId: subSid, status }) + } + } + + const handleSessionEnd = (event: unknown, status: SubStatus) => { + const e = event as Record + const props_ = e.properties as Record | undefined + const sid = String(props_?.sessionID ?? "") + if (!sid) return + + const sessionTokens = readSessionTokens(sid) + const sessionCost = readSessionCost(sid) + const sessionModel = readSessionModel(sid) + const sessionTodo = readSessionTodo(sid) + let sessionAgent: string | undefined + let errorMsg: string | undefined + try { + const s = props.api.state.session.get(sid) + sessionAgent = s?.agent + if (status === "error") { + const evtErr = props_?.error as Record | undefined + errorMsg = safeErrorMsg(evtErr) || safeErrorMsg(props_?.message) + if (!errorMsg) { + const msgs = props.api.state.session.messages(sid) + if (msgs) { + for (let i = (msgs as any[]).length - 1; i >= 0; i--) { + const m = (msgs as any[])[i] + if (m.role === "assistant" && m.error) { + errorMsg = safeErrorMsg(m.error) + break + } + } + } + } + } + } catch {} + + // 在给定的 entries Map 中查找并更新匹配的子代理 entry。 + // 返回 true 表示找到并更新了,false 表示未找到。 + const tryMatchAndUpdate = ( + entriesMap: Map, + targetSid: string, + targetStatus: SubStatus, + nowTs: number, + ): boolean => { + // 精确匹配:sessionId 对得上 + 状态为 running + for (const [, entry] of entriesMap) { + if (entry.sessionId === targetSid && entry.status === "running") { + entry.status = targetStatus + entry.endedAt = nowTs + entry.tokens = entry.tokens ?? sessionTokens + entry.cost = entry.cost ?? sessionCost + entry.model = entry.model ?? sessionModel + entry.todoTotal = entry.todoTotal ?? sessionTodo?.total + entry.todoDone = entry.todoDone ?? sessionTodo?.done + entry.error = errorMsg || entry.error + return true + } + } + // 回退:sessionId 未关联但 agent 名匹配 + 状态为 running + if (sessionAgent) { + const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]/g, "") + const saNorm = normalize(sessionAgent) + let best: { entry: SubEntry; gap: number } | null = null + for (const [, entry] of entriesMap) { + if (entry.status !== "running") continue + const eaNorm = normalize(entry.agent) + if (!eaNorm || !saNorm) continue + if (!eaNorm.includes(saNorm) && !saNorm.includes(eaNorm)) continue + const gap = nowTs - (entry.startedAt || 0) + if (!best || gap > best.gap) best = { entry, gap } + } + if (!best) { + for (const [, entry] of entriesMap) { + if (entry.status !== "running") continue + if (entry.sessionId) continue + const gap = nowTs - (entry.startedAt || 0) + if (!best || gap > best.gap) best = { entry, gap } + } + } + if (best) { + best.entry.status = targetStatus + best.entry.endedAt = nowTs + best.entry.tokens = best.entry.tokens ?? sessionTokens + best.entry.cost = best.entry.cost ?? sessionCost + best.entry.model = best.entry.model ?? sessionModel + best.entry.todoTotal = best.entry.todoTotal ?? sessionTodo?.total + best.entry.todoDone = best.entry.todoDone ?? sessionTodo?.done + best.entry.sessionId = targetSid + best.entry.error = errorMsg || best.entry.error + return true + } + } + return false + } + + setEntryMap((prev) => { + let changed = false + const next = new Map(prev) + for (const [id, entry] of next) { + if (entry.sessionId !== sid) continue + if (entry.status !== "running" && entry.status !== "done") continue + // Skip parent session idle — subagent entries belong to child sessions only + if (sid === props.sessionId) continue + // For "done" entries (sync tasks completed before session.idle), only backfill tokens/cost + const alreadySettled = entry.status !== "running" + next.set(id, { + ...entry, + ...(alreadySettled ? {} : { status, endedAt: Date.now() }), + tokens: entry.tokens ?? sessionTokens, + cost: entry.cost ?? sessionCost, + model: entry.model ?? sessionModel, + todoTotal: entry.todoTotal ?? sessionTodo?.total, + todoDone: entry.todoDone ?? sessionTodo?.done, + error: errorMsg || entry.error, + }) + changed = true + } + if (!changed && sessionAgent) { + const nowTs = Date.now() + const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]/g, "") + const saNorm = normalize(sessionAgent) + let best: { id: string; gap: number } | null = null + + // Phase 1: try matching by agent name(agent 名有交集) + for (const [id, entry] of next) { + if (entry.status !== "running") continue + const eaNorm = normalize(entry.agent) + if (!eaNorm || !saNorm) continue + if (!eaNorm.includes(saNorm) && !saNorm.includes(eaNorm)) continue + const gap = nowTs - (entry.startedAt || 0) + if (!best || gap > best.gap) best = { id, gap } + } + + // Phase 2: if agent name has no overlap (e.g. category calls: agent="deep" vs sessionAgent="Sisyphus-Junior"), + // fall back to time proximity for entries that have no sessionId yet + if (!best) { + for (const [id, entry] of next) { + if (entry.status !== "running") continue + if (entry.sessionId) continue + const gap = nowTs - (entry.startedAt || 0) + if (!best || gap > best.gap) best = { id, gap } + } + } + + if (best) { + const entry = next.get(best.id)! + next.set(best.id, { + ...entry, status, endedAt: nowTs, + tokens: sessionTokens || entry.tokens, + cost: sessionCost || entry.cost, + sessionId: sid, + error: errorMsg || entry.error, + }) + changed = true + } + } + return changed ? next : prev + }) + + // 当子代理所属的父 session 与当前视图不同时,通过模块级缓存定位 + // 并更新父 session 的 entry 状态,随后写回 KV。 + try { + const sessionObj = props.api.state.session.get(sid) + const parentSid = sessionObj?.parentID + if (parentSid && parentSid !== props.sessionId) { + // 优先从模块级缓存获取父 session 的 entries,不受当前视图切换影响 + const parentCache = globalEntryCache.get(parentSid) + const nowTs = Date.now() + let found = false + + if (parentCache) { + found = tryMatchAndUpdate(parentCache, sid, status, nowTs) + } + + // 缓存未命中时回退到 KV 读取 + if (!found) { + const data = loadSessionData() + const rec = data[parentSid] + if (rec?.entries) { + const fallbackMap = new Map(rec.entries.map((e: SubEntry) => [e.id, e])) + found = tryMatchAndUpdate(fallbackMap, sid, status, nowTs) + if (found) { + // 回退命中后写入 KV 并回填缓存 + data[parentSid] = { ...rec, ts: nowTs, entries: [...fallbackMap.values()] } + saveSessionData(data) + globalEntryCache.set(parentSid, fallbackMap) + } + } + } + + // 将模块级缓存中的最新状态同步到 KV + if (found && parentCache) { + const data = loadSessionData() + data[parentSid] = { ...data[parentSid], ts: nowTs, entries: [...parentCache.values()] } + saveSessionData(data) + } + } + } catch {} + + // Delayed backfill: re-read data after state sync catches up, to capture the final + // token/cost values that may not have been available when session.idle fired. + setTimeout(() => { + if (disposed) return + const finalTokens = readSessionTokens(sid) + const finalCost = readSessionCost(sid) + const finalModel = readSessionModel(sid) + const finalTodo = readSessionTodo(sid) + setEntryMap((prev) => { + let changed = false + const next = new Map(prev) + for (const [id, entry] of next) { + if (entry.sessionId !== sid) continue + const t = finalTokens ?? entry.tokens + const c = finalCost ?? entry.cost + const m = finalModel ?? entry.model + const tt = finalTodo?.total ?? entry.todoTotal + const td = finalTodo?.done ?? entry.todoDone + if (t !== entry.tokens || c !== entry.cost || m !== entry.model || + tt !== entry.todoTotal || td !== entry.todoDone) { + next.set(id, { ...entry, tokens: t, cost: c, model: m, todoTotal: tt, todoDone: td }) + changed = true + } + } + return changed ? next : prev + }) + bump() + }, 150) + } + + // ── bumpRenderTick: force re-render (visual-cache pattern) ── + const bump = () => setRenderTick((v) => v + 1) + + onMount(() => { + // Fast clock for smooth time display, separate from token polling + const clock = setInterval(() => { setNow(Date.now()); bump() }, 100) + // Token poll — runs every 500ms for running entries + const tokenTimer = setInterval(() => { + untrack(() => { + setEntryMapRaw((prev) => { + let changed = false + const next = new Map(prev) + for (const [id, entry] of next) { + if (entry.status === "running" && entry.sessionId) { + // Only read from child sessions, never the parent + let isChild = false + try { + const s = props.api.state.session.get(entry.sessionId) + isChild = s?.parentID === props.sessionId + } catch {} + if (!isChild) continue + const total = readSessionTokens(entry.sessionId) + const todo = readSessionTodo(entry.sessionId) + const model = entry.model ?? readSessionModel(entry.sessionId) + const nextEntry: SubEntry = { ...entry } + if (total !== undefined && total !== entry.tokens) { nextEntry.tokens = total; changed = true } + if (todo !== undefined) { + if (todo.total !== entry.todoTotal || todo.done !== entry.todoDone) { + nextEntry.todoTotal = todo.total; nextEntry.todoDone = todo.done; changed = true + } + } + if (model && !entry.model) { nextEntry.model = model; changed = true } + if (changed) next.set(id, nextEntry) + } + } + return changed ? next : prev + }) + }) + bump() + }, 500) + bump() + + const unsubPart = props.api.event.on("message.part.updated", (e) => { + handlePartUpdated(e) + bump() + }) + const unsubMsg = props.api.event.on("message.updated", () => bump()) + const unsubIdle = props.api.event.on("session.idle", (e) => { + handleSessionEnd(e, "done") + bump() + }) + const unsubError = props.api.event.on("session.error", (e) => { + handleSessionEnd(e, "error") + bump() + }) + + onCleanup(() => { + disposed = true + clearInterval(clock) + clearInterval(tokenTimer) + unsubPart() + unsubMsg() + unsubIdle() + unsubError() + }) + }) + + // ── session‑switch & initial‑load scan ── + // On session change: load from kv (entries survive component unmount), then scan+merge. + // On same session: only scan+merge (keep event‑driven running entries). + let lastSid = props.sessionId + let lastTick = 0 + createEffect(() => { + const sid = props.sessionId + const switched = sid !== lastSid + lastSid = sid + const tick = clearTick() // 外部触发清除时 +1,effect 重跑 + const forceReload = tick !== lastTick && !switched + lastTick = tick + const t = setTimeout(() => { + untrack(() => { + if (switched) { + const { parentSid, isChild } = resolveParent(sid) + const data = loadSessionData() + const saved = isChild + ? data[parentSid]?.children?.[sid]?.scroll ?? 0 + : data[sid]?.scroll ?? 0 + setScrollOffset(saved) + // 刷新父会话的访问时间 TTL,防止活跃会话的数据过期 + if (!isChild && data[sid]?.entries?.length) { + data[sid].ts = Date.now() + saveSessionData(data) + } + } + // scan uses setEntryMapRaw — ephemeral data, not persisted to kv. + // Only event-driven changes (handlePartUpdated, handleSessionEnd) persist. + setEntryMapRaw((prev) => { + // 优先从模块级缓存加载,KV 仅作缓存未命中时的回退 + const next = (switched || forceReload) + ? new Map(globalEntryCache.get(sid) ?? loadEntries(sid)) + : new Map(prev) + // 从 KV 加载当前会话的清除名单,扫描时跳过被手动清除的历史条目 + const { parentSid: scanPSid, isChild: scanChild } = resolveParent(sid) + const scanRec = loadSessionData()[scanPSid] + const clearedIds = new Set(scanChild ? scanRec?.children?.[sid]?.clearedIds : scanRec?.clearedIds) + try { + const msgs = props.api.state.session.messages(sid) + if (msgs && (msgs as any[]).length) { + for (const msg of msgs) { + const parts = props.api.state.part(msg.id) ?? [] + for (const partRaw of parts) { + const part = partRaw as Record + + // Subtask entries are purely event-driven — never created by scan. + // (SubtaskPart exists from spawn, not completion, so we cannot infer status.) + if (part.type === "tool") { + const tool = String((part as any).tool ?? "") + if (!SUBAGENT_TOOLS.has(tool)) continue + const id = `tool:${String(part.id ?? "")}` + if (!part.id) continue + + const st = (part as any).state as Record | undefined + const rawStatus = String(st?.status ?? "") + const exists = next.get(id) + + // 已手动清除的条目:scan 发现但不在内存 → 跳过重建 + if (!exists && clearedIds.has(id)) continue + + // Only create entries for tool calls that entered execution. + // "pending" / empty: skip new entries; allow heuristics for existing ones below. + if ((rawStatus === "pending" || rawStatus === "") && !exists) continue + + // "error": only update existing, never create a new entry + if (rawStatus === "error") { + if (exists && exists.status === "running") { + next.set(id, { ...exists, status: "error", endedAt: Date.now() }) + } + continue + } + + let status: SubStatus = "running" + if (rawStatus === "completed") status = "done" + // Background tasks: tool completion ≠ agent completion — keep running until session.idle + // Only keep running if state metadata confirms a child session was spawned. + if (((st?.input as Record | undefined)?.run_in_background === true || (st?.input as Record | undefined)?.background === true) && status === "done") { + const scanStMeta = st?.metadata as Record | undefined + const scanHasChild = scanStMeta?.session_id !== undefined || scanStMeta?.sessionId !== undefined + if (scanHasChild) status = "running" + } + + // Already settled → skip + if (exists && exists.status !== "running") continue + // Running entry with no explicit status improvement from part: + // try message-level heuristics first, then time-based fallback. + if (exists && status === "running") { + if (!rawStatus) { + const msgTokens = (msg as any)?.tokens as Record | undefined + if (msgTokens && (Number(msgTokens.input) > 0 || Number(msgTokens.output) > 0)) { + status = "done" // LLM returned tokens → agent completed + } else if (Date.now() - exists.startedAt > 30 * 60 * 1000) { + status = "done" // >30 min idle → assume completed + } else { + continue + } + } else { + continue + } + } + + // If already tracked as running but tool state says completed/error → update + // If not tracked → add fresh + + const input = st?.input as Record | undefined + const agent = String((part as any).subagent_type ?? input?.subagent_type ?? tool) + const prompt = String(input?.prompt ?? (part as any).description ?? "") + const desc = input?.description !== undefined ? String(input.description) : "" + const title = desc || truncate(prompt.replace(/\n/g, " ").trim(), 40) + + let tokens: number | undefined + const scanStMeta2 = st?.metadata as Record | undefined + const scanSubSid = scanStMeta2?.session_id !== undefined ? String(scanStMeta2.session_id) + : scanStMeta2?.sessionId !== undefined ? String(scanStMeta2.sessionId) + : undefined + if (scanSubSid) tokens = readSessionTokens(scanSubSid) + + const ended = status === "done" // "error" handled above, never reaches here + next.set(id, { + id, title, agent, prompt, + // Preserve existing values (from handleSessionEnd / KV) — scan must not overwrite + tokens: exists?.tokens ?? tokens, + sessionId: exists?.sessionId ?? scanSubSid, + status, + startedAt: exists?.startedAt || Date.now(), + endedAt: ended ? (exists?.endedAt || Date.now()) : undefined, + }) + } + } + } + } + } catch {} + return next + }) + // Reconcile: check running entries against live child session status. + // Covers session.idle events missed while user was inside a child session. + setEntryMapRaw((prev) => { + let changed = false + const next = new Map(prev) + for (const [id, entry] of next) { + if (entry.status !== "running" || !entry.sessionId) continue + try { + const st = props.api.state.session.status(entry.sessionId) + if (!st || st.type !== "idle") continue + const tokens = readSessionTokens(entry.sessionId) + const cost = readSessionCost(entry.sessionId) + next.set(id, { + ...entry, status: "done" as SubStatus, endedAt: Date.now(), + tokens: tokens ?? entry.tokens, + cost: cost ?? entry.cost, + }) + changed = true + } catch {} + } + return changed ? next : prev + }) + bump() + }) + }, 150) + onCleanup(() => clearTimeout(t)) + }) + + // ── palette ── + const pal = createMemo(() => { + const th = props.theme as Record + const sat = (k: string, fb: string) => desaturateTo(th[k], MAX_SAT, fb) + return { + primary: sat("primary", FALLBACK.primary), + text: sat("text", FALLBACK.text), + muted: sat("textMuted", FALLBACK.muted), + success: sat("success", FALLBACK.success), + warning: sat("warning", FALLBACK.warning), + error: sat("error", FALLBACK.error), + border: sat("border", FALLBACK.border), + } + }) + + // ── derived signals ── + // Stable list — only changes when entryMap changes + const entryList = createMemo(() => { + const entries = [...entryMap().values()] + if (props.sortOrder() === "desc") { + return entries.sort((a, b) => b.startedAt - a.startedAt) + } + return entries.sort((a, b) => a.startedAt - b.startedAt) + }) + + const max = props.maxEntries + const clampedOffset = createMemo(() => { + const total = entryList().length + const m = max() + if (total <= m) return 0 + return Math.min(scrollOffset(), total - m) + }) + const visibleList = createMemo(() => entryList().slice(clampedOffset(), clampedOffset() + max())) + const hiddenAbove = createMemo(() => clampedOffset()) + const hiddenBelow = createMemo(() => Math.max(0, entryList().length - clampedOffset() - max())) + + // Drop hover state when ↑ more disappears (hiddenAbove hits zero) + createEffect(() => { + if (hiddenAbove() === 0) setHoveredMoreAbove(false) + }) + + // Reset scroll on sort order change: jump to newest in view + let sortInitialized = false + createEffect(() => { + props.sortOrder() + if (!sortInitialized) { sortInitialized = true; return } + const total = untrack(() => entryList().length) + const m = untrack(() => max()) + const target = props.sortOrder() === "desc" ? 0 : Math.max(0, total - m) + setScrollOffset(target) + setTimeout(() => { + try { persistScroll(props.sessionId, target) } catch {} + }, 0) + }) + + // When new entries arrive while viewing the newest end, keep the view at newest + let prevEntryCount = 0 + createEffect(() => { + const total = entryList().length + if (prevEntryCount === 0) { prevEntryCount = total; return } + if (total === prevEntryCount) return + + const m = max() + const wasAtNewest = props.sortOrder() === "desc" + ? untrack(() => scrollOffset() === 0) + : untrack(() => scrollOffset() >= prevEntryCount - m) + + prevEntryCount = total + + if (wasAtNewest) { + const target = props.sortOrder() === "desc" ? 0 : Math.max(0, total - m) + setScrollOffset(target) + setTimeout(() => { + try { persistScroll(props.sessionId, target) } catch {} + }, 0) + } + }) + + const entries = createMemo(() => { + const nowVal = now() + return entryList().map((e) => ({ + ...e, + elapsed: (e.endedAt ?? nowVal) - e.startedAt, + })) + }) + + const doneCount = createMemo(() => entryList().filter((e) => e.status === "done").length) + const runningCount = createMemo(() => entryList().filter((e) => e.status === "running").length) + const errCount = createMemo(() => entryList().filter((e) => e.status === "error").length) + const anyEntry = () => entryList().length > 0 + + const totalTokens = createMemo(() => { + let sum = 0 + for (const e of entryList()) { if (e.tokens) sum += e.tokens } + return sum + }) + + const totalCost = createMemo(() => { + let sum = 0 + for (const e of entryList()) { if (e.cost) sum += e.cost } + return sum + }) + + const toggleExpand = (id: string) => { + setExpanded((prev) => { + const next = prev === id ? undefined : id + try { persistExpanded(props.sessionId, next ?? "") } catch {} + return next + }) + } + + const sep = () => "\u2500".repeat(Math.max(1, panelWidth())) + + // ── expanded detail right-align ── + const expandedMaxLabelW = createMemo(() => { + const labels = [ + t("agent.label"), t("status.label"), t("time.label"), t("tokens.label"), + t("error.label"), t("cost.label"), t("model.label"), t("todo.label"), t("session.label"), + ] + return Math.max(...labels.map(l => visualWidth(l + ": "))) + }) + + const expandedPad = (label: string) => Math.max(0, expandedMaxLabelW() - visualWidth(label + ": ")) + + const expandedValAvail = () => Math.max(6, panelWidth() - INDENT - expandedMaxLabelW()) + + // ── header parts for colored spans ── + const summaryParts = createMemo(() => { + if (!anyEntry()) return null + const dot = "\u25cf" + const cost = totalCost() + return { + done: `${dot}${doneCount()}`, + running: runningCount() > 0 ? `${dot}${runningCount()}` : null, + err: errCount() > 0 ? `${dot}${errCount()}` : null, + duration: totalTokens() > 0 ? fmtTokens(totalTokens()) : "", + cost: cost > 0 ? `$${cost.toFixed(2)}` : "", + } + }) + + const summaryCols = createMemo(() => { + const p = summaryParts() + if (!p) return 0 + let w = visualWidth(p.done) + if (p.running) w += 1 + visualWidth(p.running) + if (p.err) w += 1 + visualWidth(p.err) + w += p.duration ? 1 + visualWidth(p.duration) : 0 + w += p.cost ? 1 + visualWidth(p.cost) : 0 + return w + }) + + const versionText = ` v${PLUGIN_VERSION}` + const versionW = visualWidth(versionText) + + const showVersion = createMemo(() => { + if (!open()) return false + const icon = "\u25bc" + const need = visualWidth(icon) + 1 + visualWidth(t("panel.title")) + versionW + summaryCols() + return need <= panelWidth() + }) + + const leftCols = createMemo(() => { + const icon = open() ? "\u25bc" : "\u25b6" + let w = visualWidth(icon) + 1 + visualWidth(t("panel.title")) + if (showVersion()) w += versionW + return w + }) + + const spacerCols = createMemo(() => { + if (!anyEntry()) return 0 + return Math.max(0, panelWidth() - leftCols() - summaryCols()) + }) + + const valueCols = (label: string) => + Math.max(4, panelWidth() - INDENT - visualWidth(label + ": ")) + + // ── render ── + return ( + { + const w = boxEl ? Math.max(20, boxEl.width ?? 0) : 28 + setPanelWidth((prev) => (prev === w ? prev : w)) + }} + > + {/* ── header: same pattern as visual-cache's fold toggle ── */} + {/* renderTick in span forces the text element to re-evaluate */} + { + setOpen((o) => { + const n = !o + try { props.api.kv.set(`${KV_PREFIX}.open`, n) } catch {} + return n + }) + bump() + }} + > + {renderTick() >= 0 && open() ? "\u25bc " : "\u25b6 "} + {t("panel.title")} + {versionText} + {anyEntry() ? ( + <> + {" ".repeat(spacerCols())} + {summaryParts()!.done} + {runningCount() > 0 && ( + {summaryParts()!.running} + )} + {errCount() > 0 && ( + {summaryParts()!.err} + )} + {summaryParts()!.duration ? ( + {summaryParts()!.duration} + ) : null} + {summaryParts()!.cost ? ( + {summaryParts()!.cost} + ) : null} + + ) : null} + + + {/* ── panel body ── */} + + {sep()} + + + {" "}> {t("status.none")} {/* empty indent kept for visual balance */} + + } + > + { + if (props.scrollMode() === "click") return + const total = entryList().length + const m = max() + if (total <= m) return + const dir = e.button === 0 ? 1 : -1 + setScrollOffset((prev) => { + const next = Math.max(0, Math.min(prev + dir, total - m)) + try { persistScroll(props.sessionId, next) } catch {} + return next + }) + }} + > + 0}> + setHoveredMoreAbove(true)} + onMouseOut={() => setHoveredMoreAbove(false)} + onMouseUp={() => { + const total = entryList().length + const m = max() + if (total <= m) return + const next = Math.max(0, scrollOffset() - m) + if (next === 0) { + setTimeout(() => { + setScrollOffset(next) + try { persistScroll(props.sessionId, next) } catch {} + }, 0) + } else { + setScrollOffset(next) + try { persistScroll(props.sessionId, next) } catch {} + } + }} + > + + {" "}↑ {hiddenAbove()} {t("scroll.more")} + + + + + {(entry) => { + const isExpanded = () => expanded() === entry.id + const isRunning = entry.status === "running" + const isError = entry.status === "error" + const elapsed = () => (entry.endedAt ?? now()) - entry.startedAt + + const statusDot = () => "\u25cf" + const statusColor = () => { + if (!isRunning) return isError ? pal().error : pal().success + const t = (Math.sin(((now() % 2000) / 2000) * Math.PI * 2 - Math.PI / 2) + 1) / 2 + const a = rgb(pal().muted), b = rgb(pal().warning) + if (!a || !b) return pal().warning + const r = Math.round(a.r + (b.r - a.r) * t) + const g = Math.round(a.g + (b.g - a.g) * t) + const bl = Math.round(a.b + (b.b - a.b) * t) + return "#" + [r, g, bl].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("") + } + + const timeColor = () => + isRunning ? pal().warning : isError ? pal().error : pal().muted + + // Entry label: collapsed shows title only, expanded shows title only too + const tokenText = () => + !isExpanded() && entry.tokens !== undefined && entry.tokens > 0 + ? ` ${fmtTokens(entry.tokens!)}` + : "" + const timeText = () => + !isExpanded() && (elapsed() >= 2000 || entry.endedAt !== undefined) + ? fmtDurationShort(elapsed(), isRunning) + : "" + const suffixW = () => { + let w = 0 + const t = timeText() + if (t) w += 1 + visualWidth(t) + const tk = tokenText() + if (tk) w += visualWidth(tk) + return w + } + const labelAvail = () => Math.max(6, panelWidth() - LEFT_PAD - suffixW()) + const labelText = () => { + const max = labelAvail() + const text = entry.title || entry.agent + const truncated = truncate(text, max) + const pad = Math.max(0, max - visualWidth(truncated)) + return truncated + " ".repeat(pad) + } + + return ( + <> + {/* entry line — left-aligned */} + toggleExpand(entry.id)}> + + {isExpanded() ? "\u25bc" : "\u25b6"} + + {" "} + {statusDot()} + {" "} + {labelText()} + {timeText() ? ( + <> + {" "} + {timeText()} + + ) : null} + {tokenText() ? ( + {tokenText()} + ) : null} + + + {/* expanded detail — right-aligned values */} + + + {" "} + {t("agent.label")}: + {" ".repeat(expandedPad(t("agent.label")))} + {entry.agent} + + + {" "} + {t("status.label")}: + {" ".repeat(expandedPad(t("status.label")))} + + {isRunning ? t("status.running") : isError ? t("status.error") : t("status.done")} + + + = 2000 || entry.endedAt !== undefined}> + + {" "} + {t("time.label")}: + {" ".repeat(expandedPad(t("time.label")))} + + {fmtDurationShort(elapsed(), isRunning)} + + + + + + {" "} + {t("tokens.label")}: + {" ".repeat(expandedPad(t("tokens.label")))} + {fmtTokens(entry.tokens!)} + + + + + {" "} + {t("error.label")}: + {" ".repeat(expandedPad(t("error.label")))} + {truncate(String(entry.error), expandedValAvail())} + + + + {(() => { + const cost = entry.cost! + return ( + + {" "} + {t("cost.label")}: + {" ".repeat(expandedPad(t("cost.label")))} + ${cost.toFixed(4)} + + ) + })()} + + + + {" "} + {t("model.label")}: + {" ".repeat(expandedPad(t("model.label")))} + {truncate(entry.model!, expandedValAvail())} + + + + + {" "} + {t("todo.label")}: + {" ".repeat(expandedPad(t("todo.label")))} + {entry.todoDone}/{entry.todoTotal} + + + + { + if (entry.sessionId) { + props.api.ui.toast({ + title: entry.title || entry.agent, + message: `${entry.sessionId}\n\n${t("session.toast.copy")}`, + duration: 8000, + }) + } + }} + > + {" "} + {t("session.label")}: + {" ".repeat(expandedPad(t("session.label")))} + {truncate(entry.sessionId!, expandedValAvail() - visualWidth(" ⎘"))} + + + + {/* 进入会话 + 标记完成:同排左右两端,空间隔离防误触 */} + + {(() => { + const dismissLabel = () => `- ${t("dismiss.label")}` + const openPrefix = () => " \u2192 " + const openFull = () => entry.sessionId ? openPrefix() + t("open.label") : "" + const openW = () => entry.sessionId ? visualWidth(openFull()) : 0 + const spacerW = () => Math.max(1, panelWidth() - openW() - visualWidth(dismissLabel()) - 2 /* indent */) + return ( + + {" "}} + > + setHoveredOpen(entry.id)} + onMouseOut={() => setHoveredOpen(undefined)} + onMouseUp={() => { + if (entry.sessionId) { + props.api.route.navigate("session", { sessionID: entry.sessionId }) + } + }} + > + {openPrefix()} + {t("open.label")} + + + + <> + {" ".repeat(spacerW())} + setHoveredDismiss(entry.id)} + onMouseOut={() => setHoveredDismiss(undefined)} + onMouseUp={() => { + upsertEntry({ id: entry.id, title: entry.title, agent: entry.agent, prompt: entry.prompt, status: "done" }) + }} + > + {dismissLabel()} + + + + + ) + })()} + + + + ) + }} + + 0 || (props.sortOrder() === "desc" ? scrollOffset() > 0 : entryList().length > max() && clampedOffset() < entryList().length - max())}> + {(() => { + const showMore = hiddenBelow() > 0 + const showTop = props.sortOrder() === "desc" + ? scrollOffset() > 0 + : entryList().length > max() && clampedOffset() < entryList().length - max() + const left = showMore ? ` \u2193 ${hiddenBelow()} ${t("scroll.more")}` : " " + const right = props.sortOrder() === "desc" + ? `\u2191 ${t("scroll.top")}` + : `\u2193 ${t("scroll.bottom")}` + const pad = showTop ? Math.max(1, panelWidth() - visualWidth(left) - visualWidth(right)) : 0 + return ( + + showMore && setHoveredMoreBelow(true)} + onMouseOut={() => setHoveredMoreBelow(false)} + onMouseUp={() => { + if (!showMore) return + const total = entryList().length + const m = max() + if (total <= m) return + setScrollOffset((prev) => Math.min(total - m, prev + m)) + try { persistScroll(props.sessionId, scrollOffset()) } catch {} + setHoveredMoreBelow(false) + }} + > + + {left} + + + {showTop ? ( + <> + {" ".repeat(pad)} + setHoveredTop(true)} + onMouseOut={() => setHoveredTop(false)} + onMouseUp={() => { + const total = entryList().length + const m = max() + if (props.sortOrder() === "desc") { + setScrollOffset(0) + } else { + setScrollOffset(Math.max(0, total - m)) + } + setHoveredTop(false) + }} + > + {right} + + + ) : null} + + ) + })()} + + + + + + ) +} + +// =================================================================== +// Plugin entry +// =================================================================== + +interface SharedSignals { + lang: () => Lang + setLang: (l: Lang) => void + maxEntries: () => number + setMaxEntries: (n: number) => void + sortOrder: () => SortOrder + setSortOrder: (o: SortOrder) => void + scrollMode: () => ScrollMode + setScrollMode: (m: ScrollMode) => void + sessionId: string +} + +function createSidebarSlot(api: TuiPluginApi, sig: SharedSignals): TuiSlotPlugin { + return { + order: 60, + slots: { + sidebar_content(ctx: TuiSlotContext, input: { session_id: string }): JSX.Element { + sig.sessionId = input.session_id + return ( + + ) + }, + }, + } +} + +const KV_PREFIX = "subagent_magazine" + +const tui: TuiPlugin = async (api: TuiPluginApi) => { + // ── language ── + const stored = String(api.kv.get(`${KV_PREFIX}.lang`, "")) + const initialLang: Lang = + stored === "zh" || stored === "en" ? stored : detectLang() + const [lang, setLang] = createSignal(initialLang) + const [maxEntries, setMaxEntries] = createSignal( + parseInt(String(api.kv.get(`${KV_PREFIX}.max_entries`, "10")), 10) || 10 + ) + const [sortOrder, setSortOrder] = createSignal( + String(api.kv.get(`${KV_PREFIX}.order`, "desc")) === "asc" ? "asc" : "desc" + ) + const [scrollMode, setScrollMode] = createSignal( + String(api.kv.get(`${KV_PREFIX}.scroll_mode`, "wheel")) === "click" ? "click" : "wheel" + ) + + const signals: SharedSignals = { lang, setLang, maxEntries, setMaxEntries, sortOrder, setSortOrder, scrollMode, setScrollMode, sessionId: "" } + + api.slots.register(createSidebarSlot(api, signals)) + + // ── slash command: /subagent-lang ── + api.command?.register(() => [ + { + title: "SubAgent Magazine: Language", + value: "subagent-lang", + description: "Switch display language (中文 / English)", + slash: { name: "subagent-lang" }, + onSelect: (dialog) => { + dialog?.replace(() => ( + { + const l = opt.value as Lang + setLang(l) + api.kv.set(`${KV_PREFIX}.lang`, l) + api.ui.toast({ + message: l === "zh" ? "语言: 中文" : "Language: English", + }) + dialog?.clear() + }} + /> + )) + }, + }, + { + title: "SubAgent Magazine: Sort Order", + value: "subagent-order", + description: "Set sub-agent entry sort order (desc / asc)", + slash: { name: "subagent-order" }, + onSelect: (dialog) => { + const t = (k: string) => I18N[lang()][k] ?? k + dialog?.replace(() => ( + { + const o = opt.value as SortOrder + setSortOrder(o) + api.kv.set(`${KV_PREFIX}.order`, o) + api.ui.toast({ + message: o === "desc" ? t("order.desc") : t("order.asc"), + }) + dialog?.clear() + }} + /> + )) + }, + }, + { + title: "SubAgent Magazine: Scroll Mode", + value: "subagent-scroll", + description: "Set scroll mode (wheel / click)", + slash: { name: "subagent-scroll" }, + onSelect: (dialog) => { + const t = (k: string) => I18N[lang()][k] ?? k + dialog?.replace(() => ( + { + const m = opt.value as ScrollMode + setScrollMode(m) + api.kv.set(`${KV_PREFIX}.scroll_mode`, m) + api.ui.toast({ + message: m === "wheel" ? t("scroll.wheel") : t("scroll.click"), + }) + dialog?.clear() + }} + /> + )) + }, + }, + { + title: "SubAgent Magazine: Max Entries", + value: "subagent-max", + description: "Set max visible sub-agent entries in sidebar", + slash: { name: "subagent-max" }, + onSelect: (dialog) => { + dialog?.replace(() => ( + ( + Number of entries to show in the sidebar (1–50) + )} + value={String(maxEntries())} + onConfirm={(val) => { + const n = Math.max(1, Math.min(50, parseInt(val, 10) || 10)) + setMaxEntries(n) + api.kv.set(`${KV_PREFIX}.max_entries`, n) + api.ui.toast({ message: `Max entries: ${n}` }) + dialog?.clear() + }} + /> + )) + }, + }, + { + title: "SubAgent Magazine: Version", + value: "subagent-version", + description: "Show plugin version", + slash: { name: "subagent-version" }, + onSelect: (dialog) => { + api.ui.toast({ message: `opencode-subagent-magazine v${PLUGIN_VERSION}` }) + dialog?.clear() + }, + }, + { + title: "SubAgent Magazine: Session", + value: "subagent-session", + description: "Show current session ID", + slash: { name: "subagent-session" }, + onSelect: (dialog) => { + api.ui.toast({ message: `Session: ${signals.sessionId}` }) + dialog?.clear() + }, + }, + { + title: "SubAgent Magazine: Clear Running", + value: "subagent-clear-running", + description: "Mark all running sub-agent entries as done (for stuck/zombie entries)", + slash: { name: "subagent-clear-running" }, + onSelect: (dialog) => { + const sid = signals.sessionId + const entries = globalEntryCache.get(sid) + if (!entries || entries.size === 0) { + const msg = signals.lang() === "zh" ? "暂无子代理条目" : "No sub-agent entries found" + api.ui.toast({ message: msg }) + dialog?.clear() + return + } + let count = 0 + for (const [, entry] of entries) { + if (entry.status === "running") { + entry.status = "done" as SubStatus + entry.endedAt = Date.now() + count++ + } + } + if (count > 0) { + // 立即写 KV + try { + const data = JSON.parse(String(api.kv.get(`${KV_PREFIX}.session_data`, "{}"))) + const sessionObj = api.state.session.get(sid) + const parentID = (sessionObj as any)?.parentID as string | undefined + if (parentID) { + if (!data[parentID]) data[parentID] = { ts: Date.now(), entries: [], scroll: 0, expanded: "", children: {} } + if (!data[parentID].children) data[parentID].children = {} + if (!data[parentID].children[sid]) data[parentID].children[sid] = { scroll: 0, expanded: "", entries: [] } + data[parentID].children[sid] = { ...data[parentID].children[sid], entries: [...entries.values()] } + } else { + data[sid] = { + ts: Date.now(), + entries: [...entries.values()], + scroll: data[sid]?.scroll ?? 0, + expanded: data[sid]?.expanded ?? "", + children: data[sid]?.children ?? {}, + } + } + api.kv.set(`${KV_PREFIX}.session_data`, JSON.stringify(data)) + } catch {} + const msg = signals.lang() === "zh" + ? `已标记 ${count} 个运行中的条目为完成` + : `Marked ${count} running entries as done` + api.ui.toast({ message: msg }) + } else { + const msg = signals.lang() === "zh" + ? "没有需要清理的运行中条目" + : "No running entries to clear" + api.ui.toast({ message: msg }) + } + dialog?.clear() + }, + }, + { + title: "SubAgent Magazine: TTL", + value: "subagent-ttl", + description: "Set session data retention period (days before auto-cleanup)", + slash: { name: "subagent-ttl" }, + onSelect: (dialog) => { + const t = (k: string) => I18N[signals.lang()][k] ?? k + const curRaw = parseInt(String(api.kv.get(`${KV_PREFIX}.ttl_days`, "3")), 10) + const curDays = Number.isNaN(curRaw) ? 3 : curRaw + const curLabel = curDays === 0 ? t("ttl.unlimited") : `${curDays}d` + dialog?.replace(() => ( + { + const days = parseInt(opt.value, 10) + api.kv.set(`${KV_PREFIX}.ttl_days`, String(days)) + const msg = days === 0 ? t("ttl.toast_unlimited") : t("ttl.toast").replace("{n}", String(days)) + api.ui.toast({ message: msg }) + dialog?.clear() + }} + /> + )) + }, + }, + { + title: "SubAgent Magazine: Clear Entries", + value: "subagent-clear-entries", + description: "Delete all sub-agent records for the current session (cannot be undone)", + slash: { name: "subagent-clear-entries" }, + onSelect: (dialog) => { + const t = (k: string) => I18N[signals.lang()][k] ?? k + const sid = signals.sessionId + const sessionObj = api.state.session.get(sid) + const parentID = (sessionObj as any)?.parentID as string | undefined + // 检查是否存在运行中的条目 + const cached = globalEntryCache.get(sid) + let runningCount = 0 + if (cached) { + for (const [, e] of cached) { if (e.status === "running") runningCount++ } + } + const msg = runningCount > 0 + ? t("clear.prompt_running").replace("{n}", String(runningCount)) + : t("clear.prompt") + dialog?.replace(() => ( + { + try { + const data = JSON.parse(String(api.kv.get(`${KV_PREFIX}.session_data`, "{}"))) + let count = 0 + if (parentID) { + if (data[parentID]?.children?.[sid]) { + const child = data[parentID].children[sid] + const ids = child.entries?.map((e: any) => e.id) ?? [] + count = ids.length + child.entries = [] + child.scroll = 0 + child.expanded = "" + child.clearedIds = [...new Set([...(child.clearedIds ?? []), ...ids])] + } + } else { + count = data[sid]?.entries?.length ?? 0 + if (data[sid]) { + const ids = data[sid].entries?.map((e: any) => e.id) ?? [] + data[sid].entries = [] + data[sid].scroll = 0 + data[sid].expanded = "" + data[sid].clearedIds = [...new Set([...(data[sid].clearedIds ?? []), ...ids])] + } + } + api.kv.set(`${KV_PREFIX}.session_data`, JSON.stringify(data)) + globalEntryCache.delete(sid) + setClearTick((v) => v + 1) + const msg = t("clear.done").replace("{n}", String(count)) + api.ui.toast({ message: msg }) + } catch {} + dialog?.clear() + }} + /> + )) + }, + }, + ]) +} + +const mod: TuiPluginModule & { id: string } = { + id: "opencode-subagent-magazine", + tui, +} + +export default mod