/** @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}
/>
);
},
},
};
}