import { createComponent as _$createComponent } from "opentui:runtime-module:%40opentui%2Fsolid"; import { memo as _$memo } from "opentui:runtime-module:%40opentui%2Fsolid"; import { createTextNode as _$createTextNode } from "opentui:runtime-module:%40opentui%2Fsolid"; import { effect as _$effect } from "opentui:runtime-module:%40opentui%2Fsolid"; import { insertNode as _$insertNode } from "opentui:runtime-module:%40opentui%2Fsolid"; import { insert as _$insert } from "opentui:runtime-module:%40opentui%2Fsolid"; import { setProp as _$setProp } from "opentui:runtime-module:%40opentui%2Fsolid"; import { createElement as _$createElement } from "opentui:runtime-module:%40opentui%2Fsolid"; /** @jsxImportSource @opentui/solid */ // @ts-nocheck import { createMemo, createSignal, onCleanup } from "opentui:runtime-module:solid-js"; import { version as packageVersion } from "../../package.json"; import { AftRpcClient } from "../shared/rpc-client"; import { coerceAftStatus, formatBytes, formatCacheRoleLabel, formatSemanticIndexStatus, formatSemanticRefreshing } from "../shared/status"; import { createDebouncedStatusRefresh, refreshAftTuiSocketScope, 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) { let client = rpcClients.get(directory); if (client) return client; client = new AftRpcClient(resolveTuiStorageDir(), directory); rpcClients.set(directory, client); return client; } function getSessionId(api) { 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) { 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) { switch (status) { case "ready": return "ok"; case "loading": case "building": return "warn"; case "failed": case "error": return "err"; default: return "muted"; } } function pickToneColor(theme, tone) { switch (tone) { case "ok": return theme.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 => { 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 (() => { var _el$ = _$createElement("box"), _el$2 = _$createElement("text"), _el$3 = _$createElement("text"); _$insertNode(_el$, _el$2); _$insertNode(_el$, _el$3); _$setProp(_el$, "flexDirection", "row"); _$setProp(_el$, "width", "100%"); _$setProp(_el$, "justifyContent", "space-between"); _$insert(_el$2, () => props.label); _$insert(_el$3, () => props.value); _$effect(_p$ => { var _v$ = props.theme.textMuted, _v$2 = fg(); _v$ !== _p$.e && (_p$.e = _$setProp(_el$2, "fg", _v$, _p$.e)); _v$2 !== _p$.t && (_p$.t = _$setProp(_el$3, "fg", _v$2, _p$.t)); return _p$; }, { e: undefined, t: undefined }); return _el$; })(); }; /** * 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) { return result => { const rec = result; if (rec?.success === false) return true; // error envelopes handled by callers return isSnapshotForContext(coerceAftStatus(rec), directory, rec?.served_directory); }; } const StatusDialog = props => { const theme = createMemo(() => props.api.theme.current); 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 = 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.success !== false) { const snapshot = coerceAftStatus(response); // 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 => 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 (() => { var _el$4 = _$createElement("box"), _el$5 = _$createElement("box"), _el$6 = _$createElement("text"), _el$7 = _$createElement("b"), _el$9 = _$createElement("box"), _el$0 = _$createElement("text"); _$insertNode(_el$4, _el$5); _$insertNode(_el$4, _el$9); _$setProp(_el$4, "flexDirection", "column"); _$setProp(_el$4, "width", "100%"); _$setProp(_el$4, "paddingLeft", 2); _$setProp(_el$4, "paddingRight", 2); _$setProp(_el$4, "paddingTop", 1); _$setProp(_el$4, "paddingBottom", 1); _$insertNode(_el$5, _el$6); _$setProp(_el$5, "justifyContent", "center"); _$setProp(_el$5, "width", "100%"); _$setProp(_el$5, "marginBottom", 1); _$setProp(_el$5, "flexDirection", "row"); _$setProp(_el$5, "gap", 2); _$insertNode(_el$6, _el$7); _$insertNode(_el$7, _$createTextNode(`⚡ AFT Status`)); _$insert(_el$5, (() => { var _c$ = _$memo(() => status()?.cache_role !== "not_initialized"); return () => _c$() && (() => { var _el$10 = _$createElement("text"), _el$11 = _$createTextNode(`v`); _$insertNode(_el$10, _el$11); _$insert(_el$10, () => status()?.version ?? packageVersion, null); _$effect(_$p => _$setProp(_el$10, "fg", t().textMuted, _$p)); return _el$10; })(); })(), null); _$insert(_el$4, (() => { var _c$2 = _$memo(() => !!error()); return () => _c$2() ? (() => { var _el$12 = _$createElement("box"), _el$13 = _$createElement("text"); _$insertNode(_el$12, _el$13); _$setProp(_el$12, "width", "100%"); _$setProp(_el$12, "marginBottom", 1); _$insert(_el$13, error); _$effect(_$p => _$setProp(_el$13, "fg", t().warning, _$p)); return _el$12; })() : null; })(), _el$9); _$insert(_el$4, (() => { var _c$3 = _$memo(() => status()?.cache_role === "not_initialized"); return () => _c$3() ? (() => { var _el$14 = _$createElement("box"), _el$15 = _$createElement("text"); _$insertNode(_el$14, _el$15); _$setProp(_el$14, "width", "100%"); _$setProp(_el$14, "marginTop", 1); _$setProp(_el$14, "justifyContent", "center"); _$insert(_el$15, () => status().message || "AFT bridge is now spawned lazily, information here will be populated after first tool call."); _$effect(_$p => _$setProp(_el$15, "fg", t().textMuted, _$p)); return _el$14; })() : null; })(), _el$9); _$insert(_el$4, (() => { var _c$4 = _$memo(() => !!(status() && status().cache_role !== "not_initialized")); return () => _c$4() ? (() => { var _el$16 = _$createElement("box"); _$setProp(_el$16, "flexDirection", "column"); _$setProp(_el$16, "width", "100%"); _$setProp(_el$16, "marginBottom", 1); _$insert(_el$16, _$createComponent(R, { get theme() { return t(); }, label: "Project root", get value() { return status().project_root ?? "(not configured)"; } }), null); _$insert(_el$16, _$createComponent(R, { get theme() { return t(); }, label: "Canonical root", get value() { return status().canonical_root ?? "(not configured)"; } }), null); _$insert(_el$16, _$createComponent(R, { get theme() { return t(); }, label: "Cache role", get value() { return formatCacheRoleLabel(status().cache_role); }, get tone() { return cacheRoleTone(status().cache_role); } }), null); return _el$16; })() : null; })(), _el$9); _$insert(_el$4, (() => { var _c$5 = _$memo(() => !!(status() && status().cache_role !== "not_initialized")); return () => _c$5() ? (() => { var _el$17 = _$createElement("box"), _el$18 = _$createElement("box"), _el$19 = _$createElement("text"), _el$20 = _$createElement("b"), _el$22 = _$createElement("box"), _el$23 = _$createElement("text"), _el$24 = _$createElement("b"), _el$26 = _$createElement("box"), _el$27 = _$createElement("text"), _el$28 = _$createElement("b"), _el$30 = _$createElement("box"), _el$31 = _$createElement("text"), _el$32 = _$createElement("b"), _el$34 = _$createElement("box"), _el$35 = _$createElement("text"), _el$36 = _$createElement("b"); _$insertNode(_el$17, _el$18); _$insertNode(_el$17, _el$30); _$setProp(_el$17, "flexDirection", "row"); _$setProp(_el$17, "width", "100%"); _$setProp(_el$17, "gap", 4); _$insertNode(_el$18, _el$19); _$insertNode(_el$18, _el$22); _$insertNode(_el$18, _el$26); _$setProp(_el$18, "flexDirection", "column"); _$setProp(_el$18, "flexGrow", 1); _$setProp(_el$18, "flexBasis", 0); _$insertNode(_el$19, _el$20); _$insertNode(_el$20, _$createTextNode(`Search index`)); _$insert(_el$18, _$createComponent(R, { get theme() { return t(); }, label: "Status", get value() { return status().search_index.status; }, get tone() { return statusTone(status().search_index.status); } }), _el$22); _$insert(_el$18, _$createComponent(R, { get theme() { return t(); }, label: "Files", get value() { return formatCountShort(status().search_index.files); } }), _el$22); _$insert(_el$18, _$createComponent(R, { get theme() { return t(); }, label: "Trigrams", get value() { return formatCountShort(status().search_index.trigrams); } }), _el$22); _$insert(_el$18, _$createComponent(R, { get theme() { return t(); }, label: "Disk", get value() { return formatBytes(status().disk.trigram_disk_bytes); }, tone: "muted" }), _el$22); _$insertNode(_el$22, _el$23); _$setProp(_el$22, "marginTop", 1); _$insertNode(_el$23, _el$24); _$insertNode(_el$24, _$createTextNode(`Runtime`)); _$insert(_el$18, _$createComponent(R, { get theme() { return t(); }, label: "LSP servers", get value() { return String(status().lsp_servers); } }), _el$26); _$insert(_el$18, _$createComponent(R, { get theme() { return t(); }, label: "Symbol cache (local)", get value() { return formatCountShort(status().symbol_cache.local_entries); } }), _el$26); _$insert(_el$18, _$createComponent(R, { get theme() { return t(); }, label: "Symbol cache (warm)", get value() { return formatCountShort(status().symbol_cache.warm_entries); }, tone: "muted" }), _el$26); _$insertNode(_el$26, _el$27); _$setProp(_el$26, "marginTop", 1); _$insertNode(_el$27, _el$28); _$insertNode(_el$28, _$createTextNode(`Features`)); _$insert(_el$18, _$createComponent(R, { get theme() { return t(); }, label: "format_on_edit", get value() { return status().features.format_on_edit ? "on" : "off"; }, get tone() { return status().features.format_on_edit ? "ok" : "muted"; } }), null); _$insert(_el$18, _$createComponent(R, { get theme() { return t(); }, label: "search_index", get value() { return status().features.search_index ? "on" : "off"; }, get tone() { return status().features.search_index ? "ok" : "muted"; } }), null); _$insert(_el$18, _$createComponent(R, { get theme() { return t(); }, label: "semantic_search", get value() { return status().features.semantic_search ? "on" : "off"; }, get tone() { return status().features.semantic_search ? "ok" : "muted"; } }), null); _$insertNode(_el$30, _el$31); _$insertNode(_el$30, _el$34); _$setProp(_el$30, "flexDirection", "column"); _$setProp(_el$30, "flexGrow", 1); _$setProp(_el$30, "flexBasis", 0); _$insertNode(_el$31, _el$32); _$insertNode(_el$32, _$createTextNode(`Semantic index`)); _$insert(_el$30, _$createComponent(R, { get theme() { return t(); }, label: "Status", get value() { return formatSemanticIndexStatus(status().semantic_index.status, status().semantic_index.stage); }, get tone() { return statusTone(status().semantic_index.status); } }), _el$34); _$insert(_el$30, (() => { var _c$0 = _$memo(() => !!formatSemanticRefreshing(status().semantic_index.refreshing_count)); return () => _c$0() ? (() => { var _el$38 = _$createElement("box"), _el$39 = _$createElement("text"); _$insertNode(_el$38, _el$39); _$setProp(_el$38, "width", "100%"); _$insert(_el$39, () => formatSemanticRefreshing(status().semantic_index.refreshing_count)); _$effect(_$p => _$setProp(_el$39, "fg", t().textMuted, _$p)); return _el$38; })() : null; })(), _el$34); _$insert(_el$30, _$createComponent(R, { get theme() { return t(); }, label: "Entries", get value() { return formatCountShort(status().semantic_index.entries); } }), _el$34); _$insert(_el$30, (() => { var _c$1 = _$memo(() => !!status().semantic_index.backend); return () => _c$1() ? _$createComponent(R, { get theme() { return t(); }, label: "Backend", get value() { return status().semantic_index.backend; }, tone: "muted" }) : null; })(), _el$34); _$insert(_el$30, (() => { var _c$10 = _$memo(() => !!status().semantic_index.model); return () => _c$10() ? _$createComponent(R, { get theme() { return t(); }, label: "Model", get value() { return status().semantic_index.model; }, tone: "muted" }) : null; })(), _el$34); _$insert(_el$30, (() => { var _c$11 = _$memo(() => status().semantic_index.dimension != null); return () => _c$11() ? _$createComponent(R, { get theme() { return t(); }, label: "Dimension", get value() { return String(status().semantic_index.dimension); }, tone: "muted" }) : null; })(), _el$34); _$insert(_el$30, _$createComponent(R, { get theme() { return t(); }, label: "Disk", get value() { return formatBytes(status().disk.semantic_disk_bytes); }, tone: "muted" }), _el$34); _$insertNode(_el$34, _el$35); _$setProp(_el$34, "marginTop", 1); _$insertNode(_el$35, _el$36); _$insertNode(_el$36, _$createTextNode(`Current session`)); _$insert(_el$30, _$createComponent(R, { get theme() { return t(); }, label: "Tracked files", get value() { return String(status().session.tracked_files); } }), null); _$insert(_el$30, _$createComponent(R, { get theme() { return t(); }, label: "Checkpoints", get value() { return String(status().session.checkpoints); } }), null); _$insert(_el$30, _$createComponent(R, { get theme() { return t(); }, label: "All-session checkpoints", get value() { return String(status().checkpoints_total); }, tone: "muted" }), null); _$effect(_p$ => { var _v$5 = t().text, _v$6 = t().text, _v$7 = t().text, _v$8 = t().text, _v$9 = t().text; _v$5 !== _p$.e && (_p$.e = _$setProp(_el$19, "fg", _v$5, _p$.e)); _v$6 !== _p$.t && (_p$.t = _$setProp(_el$23, "fg", _v$6, _p$.t)); _v$7 !== _p$.a && (_p$.a = _$setProp(_el$27, "fg", _v$7, _p$.a)); _v$8 !== _p$.o && (_p$.o = _$setProp(_el$31, "fg", _v$8, _p$.o)); _v$9 !== _p$.i && (_p$.i = _$setProp(_el$35, "fg", _v$9, _p$.i)); return _p$; }, { e: undefined, t: undefined, a: undefined, o: undefined, i: undefined }); return _el$17; })() : null; })(), _el$9); _$insert(_el$4, (() => { var _c$6 = _$memo(() => !!status()?.semantic_index.stage); return () => _c$6() ? (() => { var _el$40 = _$createElement("box"), _el$41 = _$createElement("text"), _el$42 = _$createElement("b"); _$insertNode(_el$40, _el$41); _$setProp(_el$40, "flexDirection", "column"); _$setProp(_el$40, "width", "100%"); _$setProp(_el$40, "marginTop", 1); _$insertNode(_el$41, _el$42); _$insertNode(_el$42, _$createTextNode(`Semantic build progress`)); _$insert(_el$40, _$createComponent(R, { get theme() { return t(); }, label: "Stage", get value() { return status().semantic_index.stage; } }), null); _$insert(_el$40, (() => { var _c$12 = _$memo(() => status().semantic_index.files != null); return () => _c$12() ? _$createComponent(R, { get theme() { return t(); }, label: "Files seen", get value() { return formatCountShort(status().semantic_index.files); } }) : null; })(), null); _$insert(_el$40, (() => { var _c$13 = _$memo(() => !!(status().semantic_index.entries_done != null || status().semantic_index.entries_total != null)); return () => _c$13() ? _$createComponent(R, { get theme() { return t(); }, label: "Progress", get value() { return `${formatCountShort(status().semantic_index.entries_done ?? null)} / ${formatCountShort(status().semantic_index.entries_total ?? null)}`; } }) : null; })(), null); _$effect(_$p => _$setProp(_el$41, "fg", t().text, _$p)); return _el$40; })() : null; })(), _el$9); _$insert(_el$4, (() => { var _c$7 = _$memo(() => !!status()?.semantic_index.error); return () => _c$7() ? (() => { var _el$44 = _$createElement("box"), _el$45 = _$createElement("text"), _el$46 = _$createTextNode(`⚠ `); _$insertNode(_el$44, _el$45); _$setProp(_el$44, "marginTop", 1); _$setProp(_el$44, "width", "100%"); _$insertNode(_el$45, _el$46); _$insert(_el$45, () => status().semantic_index.error, null); _$effect(_$p => _$setProp(_el$45, "fg", t().error, _$p)); return _el$44; })() : null; })(), _el$9); _$insert(_el$4, (() => { var _c$8 = _$memo(() => compressionAggregateRows().length > 0); return () => _c$8() ? (() => { var _el$47 = _$createElement("box"), _el$48 = _$createElement("text"), _el$49 = _$createElement("b"); _$insertNode(_el$47, _el$48); _$setProp(_el$47, "flexDirection", "column"); _$setProp(_el$47, "width", "100%"); _$setProp(_el$47, "marginTop", 1); _$insertNode(_el$48, _el$49); _$insertNode(_el$49, _$createTextNode(`Compression`)); _$insert(_el$47, () => compressionAggregateRows().map(row => row.kind === "scope" ? (() => { var _el$51 = _$createElement("box"), _el$52 = _$createElement("text"); _$insertNode(_el$51, _el$52); _$setProp(_el$51, "width", "100%"); _$insert(_el$52, () => row.label); _$effect(_$p => _$setProp(_el$52, "fg", t().text, _$p)); return _el$51; })() : _$createComponent(R, { get theme() { return t(); }, get label() { return row.label; }, get value() { return row.value; }, tone: "muted" })), null); _$effect(_$p => _$setProp(_el$48, "fg", t().text, _$p)); return _el$47; })() : null; })(), _el$9); _$insert(_el$4, (() => { var _c$9 = _$memo(() => !!status()?.status_bar); return () => _c$9() ? (() => { var _el$53 = _$createElement("box"), _el$54 = _$createElement("text"), _el$55 = _$createElement("b"); _$insertNode(_el$53, _el$54); _$setProp(_el$53, "flexDirection", "column"); _$setProp(_el$53, "width", "100%"); _$setProp(_el$53, "marginTop", 1); _$insertNode(_el$54, _el$55); _$insert(_el$55, () => status().status_bar.tier2_stale ? "Code Health ~" : "Code Health"); _$insert(_el$53, (() => { var _c$14 = _$memo(() => status().status_bar.errors !== undefined); return () => _c$14() ? _$createComponent(R, { get theme() { return t(); }, label: "Errors", get value() { return formatCountShort(status().status_bar.errors); }, get tone() { return status().status_bar.errors > 0 ? "err" : "muted"; } }) : null; })(), null); _$insert(_el$53, (() => { var _c$15 = _$memo(() => status().status_bar.warnings !== undefined); return () => _c$15() ? _$createComponent(R, { get theme() { return t(); }, label: "Warnings", get value() { return formatCountShort(status().status_bar.warnings); }, get tone() { return status().status_bar.warnings > 0 ? "warn" : "muted"; } }) : null; })(), null); _$insert(_el$53, (() => { var _c$16 = _$memo(() => status().status_bar.dead_code !== undefined); return () => _c$16() ? _$createComponent(R, { get theme() { return t(); }, label: "Dead Code", get value() { return formatCountShort(status().status_bar.dead_code); }, tone: "muted" }) : null; })(), null); _$insert(_el$53, (() => { var _c$17 = _$memo(() => status().status_bar.unused_exports !== undefined); return () => _c$17() ? _$createComponent(R, { get theme() { return t(); }, label: "Unused Exports", get value() { return formatCountShort(status().status_bar.unused_exports); }, tone: "muted" }) : null; })(), null); _$insert(_el$53, (() => { var _c$18 = _$memo(() => status().status_bar.duplicates !== undefined); return () => _c$18() ? _$createComponent(R, { get theme() { return t(); }, label: "Duplicates", get value() { return formatCountShort(status().status_bar.duplicates); }, tone: "muted" }) : null; })(), null); _$insert(_el$53, (() => { var _c$19 = _$memo(() => status().status_bar.todos !== undefined); return () => _c$19() ? _$createComponent(R, { get theme() { return t(); }, label: "TODOs", get value() { return formatCountShort(status().status_bar.todos); }, tone: "muted" }) : null; })(), null); _$effect(_$p => _$setProp(_el$54, "fg", t().text, _$p)); return _el$53; })() : null; })(), _el$9); _$insertNode(_el$9, _el$0); _$setProp(_el$9, "marginTop", 1); _$setProp(_el$9, "justifyContent", "flex-end"); _$setProp(_el$9, "width", "100%"); _$insertNode(_el$0, _$createTextNode(`Enter or Esc to close`)); _$effect(_p$ => { var _v$3 = t().accent, _v$4 = t().textMuted; _v$3 !== _p$.e && (_p$.e = _$setProp(_el$6, "fg", _v$3, _p$.e)); _v$4 !== _p$.t && (_p$.t = _$setProp(_el$0, "fg", _v$4, _p$.t)); return _p$; }, { e: undefined, t: undefined }); return _el$4; })(); }; async function showStatusDialog(api) { 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 = null; let initialError = null; try { const response = await client.call("status", { sessionID }, { accept: statusAcceptGate(directory) }); if (response.success !== false) { initial = coerceAftStatus(response); } 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(() => _$createComponent(StatusDialog, { api: api, client: client, directory: directory, sessionID: sessionID, initial: initial, initialError: initialError })); 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) { const apiAny = api; 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) { 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", {}); if (announcement.show && announcement.version && announcement.features?.length) { const featureText = announcement.features.map(f => ` • ${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(() => _$createComponent(api.ui.DialogAlert, { get title() { return `AFT v${announcement.version}`; }, message: message, onConfirm: () => { // 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", {}); if (result.warnings?.length) { const warningText = result.warnings.join("\n\n"); api.ui.dialog.replace(() => _$createComponent(api.ui.DialogAlert, { title: "AFT Warning", message: warningText, onConfirm: () => {} }), () => {}); } } catch { // RPC server not ready — skip warnings } } const tui = 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 => { 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 };