/** @jsxImportSource @opentui/solid */ // @ts-nocheck // AFT sidebar slot. Header with "AFT" badge + version, then live status of search and semantic // indexes plus their on-disk size. Refreshes on mount/session change and on // server-pushed status invalidations with a small debounce, so the panel stays // current without polling. import { canonicalizeProjectRoot } from "@cortexkit/aft-bridge"; import type { TuiPluginApi, TuiSlotPlugin, TuiThemeCurrent } from "@opencode-ai/plugin/tui"; import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"; import { AftRpcClient } from "../shared/rpc-client"; import { type AftStatusSnapshot, coerceAftStatus, formatSemanticIndexStatus, formatSemanticRefreshing, type StatusBar, type StatusCompression, worktreeCacheRoleNote, } from "../shared/status"; import { resolveCortexKitStorageRoot } from "../shared/storage-paths"; import { badgeTextColor } from "./badge-contrast"; import { createDebouncedStatusRefresh, refreshAftTuiSocketScope, subscribeStatusInvalidations, } from "./notification-socket"; import { type AftTuiPrefs, computeEffectiveOrder, DEFAULT_PREFS, DEFAULT_SLOT_ORDER, PLUGIN_KEY, persistCollapsedIfEnabled, readTuiPreferencesFile, resolveAftPrefs, seedCollapsedFromPrefs, watchTuiPreferences, } from "./preferences"; const SINGLE_BORDER = { type: "single" } as any; const REFRESH_DEBOUNCE_MS = 200; function formatBytes(n: number): string { if (!Number.isFinite(n) || n <= 0) return "—"; if (n >= 1_073_741_824) return `${(n / 1_073_741_824).toFixed(1)} GB`; if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`; if (n >= 1_024) return `${Math.round(n / 1_024)} KB`; return `${n} B`; } function formatCount(n: number | null | undefined): string { if (n == null || !Number.isFinite(n)) return "—"; if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${Math.round(n / 1_000)}K`; return String(n); } /** Tagged rows for the Compression section. Each scope (Session / Project) * emits a "scope" header followed by two "stat" rows — Tokens Saved and * Compression Ratio — so the renderer can use the same StatRow layout as * Search Index / Semantic Index above. Pi's monospace overlay and the * OpenCode TUI dialog/sidebar all consume this same shape. */ export type CompressionRow = | { kind: "scope"; label: string } | { kind: "stat"; label: string; value: string }; function appendScope( rows: CompressionRow[], label: string, scope: { events: number; original_tokens: number; compressed_tokens: number; savings_tokens: number; }, ): void { const savings = scope.savings_tokens; const pct = scope.original_tokens > 0 ? Math.round((savings / scope.original_tokens) * 100) : 0; rows.push({ kind: "scope", label }); rows.push({ kind: "stat", label: "Tokens Saved", value: savings.toLocaleString("en-US") }); rows.push({ kind: "stat", label: "Compression Ratio", value: `${pct}%` }); } export function formatCompressionSidebarRows( compression: StatusCompression | undefined, ): CompressionRow[] { if (!compression || compression.project.events <= 0) return []; const rows: CompressionRow[] = []; if (compression.session.events > 0) { appendScope(rows, "Session", compression.session); } appendScope(rows, "Project", compression.project); return rows; } // Map index status → (label, theme color name). The label is what we want // the user to see; the color encodes severity so the eye lands on warnings. function statusDisplay(status: string): { label: string; tone: "ok" | "warn" | "err" | "muted" } { switch (status) { case "ready": return { label: "ready", tone: "ok" }; case "loading": case "building": return { label: status, tone: "warn" }; case "failed": case "error": return { label: status, tone: "err" }; case "disabled": return { label: "disabled", tone: "muted" }; default: return { label: status || "unknown", tone: "muted" }; } } const StatRow = (props: { theme: TuiThemeCurrent; label: string; value: string; tone?: "ok" | "warn" | "err" | "muted" | "accent"; }) => { const fg = createMemo(() => { switch (props.tone) { case "ok": return props.theme.success ?? props.theme.accent; case "warn": return props.theme.warning; case "err": return props.theme.error; case "muted": return props.theme.textMuted; case "accent": return props.theme.accent; default: return props.theme.text; } }); return ( {props.label} {props.value} ); }; const SectionHeader = (props: { theme: TuiThemeCurrent; title: string; marginTop?: number }) => ( {props.title} ); // Map a status tone to a theme color — used for the collapsed-view status dots. function toneColor(theme: TuiThemeCurrent, tone: "ok" | "warn" | "err" | "muted"): string { switch (tone) { case "ok": return theme.success ?? theme.accent; case "warn": return theme.warning; case "err": return theme.error; default: return theme.textMuted; } } // Collapsed-view row: label on the left, a status dot (or compact value) on the // right. Mirrors the expanded StatRow layout so the columns line up. const CollapsedRow = (props: { theme: TuiThemeCurrent; label: string; children: JSX.Element }) => ( {props.label} {props.children} ); // Compact "saved / ratio" string for the collapsed Compression row — e.g. // "7.6M / 64%". Uses the local `formatCount` (not the aft-bridge token // formatter) so the TUI bundle doesn't pull the bridge barrel, which exports // URL-fetch helpers unsuitable for Bun's TUI runtime. Returns null when no // compression has been recorded yet. export function collapsedCompressionValue( compression: StatusCompression | undefined, ): string | null { if (!compression || compression.project.events <= 0) return null; const { savings_tokens, original_tokens } = compression.project; const pct = original_tokens > 0 ? Math.round((savings_tokens / original_tokens) * 100) : 0; return `${formatCount(savings_tokens)} / ${pct}%`; } export type HealthLightTone = "ok" | "warn" | "err" | "muted"; // Degraded-mode reason → human-readable hint. Distinct strings per reason // because the UX direction is different: "home_root" tells the user to open a // real project subdirectory, "search_too_many_files" tells them the tree is too // big for full indexing, and "watcher_unavailable" is an honest soft // degradation (AFT continues without live external-change invalidation). export function degradedReasonLabel(reason: string): string { if (reason === "home_root") { return "project root is your home directory"; } if (reason.startsWith("search_too_many_files:")) { const threshold = reason.split(":")[1] ?? "20000"; return `project exceeds ${threshold} files`; } if (reason === "watcher_unavailable") { return "file watcher unavailable; continuing without live external-change invalidation"; } return reason; // unknown reason — surface verbatim so users can grep logs } export interface HealthLights { diagnostics: HealthLightTone; code: HealthLightTone; todos: HealthLightTone; } // Missing categories are intentionally muted: a green light requires an // explicit zero for every category that feeds that light. export function collapsedHealthLights(statusBar: StatusBar | undefined): HealthLights | null { if (!statusBar) return null; const diagnostics: HealthLightTone = statusBar.errors !== undefined && statusBar.errors > 0 ? "err" : statusBar.warnings !== undefined && statusBar.warnings > 0 ? "warn" : statusBar.errors === 0 && statusBar.warnings === 0 ? "ok" : "muted"; const codeValues = [statusBar.dead_code, statusBar.unused_exports, statusBar.duplicates]; const code: HealthLightTone = codeValues.some((value) => value !== undefined && value > 0) ? "warn" : codeValues.every((value) => value === 0) ? "ok" : "muted"; const todos: HealthLightTone = statusBar.todos === undefined ? "muted" : statusBar.todos > 0 ? "warn" : "ok"; return { diagnostics, code, todos }; } // Keep the TUI on the bridge's shared resolver so its root matches the // configure payload used by the plugin and binary. export function resolveTuiStorageDir(): string { return resolveCortexKitStorageRoot(); } // One RPC client per project directory — same pattern as the /aft-status // dialog handler in tui/index.tsx. Sharing the map avoids opening a second // connection just for the sidebar. const sidebarClients = new Map(); function getClient(directory: string): AftRpcClient { let client = sidebarClients.get(directory); if (client) return client; client = new AftRpcClient(resolveTuiStorageDir(), directory); sidebarClients.set(directory, client); return client; } export type ScopedSidebarStatus = { directory: string; sessionID: string; snapshot: AftStatusSnapshot; }; export function scopedSidebarSnapshot( scoped: ScopedSidebarStatus | null, directory: string, sessionID: string, ): AftStatusSnapshot | null { if (!scoped) return null; if (scoped.directory !== directory || scoped.sessionID !== sessionID) return null; return scoped.snapshot; } /** * Stale-while-revalidate guard. A transient `not_initialized` snapshot (bridge * mid-respawn after a binary swap, or a momentary session-dir key miss) arrives * over RPC as `success: true`, so a naive `setStatus` would overwrite a good * snapshot and collapse the panel to the lazy-bridge placeholder — the blank * flicker that recovers on the next refresh. Suppress the downgrade only when we * already hold initialized data for the same context; never blocks the first * real snapshot, and a genuine context switch clears separately. */ export function shouldSuppressUninitializedDowngrade( incomingCacheRole: string | undefined, haveInitializedForContext: boolean, ): boolean { return incomingCacheRole === "not_initialized" && haveInitializedForContext; } /** * Cross-project contamination belt. The RPC layer can hand back a snapshot * describing a DIFFERENT project than the one this sidebar asked about — a * multi-project host (Desktop / `opencode serve`) whose status handler * resolved another project's warm bridge, including long-lived processes * still running pre-fix plugin code. Rendering it shows another repo's * indexes/health in this window. * * A mismatched project_root is acceptable ONLY when the serving handler says * it resolved that directory DELIBERATELY: new servers attach * `served_directory` (their own cwd, or the SDK-verified `opencode -s` resume * directory) to every status response. That marker is handler-attached * provenance — it cannot be faked by snapshot contents. We explicitly do NOT * use `snapshot.session.id` here: Rust echoes the REQUESTED session id into * the snapshot, so it matches even when the data came from another project's * bridge (the hole that let contamination through this belt's first version). * * Rules: * - placeholder/synthetic snapshots (no project_root) → accept (not data) * - project_root (or canonical_root) matches the sidebar directory → accept * - mismatched root AND served_directory matches a snapshot root → accept * (deliberate, SDK-verified resume serve from a new server) * - otherwise → reject (stray; includes everything old servers cross-serve) */ export function isSnapshotForContext( snapshot: AftStatusSnapshot, directory: string, servedDirectory: string | undefined, ): boolean { // Canonicalize both sides through the SAME canonicalizer the bridge routes // by, so a symlinked / `/var`-vs-`/private/var` / trailing-slash spelling of // the sidebar directory still matches Rust's canonical_root. A raw stripSlash // compare (the old behavior) rejected legitimate snapshots whenever the TUI // directory and Rust's root were different spellings of the same location, // leaving the sidebar blank on aliased roots. const canon = (p: string) => canonicalizeProjectRoot(p); const roots = [snapshot.project_root, snapshot.canonical_root].filter( (r): r is string => typeof r === "string" && r.length > 0, ); if (roots.length === 0) return true; // placeholder / synthetic const dir = canon(directory); if (roots.some((r) => canon(r) === dir)) return true; if (typeof servedDirectory === "string" && servedDirectory.length > 0) { const served = canon(servedDirectory); return roots.some((r) => canon(r) === served); } return false; } const SidebarContent = (props: { api: TuiPluginApi; sessionID: () => string; theme: TuiThemeCurrent; pluginVersion: string; }) => { const [status, setStatus] = createSignal(null); const [prefs, setPrefs] = createSignal(structuredClone(DEFAULT_PREFS)); const [collapsed, setCollapsed] = createSignal(seedCollapsedFromPrefs(DEFAULT_PREFS)); let inflight: { controller: AbortController; generation: number; directory: string; sessionID: string; } | null = null; let generation = 0; const currentDirectory = () => props.api.state.path.directory ?? ""; const requestRender = () => { try { props.api.renderer.requestRender(); } catch { // renderer may not be available during teardown; safe to ignore } }; const abortInflight = () => { if (!inflight) return; inflight.controller.abort(); inflight = null; }; const clearStatusForContext = (directory: string, sessionID: string) => { const current = status(); if (!current) return; if (current.directory === directory && current.sessionID === sessionID) return; setStatus(null); requestRender(); }; const refresh = async () => { const sid = props.sessionID(); const directory = currentDirectory(); if (!sid || !directory) { generation++; abortInflight(); if (status()) { setStatus(null); requestRender(); } return; } clearStatusForContext(directory, sid); if (inflight) { if (inflight.directory === directory && inflight.sessionID === sid) return; generation++; abortInflight(); } const requestGeneration = ++generation; const controller = new AbortController(); inflight = { controller, generation: requestGeneration, directory, sessionID: sid }; try { const client = getClient(directory); const response = await client.call( "status", { sessionID: sid }, { signal: controller.signal, // With several RPC servers alive for this project hash, a stray // warm response (another project's bridge) must not beat the right // server or the placeholder — skip it at the port-scan level. accept: (result) => { const rec = result as Record; if (rec?.success === false) return true; // errors handled below return isSnapshotForContext( coerceAftStatus(rec), directory, rec?.served_directory as string | undefined, ); }, }, ); if (controller.signal.aborted || requestGeneration !== generation) return; if (currentDirectory() !== directory || props.sessionID() !== sid) return; if (response && (response as Record).success !== false) { const snapshot = coerceAftStatus(response as Record); // Belt: never render a snapshot describing another project (see // isSnapshotForContext). Keep whatever we currently show instead. const servedDirectory = (response as Record).served_directory as | string | undefined; if (!isSnapshotForContext(snapshot, directory, servedDirectory)) return; // Stale-while-revalidate: keep the last-good snapshot instead of // flickering to the lazy-bridge placeholder on a transient // not_initialized. See shouldSuppressUninitializedDowngrade. const current = status(); const haveGoodForContext = current !== null && current.directory === directory && current.sessionID === sid && current.snapshot.cache_role !== "not_initialized"; if (shouldSuppressUninitializedDowngrade(snapshot.cache_role, haveGoodForContext)) return; // Equality gate: a pushed invalidation can still produce the same // snapshot (for example, a session-scoped status frame that does not // affect this sidebar's visible fields). Minting a new status object // would run SolidJS reactivity and schedule a host frame for no visible // change. Skip the update when the freshly-fetched snapshot is // byte-identical to what we already show for this exact context. // JSON.stringify is sound here because the snapshot is a plain object // coerced from the status RPC's JSON. if ( current !== null && current.directory === directory && current.sessionID === sid && JSON.stringify(current.snapshot) === JSON.stringify(snapshot) ) { return; } setStatus({ directory, sessionID: sid, snapshot }); requestRender(); } } catch { if (controller.signal.aborted || requestGeneration !== generation) return; // RPC server may not be ready yet, or the bridge may be respawning // after a binary swap. Keep the previous snapshot only when it belongs // to the current project/session; mismatched snapshots were cleared above. } finally { if (inflight?.generation === requestGeneration) inflight = null; } }; const statusDebouncer = createDebouncedStatusRefresh(refresh, REFRESH_DEBOUNCE_MS); const scheduleRefresh = () => statusDebouncer.schedule(); const reloadPrefs = async () => { const root = await readTuiPreferencesFile(); const next = resolveAftPrefs(root); setPrefs(next); setCollapsed(seedCollapsedFromPrefs(next)); requestRender(); }; void reloadPrefs(); const unwatchPrefs = watchTuiPreferences(() => { void reloadPrefs(); }); onCleanup(() => { unwatchPrefs(); generation++; abortInflight(); statusDebouncer.dispose(); }); // Refresh on session id change + initial load createEffect( on(props.sessionID, () => { refreshAftTuiSocketScope(); void refresh(); }), ); // Wire live updates: the server pushes a lightweight invalidation whenever // the bridge reports a status change. The sidebar coalesces bursts into one // trailing status fetch and stays completely idle when no backend state changes. createEffect( on( props.sessionID, (sessionID) => { if (!sessionID) return; const unsubscribe = subscribeStatusInvalidations((event) => { if (event.sessionId && event.sessionId !== props.sessionID()) return; scheduleRefresh(); }); onCleanup(() => { unsubscribe(); generation++; abortInflight(); }); }, { defer: false }, ), ); const s = () => scopedSidebarSnapshot(status(), currentDirectory(), props.sessionID()); // Lazy-bridge: while AFT has no live bridge yet, the RPC server returns a // synthetic snapshot with `cache_role === "not_initialized"`. In that state // every metric is unknown by design — not "disabled" — so we hide the // version line and the entire Search Index / Semantic Index / Compression // grid until a first tool call warms the bridge. Users were reading the // pre-init `vunknown` + `Status: unknown` rows as broken state instead of // "AFT has not been used yet for this project". const notInitialized = () => s()?.cache_role === "not_initialized"; // Pre-compute display values so the JSX stays readable. createMemo for // each derived field would be overkill — these are cheap derivations. const searchStatus = () => statusDisplay(s()?.search_index?.status ?? "disabled"); const semanticStatus = () => { const rawStatus = s()?.semantic_index?.status ?? "disabled"; const display = statusDisplay(rawStatus); return { ...display, label: formatSemanticIndexStatus(rawStatus, s()?.semantic_index?.stage), }; }; const semanticRefreshing = () => formatSemanticRefreshing(s()?.semantic_index?.refreshing_count ?? 0); const trigramBytes = () => s()?.disk?.trigram_disk_bytes ?? 0; const semanticBytes = () => s()?.disk?.semantic_disk_bytes ?? 0; const compressionRows = () => formatCompressionSidebarRows(s()?.compression); const statusBar = () => s()?.status_bar; const degradedSummary = () => { const snap = s(); if (!snap?.degraded) return null; const reasons = snap.degraded_reasons ?? []; if (reasons.length === 0) return null; return reasons.map(degradedReasonLabel).join("; "); }; // Worktree borrow is a shared-index arrangement, not a degraded_reasons // entry. Keep this muted and separate from the DEGRADED badge above. const worktreeNote = () => worktreeCacheRoleNote(s()?.cache_role); return ( {/* Header: triangle toggle + AFT badge + binary version + degraded badge. Clicking the header row collapses/expands the panel (mirrors OpenCode's native MCP sidebar section). Only interactive once initialized — the lazy-bridge placeholder has nothing to collapse. */} { if (notInitialized()) return; setCollapsed((x) => { const next = !x; persistCollapsedIfEnabled(prefs(), next); return next; }); }} > {/* Triangle lives inside the accent badge so the toggle reads as one unit: "▶ AFT" / "▼ AFT". Hidden pre-init (nothing to collapse). */} {notInitialized() ? "" : collapsed() ? "▶ " : "▼ "} {prefs().header.label} {s()?.degraded && ( DEGRADED )} {!notInitialized() && prefs().header.showVersion && ( v{s()?.version ?? props.pluginVersion} )} {/* Degraded reason — explains why heavy tools (aft_search, aft_callgraph) are disabled. Surface this prominently so users know to open a real project subdirectory if they want full features. */} {s()?.degraded && degradedSummary() && ( ⚠ {degradedSummary()} )} {!notInitialized() && worktreeNote() && ( {worktreeNote()} )} {/* Lazy-bridge placeholder. AFT skips spawning the `aft` binary at plugin init to keep memory/CPU low on OpenCode Desktop sessions that have many projects pinned in the sidebar. The RPC server returns a synthetic `cache_role === "not_initialized"` snapshot until the first tool call routes through `callBridge()` and warms the bridge. Show the explanatory message instead of empty status rows so users understand why metrics are blank. */} {notInitialized() && ( {s()!.message || "AFT bridge is now spawned lazily, information here will be populated after first tool call."} )} {/* Collapsed view — condensed status dots + compact compression. Shown only when initialized AND collapsed. Three rows mirroring the section order of the expanded grid. */} {!notInitialized() && collapsed() && ( {prefs().sections.searchIndex && ( )} {prefs().sections.semanticIndex && ( )} {prefs().sections.codeHealth && collapsedHealthLights(statusBar()) && ( )} {prefs().sections.compression && collapsedCompressionValue(s()?.compression) && ( {collapsedCompressionValue(s()?.compression)} )} )} {/* Search index */} {!notInitialized() && !collapsed() && ( <> {prefs().sections.searchIndex && ( <> {(s()?.search_index?.files ?? null) != null && ( )} )} {prefs().sections.semanticIndex && ( <> {semanticRefreshing() && ( {semanticRefreshing()} )} {/* When loading, magic-context-style progress hint helps users see background work is making progress instead of stuck. */} {s()?.semantic_index?.status === "loading" && s()?.semantic_index?.entries_total != null && s()!.semantic_index.entries_total! > 0 && ( )} {(s()?.semantic_index?.entries ?? null) != null && ( )} )} {/* Human health values are optional. A category stays absent until the server proves it, rather than appearing as a clean zero. */} {prefs().sections.codeHealth && statusBar() && ( <> {statusBar()!.errors !== undefined && ( 0 ? "err" : "muted"} /> )} {statusBar()!.warnings !== undefined && ( 0 ? "warn" : "muted"} /> )} {statusBar()!.dead_code !== undefined && ( )} {statusBar()!.unused_exports !== undefined && ( )} {statusBar()!.duplicates !== undefined && ( )} {statusBar()!.todos !== undefined && ( )} )} {/* Compression aggregates. Tabular layout matching Search/Semantic Index above: each scope ("Session", "Project") renders as a subheader followed by two StatRows (Tokens Saved, Compression Ratio). Keeps numbers right-aligned in the value column instead of jamming them after the label on the same line. */} {prefs().sections.compression && compressionRows().length > 0 && ( <> {compressionRows().map((row) => row.kind === "scope" ? ( {row.label} ) : ( ), )} )} {/* Surface failures clearly so users know to act (install ONNX, fix config, etc.) rather than silently leaving the panel "off". */} {s()?.semantic_index?.status === "failed" && s()?.semantic_index?.error && ( ⚠ {s()!.semantic_index.error} )} )} ); }; export async function createAftSidebarSlot( api: TuiPluginApi, pluginVersion: string, ): Promise { const root = await readTuiPreferencesFile(); const order = computeEffectiveOrder(root, PLUGIN_KEY, DEFAULT_SLOT_ORDER); return { // DEFAULT_SLOT_ORDER (180) is AFT's coordinated default in the shared // tui-preferences ladder (anthropic-auth 160, AFT 180, magic-context 200). // Override via `order` or `forceToTop` in tui-preferences.jsonc. order, slots: { sidebar_content: (ctx, value) => { const theme = createMemo(() => (ctx as any).theme.current); return ( value.session_id} theme={theme()} pluginVersion={pluginVersion} /> ); }, }, }; }