/** @jsxImportSource @opentui/solid */ // @ts-nocheck import type { TuiPlugin, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui"; import { createMemo, createSignal, onCleanup } from "solid-js"; import { version as packageVersion } from "../../package.json"; import { AftRpcClient } from "../shared/rpc-client"; import { type AftStatusSnapshot, coerceAftStatus, formatBytes, formatCacheRoleLabel, formatSemanticIndexStatus, formatSemanticRefreshing, } from "../shared/status"; import { createDebouncedStatusRefresh, refreshAftTuiSocketScope, type SocketNotification, startAftTuiSocket, stopAftTuiSocket, subscribeStatusInvalidations, } from "./notification-socket"; import { createAftSidebarSlot, formatCompressionSidebarRows, isSnapshotForContext, resolveTuiStorageDir, shouldSuppressUninitializedDowngrade, } from "./sidebar"; // The TUI talks to the server plugin via AftRpcClient. The client reads the // JSON port file written by AftRpcServer ({ port, token }) and includes that // per-server token on every RPC request; legacy integer port files are still // tolerated for already-running older server plugins. // RPC clients keyed by directory — one per project const rpcClients = new Map(); function getRpcClient(directory: string): AftRpcClient { let client = rpcClients.get(directory); if (client) return client; client = new AftRpcClient(resolveTuiStorageDir(), directory); rpcClients.set(directory, client); return client; } function getSessionId(api: TuiPluginApi): string | null { try { const route = api.route.current; if (route?.name === "session" && route.params?.sessionID) { return route.params.sessionID; } } catch { // ignore } return null; } // --------------------------------------------------------------------------- // StatusDialog — themed, two-column JSX dialog. OpenCode renders DialogAlert // text in a proportional font with no column alignment, so the status view uses // TUI flex primitives instead of a padded monospace string. The component // subscribes to server-pushed invalidations so it can re-render as the status // snapshot changes, with no parent re-mount. // --------------------------------------------------------------------------- function formatCountShort(value: number | null | undefined): string { if (value == null || !Number.isFinite(value)) return "—"; if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; if (value >= 1_000) return `${Math.round(value / 1_000)}K`; return String(value); } function statusTone(status: string): "ok" | "warn" | "err" | "muted" { switch (status) { case "ready": return "ok"; case "loading": case "building": return "warn"; case "failed": case "error": return "err"; default: return "muted"; } } function pickToneColor(theme: TuiThemeCurrent, tone: "ok" | "warn" | "err" | "muted"): string { switch (tone) { case "ok": return (theme as any).success ?? theme.accent; case "warn": return theme.warning; case "err": return theme.error; case "muted": return theme.textMuted; } } /** * Label/value row. Label is left-aligned and muted; value is right-aligned * and themed. flexDirection="row" + justifyContent="space-between" replaces * the monospace `padEnd(40)` hack from the previous string formatter. */ const R = (props: { theme: TuiThemeCurrent; label: string; value: string; tone?: "ok" | "warn" | "err" | "muted" | "accent"; }) => { const fg = createMemo(() => { if (!props.tone) return props.theme.text; if (props.tone === "accent") return props.theme.accent; return pickToneColor(props.theme, props.tone); }); return ( {props.label} {props.value} ); }; interface StatusDialogProps { api: TuiPluginApi; client: AftRpcClient; directory: string; sessionID: string; initial: AftStatusSnapshot | null; initialError: string | null; } /** * Shared accept-gate for status RPC calls: skip warm responses that describe * another project (cross-project contamination from multi-project hosts — * see isSnapshotForContext) so they can't beat the right server's response. */ function statusAcceptGate(directory: string): (result: unknown) => boolean { return (result) => { const rec = result as Record; if (rec?.success === false) return true; // error envelopes handled by callers return isSnapshotForContext( coerceAftStatus(rec), directory, rec?.served_directory as string | undefined, ); }; } const StatusDialog = (props: StatusDialogProps) => { const theme = createMemo(() => (props.api as any).theme.current as TuiThemeCurrent); const t = () => theme(); // Reactive status signal — the dialog re-renders on pushed status // invalidations without remounting. const [status, setStatus] = createSignal(props.initial); const [error, setError] = createSignal(props.initialError); let refreshGeneration = 0; let refreshController: AbortController | null = null; const refreshStatus = async () => { if (refreshController) return; const controller = new AbortController(); const requestGeneration = ++refreshGeneration; refreshController = controller; try { const response = await props.client.call( "status", { sessionID: props.sessionID }, { signal: controller.signal, accept: statusAcceptGate(props.directory) }, ); if (controller.signal.aborted || requestGeneration !== refreshGeneration) return; if ((response as Record).success !== false) { const snapshot = coerceAftStatus(response as Record); // Stale-while-revalidate: don't downgrade a good snapshot to a transient // `not_initialized` (for example, while the bridge process respawns or // a session directory lookup briefly misses) — it arrives as success:true // and would blank the dialog until the next refresh. const current = status(); if ( shouldSuppressUninitializedDowngrade( snapshot.cache_role, current !== null && current.cache_role !== "not_initialized", ) ) { return; } setStatus(snapshot); setError(null); } } catch { if (controller.signal.aborted || requestGeneration !== refreshGeneration) return; // transient — keep showing last good snapshot } finally { if (refreshController === controller) refreshController = null; } }; const statusDebouncer = createDebouncedStatusRefresh(refreshStatus, 200); const unsubscribeStatusInvalidations = subscribeStatusInvalidations((event) => { if (event.sessionId && event.sessionId !== props.sessionID) return; statusDebouncer.schedule(); }); onCleanup(() => { unsubscribeStatusInvalidations(); statusDebouncer.dispose(); refreshGeneration++; if (refreshController) { refreshController.abort(); refreshController = null; } }); // Visual cache-role badge: main is accent. Worktree/read_only borrow the // shared repo index — muted, not warning, so it is not read as a failure. const cacheRoleTone = (role: string): "accent" | "warn" | "muted" => role === "main" ? "accent" : "muted"; // Reuse the sidebar's label/value formatter so the dialog and sidebar // render identical text (e.g. "Session" / "-174,489 tokens, 59% reduction"). // The earlier `formatCompressionDialogRows` returned padded strings that // looked offset against neighboring sections. const compressionAggregateRows = () => formatCompressionSidebarRows(status()?.compression); return ( {/* Title. Hide version while the lazy-spawn placeholder is showing — users read `vunknown` next to "AFT Status" as broken state instead of "AFT has not been used yet for this project". */} ⚡ AFT Status {status()?.cache_role !== "not_initialized" && ( v{status()?.version ?? packageVersion} )} {/* Error / not-yet-ready state */} {error() ? ( {error()} ) : null} {/* Lazy-bridge placeholder: when no bridge has spawned for this project yet (because the user has not made any tool call since opening OpenCode), the RPC server returns a synthetic snapshot with cache_role === "not_initialized". Show the explanatory message instead of an empty grid of zeros and "unknown" rows. */} {status()?.cache_role === "not_initialized" ? ( {status()!.message || "AFT bridge is now spawned lazily, information here will be populated after first tool call."} ) : null} {/* Header rows — paths span full width since they can be long */} {status() && status()!.cache_role !== "not_initialized" ? ( ) : null} {/* 2-column body. Gate on cache_role too so a synthetic not_initialized snapshot doesn't render an empty grid of "unknown" / "—" rows alongside the lazy-spawn placeholder message above. */} {status() && status()!.cache_role !== "not_initialized" ? ( {/* Left column */} Search index Runtime Features {/* Right column */} Semantic index {formatSemanticRefreshing(status()!.semantic_index.refreshing_count) ? ( {formatSemanticRefreshing(status()!.semantic_index.refreshing_count)} ) : null} {status()!.semantic_index.backend ? ( ) : null} {status()!.semantic_index.model ? ( ) : null} {status()!.semantic_index.dimension != null ? ( ) : null} Current session ) : null} {/* Optional semantic build progress — full-width below the columns */} {status()?.semantic_index.stage ? ( Semantic build progress {status()!.semantic_index.files != null ? ( ) : null} {status()!.semantic_index.entries_done != null || status()!.semantic_index.entries_total != null ? ( ) : null} ) : null} {/* Semantic error — full-width, themed error color */} {status()?.semantic_index.error ? ( ⚠ {status()!.semantic_index.error} ) : null} {/* Compression aggregates — tabular layout matching Search/Semantic Index. Each scope ("Session", "Project") renders as a subheader followed by two rows (Tokens Saved, Compression Ratio) so the numbers stay right-aligned under the value column instead of crowding the label. */} {compressionAggregateRows().length > 0 ? ( Compression {compressionAggregateRows().map((row) => row.kind === "scope" ? ( {row.label} ) : ( ), )} ) : null} {/* Human health values are optional. Omit a category until the server proves it rather than displaying an unproven zero as clean. */} {status()?.status_bar ? ( {status()!.status_bar!.tier2_stale ? "Code Health ~" : "Code Health"} {status()!.status_bar!.errors !== undefined ? ( 0 ? "err" : "muted"} /> ) : null} {status()!.status_bar!.warnings !== undefined ? ( 0 ? "warn" : "muted"} /> ) : null} {status()!.status_bar!.dead_code !== undefined ? ( ) : null} {status()!.status_bar!.unused_exports !== undefined ? ( ) : null} {status()!.status_bar!.duplicates !== undefined ? ( ) : null} {status()!.status_bar!.todos !== undefined ? ( ) : null} ) : null} {/* Footer */} Enter or Esc to close ); }; async function showStatusDialog(api: TuiPluginApi): Promise { const sessionID = getSessionId(api); if (!sessionID) { api.ui.toast({ message: "No active session", variant: "warning", duration: 5000 }); return; } const directory = api.state.path.directory ?? ""; if (!directory) { api.ui.toast({ message: "No project directory", variant: "warning", duration: 5000 }); return; } const client = getRpcClient(directory); // Prime the dialog with one initial fetch so we don't show a blank // skeleton — the component then listens for pushed invalidations. let initial: AftStatusSnapshot | null = null; let initialError: string | null = null; try { const response = await client.call( "status", { sessionID }, { accept: statusAcceptGate(directory) }, ); if ((response as Record).success !== false) { initial = coerceAftStatus(response as Record); } else { initialError = "AFT bridge returned an error response."; } } catch { initialError = "AFT is starting up. Status will refresh automatically..."; } // The dialog host already wraps every replace()'d element in its own centered // frame and binds Esc/Ctrl-C to close it. Rendering our own dialog frame inside // that host frame would misplace the content and focus; pass the bare component // so the host frames it once. // Do not pass an onClose that calls dialog.clear(). The dialog system calls // every entry's onClose on Esc, and clear() would re-trigger them, causing // infinite recursion. The host itself pops the dialog; StatusDialog cleans up // its own subscriptions in onCleanup. `replace` resets size to "medium", so // request "large" after the call. api.ui.dialog.replace(() => ( )); api.ui.dialog.setSize("large"); } /** * Register the AFT status palette command, preferring the v1.14.42+ keymap API * and falling back to the legacy `api.command.register` for older hosts. * * No slash entry is registered here. The server config hook owns the single * `/aft-status` slash registration, while this palette command keeps Ctrl+P * discovery in the TUI. */ function registerStatusCommand(api: TuiPluginApi): void { type ApiAny = { keymap?: { registerLayer?: (layer: { commands: Array>; bindings: Array>; }) => unknown; }; command?: { register?: (cb: () => Array>) => unknown; }; }; const apiAny = api as unknown as ApiAny; if (typeof apiAny.keymap?.registerLayer === "function") { apiAny.keymap.registerLayer({ commands: [ { namespace: "palette", name: "aft.status", title: "AFT: Status", category: "AFT", run() { void showStatusDialog(api); }, }, ], bindings: [], }); return; } if (typeof apiAny.command?.register === "function") { apiAny.command.register(() => [ { title: "AFT: Status", value: "aft.status", category: "AFT", onSelect() { void showStatusDialog(api); }, }, ]); return; } // Neither API surface is present. The TUI host can still load; users can // still see AFT status through the sidebar and server-owned slash command. } async function showStartupNotifications(api: TuiPluginApi): Promise { const directory = api.state.path.directory ?? ""; if (!directory) return; const client = getRpcClient(directory); // Check for feature announcements try { const announcement = (await client.call("get-announcement", {})) as { show?: boolean; version?: string; features?: string[]; footer?: string; }; if (announcement.show && announcement.version && announcement.features?.length) { const featureText = announcement.features.map((f: string) => ` • ${f}`).join("\n"); // Blank-line separator distinguishes the persistent footer (Discord // invite, etc.) from the version-specific bullets. const hasFooter = typeof announcement.footer === "string" && announcement.footer.trim().length > 0; const message = hasFooter ? `What's new:\n\n${featureText}\n\n${announcement.footer}` : `What's new:\n\n${featureText}`; api.ui.dialog.replace( () => ( { // Mark as announced so it doesn't show again void client.call("mark-announced", {}); }} /> ), () => { void client.call("mark-announced", {}); }, ); return; // Show one dialog at a time } } catch { // RPC server not ready yet — skip announcements } // Check for warnings try { const result = (await client.call("get-warnings", {})) as { warnings?: string[] }; if (result.warnings?.length) { const warningText = result.warnings.join("\n\n"); api.ui.dialog.replace( () => {}} />, () => {}, ); } } catch { // RPC server not ready — skip warnings } } const tui: TuiPlugin = async (api) => { // Sidebar slot: live status of search index, semantic index, and disk // usage. See ./sidebar.tsx for the panel itself. Registered before the // command palette entry so the sidebar is available immediately when the // user opens their first session. try { api.slots.register(await createAftSidebarSlot(api, packageVersion)); } catch { // Older OpenCode TUI hosts may not implement api.slots; fall through and // keep the palette command / server-owned slash command working. } // OpenCode 1.14.42 removed `api.command.register` entirely // (anomalyco/opencode PR #26053). Later patches reinstated it as a // deprecated shim that translates to `api.keymap.registerLayer`. To work // across the whole 1.14.x line — including the brief 1.14.42 / 1.14.43 // window where neither the legacy API nor a shim was present — we prefer // `api.keymap.registerLayer` and fall back to `api.command.register` only // when the keymap surface is missing (older hosts that predate keymap). // See https://github.com/cortexkit/aft/issues/33. registerStatusCommand(api); // Receive server → TUI dialog requests and status invalidations over one // persistent WebSocket. This avoids repeated loopback HTTP connection setup, // which caused idle CPU usage. const handleNotification = async (message: SocketNotification): Promise => { const requestedSessionId = getSessionId(api); if (!requestedSessionId) return false; if (message.sessionId && message.sessionId !== requestedSessionId) return false; if (message.type !== "action") return false; if (message.payload?.action !== "show-status-dialog") return false; await showStatusDialog(api); return true; }; startAftTuiSocket({ getDirectory: () => api.state.path.directory ?? "", getSessionId: () => getSessionId(api), onNotification: handleNotification, }); const socketScopeUnsubs = [ api.event?.on?.("message.updated", () => refreshAftTuiSocketScope()), api.event?.on?.("session.updated", () => refreshAftTuiSocketScope()), ].filter(Boolean); api.lifecycle?.onDispose?.(() => { stopAftTuiSocket(); for (const unsub of socketScopeUnsubs) { try { unsub(); } catch { // Ignore unsubscribe errors during cleanup; socket shutdown is handled separately. } } for (const client of rpcClients.values()) client.reset(); rpcClients.clear(); }); // Show startup notifications — RPC server is already running by the time TUI loads void showStartupNotifications(api); }; const id = "aft-opencode"; export default { id, tui, };