/** @jsxImportSource @opentui/solid */ import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import type { TuiPlugin, TuiPluginApi, TuiPluginModule, } from '@opencode-ai/plugin/tui' import { createEffect, createSignal, For, type JSX, onCleanup, Show, } from 'solid-js' import { createLogger } from './logger.js' import type { ApplyRequest, CommandModalName } from './rpc/protocol.js' import { createRpcClient } from './rpc/rpc-client.js' import { resolveRpcDir } from './rpc/rpc-dir.js' import { type AccountQuota, computeQuotaPacing, DEFAULT_SIDEBAR_STATE, getCollapsedQuotaSummary, getPresentQuotaWindows, getSidebarState, type QuotaPacing, type QuotaWindow, resolveActiveAccount, resolveSessionSidebarRouting, resolveSessionStickyAccount, type SidebarState, } from './sidebar-state.js' import { openCommandDialog } from './tui/command-dialogs.js' import { type AppearancePrefs, computeEffectiveOrder, DEFAULT_SLOT_ORDER, type OpenaiAuthTuiPrefs, PLUGIN_KEY, queueTuiPreferenceUpdate, readTuiPreferencesFile, resolveOpenaiAuthPrefs, watchTuiPreferences, } from './tui-preferences.js' const RPC_POLL_MS = 500 let rpcPollStarted = false const log = createLogger('rpc-tui') const ID = 'cortexkit.openai-auth' export function buildApplyRequest( command: CommandModalName, arguments_: string, sessionId?: string, ): ApplyRequest { return { command, arguments: arguments_, sessionId } } // Read package metadata from either the raw src/ entry or its generated // src/tui-compiled/ counterpart. Avoid a JSON import because package.json sits // outside the declaration build's rootDir. const PLUGIN_VERSION: string = (() => { const here = dirname(fileURLToPath(import.meta.url)) for (const packageFile of [ join(here, '..', 'package.json'), join(here, '..', '..', 'package.json'), ]) { try { const raw = readFileSync(packageFile, 'utf8') const version = (JSON.parse(raw) as { version?: string }).version if (version) return version } catch { // Try the path for the other TUI entry layout. } } return '' })() // biome-ignore lint/suspicious/noExplicitAny: opentui border prop not typed in plugin tui surface const SINGLE_BORDER = { type: 'single' } as any type Tone = 'ok' | 'warn' | 'err' | 'muted' | 'accent' | 'text' type ThemeCurrent = TuiPluginApi['theme']['current'] type ThemeColor = ThemeCurrent['text'] // Tone -> theme token. Theme tokens only — never hardcoded colors. Fallbacks // resolve to other theme tokens so the sidebar always tracks the active theme. function toneColor(theme: ThemeCurrent, tone: Tone): ThemeColor { switch (tone) { case 'ok': return theme.success ?? theme.accent case 'warn': return theme.warning ?? theme.accent case 'err': return theme.error ?? theme.accent case 'muted': return theme.textMuted ?? theme.text case 'accent': return theme.accent ?? theme.text default: return theme.text } } function usageTone(usedPct: number, appearance: AppearancePrefs): Tone { if (usedPct < appearance.warnThreshold) return 'ok' if (usedPct < appearance.errorThreshold) return 'warn' return 'err' } interface BarSegment { text: string tone: Tone } const PACE_RESERVE_CHAR = '\u2592' const PACE_DEFICIT_CHAR = '\u2593' // Variant C pacing bar: normal fill up to min(used, pace), then a pace // segment covering the gap — headroom being banked (reserve, ok tone) or the // overshoot itself (deficit, err tone) — then empty cells. Without pacing the // bar renders as a single fill+empty pair, identical to the pre-pacing look. // Empty text nodes still occupy a phantom cell in the opentui flex row, so // zero-length segments are dropped from every return path (plain and pacing). function quotaBarSegments( usedPct: number, appearance: AppearancePrefs, pacing: QuotaPacing | null, ): BarSegment[] { const width = appearance.barWidth const cells = (pct: number) => Math.max(0, Math.min(Math.round((pct / 100) * width), width)) const usedCells = cells(usedPct) const fillTone = usageTone(usedPct, appearance) const plain: BarSegment[] = [ { text: appearance.barFilledChar.repeat(usedCells), tone: fillTone }, { text: appearance.barEmptyChar.repeat(width - usedCells), tone: fillTone, }, ] if (!pacing) return plain.filter((segment) => segment.text.length > 0) const paceCells = cells(pacing.pacePercent) const lo = Math.min(usedCells, paceCells) const hi = Math.max(usedCells, paceCells) if (hi === lo) return plain.filter((segment) => segment.text.length > 0) const overspent = usedCells > paceCells return ( [ { text: appearance.barFilledChar.repeat(lo), tone: fillTone }, { text: (overspent ? PACE_DEFICIT_CHAR : PACE_RESERVE_CHAR).repeat( hi - lo, ), tone: overspent ? 'err' : 'ok', }, { text: appearance.barEmptyChar.repeat(width - hi), tone: fillTone }, ] as BarSegment[] ).filter((segment) => segment.text.length > 0) } export function formatResetIn(resetsAt: string | undefined): string { if (!resetsAt) return '' const ms = new Date(resetsAt).getTime() - Date.now() if (Number.isNaN(ms)) return '' if (ms <= 0) return 'now' const mins = Math.floor(ms / 60_000) if (mins < 60) return `${mins}m` const hrs = Math.floor(mins / 60) if (hrs < 24) { const rm = mins % 60 return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h` } const days = Math.floor(hrs / 24) const rh = hrs % 24 return rh > 0 ? `${days}d${rh}h` : `${days}d` } function formatUntil(until: number | undefined): string { if (!until) return '' const ms = until - Date.now() if (ms <= 0) return 'now' const mins = Math.ceil(ms / 60_000) if (mins < 60) return `${mins}m` const hrs = Math.floor(mins / 60) const rm = mins % 60 return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h` } // --- Reusable components (aft-style) --------------------------------------- function SectionHeader(props: { theme: ThemeCurrent title: string marginTop?: number }) { return ( {props.title} ) } function StatRow(props: { theme: ThemeCurrent label: string value: string tone?: Tone }) { return ( {props.label} {props.value} ) } // Compact row for the collapsed view: muted label left, caller-provided value // right. Mirrors StatRow's layout so columns line up. function CollapsedRow(props: { theme: ThemeCurrent label: string children: JSX.Element }) { return ( {props.label} {props.children} ) } export interface QuotaDisplayRow { key: 'primary' | 'secondary' label: string window: QuotaWindow pacing: QuotaPacing | null } // Maps present quota windows onto display rows and computes pacing against each // dynamic duration or the historical 5h/7d duration for legacy snapshots. export function buildQuotaRowsForDisplay( quota: AccountQuota | null, now: number, pacingEnabled: boolean, ): QuotaDisplayRow[] { return getPresentQuotaWindows(quota).map((row) => ({ key: row.key, label: row.label, window: row.window, pacing: pacingEnabled && row.windowMs !== null ? computeQuotaPacing(row.window, row.windowMs, now) : null, })) } export function isQuotaLoaded(quota: AccountQuota | null): boolean { return quota !== null } export function getQuotaMetadataRows( state: SidebarState, ): Array<{ label: string; value: string }> { const rows: Array<{ label: string; value: string }> = [] if (state.planType) rows.push({ label: 'Plan', value: state.planType }) if (state.credits !== undefined) { rows.push({ label: 'Credits', value: String(state.credits) }) } return rows } export function getAccountMetadataRows( resetCredits: number | undefined, ): Array<{ label: string; value: string }> { return resetCredits === undefined ? [] : [{ label: 'resets', value: String(resetCredits) }] } // Quota window row: muted label left, tone-colored bar + percentage right, // with an optional muted reset suffix. When pacing data is present, the bar // gains a pace segment and an off-pace window adds a muted subline with the // reserve/deficit delta and the projected runout. function QuotaRow(props: { theme: ThemeCurrent appearance: AppearancePrefs label: string window: { usedPercent: number; resetsAt?: string } | undefined pacing: QuotaPacing | null }) { const used = () => props.window?.usedPercent ?? 0 const reset = () => formatResetIn(props.window?.resetsAt) const paceLine = () => { const pacing = props.pacing if (!pacing || pacing.state === 'on-pace') return null const pct = Math.round(Math.abs(pacing.deltaPercent)) if (pacing.state === 'reserve') return `reserve ${pct}% \u00b7 lasts` return pacing.runsOutAt ? `deficit ${pct}% \u00b7 out in ${formatResetIn(pacing.runsOutAt)}` : `deficit ${pct}% \u00b7 lasts` } return ( {props.label.padEnd(3)} {'\u2014'} } > {/* Left group (label · bar · pct) stays left-aligned in fixed columns so bars and percentages line up across rows; the reset time is pushed to the right edge so reset times align in their own right column. */} {props.label.padEnd(3)} {(segment) => ( {segment.text} )} {` ${String(Math.round(used())).padStart(3)}%`} {reset()} {' '} {paceLine()} ) } // Account block: header row (name + status word) then per-window quota rows. // Pacing is computed here — per window, against the wall clock at render time // (re-evaluated on every state poll) — and disabled by passing false. function AccountBlock(props: { theme: ThemeCurrent appearance: AppearancePrefs name: string quota: AccountQuota | null killed: boolean active: boolean pacingEnabled: boolean resetCredits?: number marginTop?: number }) { const statusWord = () => props.killed ? 'blocked' : props.active ? 'active' : 'idle' const statusTone = (): Tone => props.killed ? 'err' : props.active ? 'ok' : 'muted' const rows = () => buildQuotaRowsForDisplay(props.quota, Date.now(), props.pacingEnabled) return ( {props.name} {statusWord()} {'checking\u2026'}} > 0} fallback={{'\u2014'}} > {(row) => ( )} {(row) => ( )} ) } // --- Quota dialog content --------------------------------------------------- // Renders the same rich visualization as the sidebar (account blocks with // quota bars + pacing) so the modal matches the sidebar look. function QuotaDialogContent(props: { api: TuiPluginApi controller: SidebarController sessionId: string | undefined }) { const prefs = props.controller.prefs const [state, setState] = createSignal(DEFAULT_SIDEBAR_STATE) let lastUpdated = 0 async function refresh() { const next = await readStateFromFile() if (next.lastUpdated !== lastUpdated) { lastUpdated = next.lastUpdated setState(next) } } createEffect(() => { const timer = setInterval(refresh, prefs().pollMs) onCleanup(() => clearInterval(timer)) }) setTimeout(refresh, 0) const theme = () => props.api.theme.current const enabledFallbacks = () => (state().fallbacks ?? []).filter((f) => f.enabled) const activeId = () => resolveQuotaDialogActiveId(state(), props.sessionId) return ( Codex Quota {(fb) => ( )} ) } // --- State plumbing --------------------------------------------------------- export async function readStateFromFile(): Promise { return getSidebarState() } export function resolveQuotaDialogActiveId( state: SidebarState, sessionId: string | undefined, now = Date.now(), ): string | undefined { return sessionId ? resolveSessionSidebarRouting(state, sessionId, now).activeId : state.activeId } export interface RoutingDisplayRow { label: string value: string tone: Tone } export function buildRoutingRowsForDisplay( state: SidebarState, sessionId: string | undefined, now = Date.now(), ): RoutingDisplayRow[] { const routing = resolveSessionSidebarRouting(state, sessionId, now) const rows: RoutingDisplayRow[] = [ { label: 'Route', value: routing.route, tone: 'accent' }, ] const pinnedAccountId = resolveSessionStickyAccount(state, sessionId, now) if (pinnedAccountId) { const pinnedAccount = resolveActiveAccount({ ...state, activeId: pinnedAccountId, }) rows.push({ label: 'Pin', value: pinnedAccount.name, tone: 'accent' }) } return rows } interface SidebarController { prefs: () => OpenaiAuthTuiPrefs collapsed: () => boolean toggleCollapsed: () => void } // The TUI may unmount and remount sidebar_content when the user switches // views (e.g. main -> subagent -> main). A remount re-runs the component // body, so any signal created inside the component would reset to its // seed. The controller lives in the plugin closure (process lifetime) // and owns the durable prefs/collapse signals plus the single shared // watcher subscription, so collapse and live pref reloads survive the // remount. No effects or memos here — those need a Solid owner, so the // poll-interval createEffect stays inside the component. function createSidebarController( initialPrefs: OpenaiAuthTuiPrefs, ): SidebarController { const [prefs, setPrefs] = createSignal(initialPrefs) const seedCollapsed = initialPrefs.rememberCollapsed && initialPrefs.collapsed != null ? initialPrefs.collapsed : initialPrefs.startCollapsed const [collapsed, setCollapsed] = createSignal(seedCollapsed) let lastPersistedCollapsed: boolean | null = initialPrefs.collapsed let lastApplied = JSON.stringify(initialPrefs) // The watcher lives for the plugin/process lifetime — it is intentionally // never disposed. Collapse guard mirrors the race-fix in toggleCollapsed: // lastPersistedCollapsed is advanced only once our own write lands, so // watcher echoes of the previous persisted value are rejected by the // `!==` check and cannot revert a user click. watchTuiPreferences(() => { void (async () => { const next = resolveOpenaiAuthPrefs(await readTuiPreferencesFile()) const serialized = JSON.stringify(next) if (serialized === lastApplied) return lastApplied = serialized setPrefs(next) if ( next.rememberCollapsed && next.collapsed != null && next.collapsed !== lastPersistedCollapsed ) { lastPersistedCollapsed = next.collapsed setCollapsed(next.collapsed) } })() }) function toggleCollapsed() { const next = !collapsed() setCollapsed(next) if (prefs().rememberCollapsed) { void queueTuiPreferenceUpdate(PLUGIN_KEY, ['collapsed'], next).then( () => { lastPersistedCollapsed = next }, ) } } return { prefs, collapsed, toggleCollapsed } } function QuotaSidebar(props: { api: TuiPluginApi controller: SidebarController sessionId: string }) { const prefs = props.controller.prefs const collapsed = props.controller.collapsed const [state, setState] = createSignal(DEFAULT_SIDEBAR_STATE) let lastUpdated = 0 let debounce: ReturnType | null = null async function refresh() { const next = await readStateFromFile() if (next.lastUpdated !== lastUpdated) { lastUpdated = next.lastUpdated setState(next) } } function scheduleRefresh() { if (debounce) clearTimeout(debounce) debounce = setTimeout(() => { debounce = null void refresh() }, prefs().refreshDebounceMs) } // Background poller (server and TUI run as separate module instances, so we // sync through the state file) plus event-driven refreshes for low latency. // The interval rebuilds whenever pollMs changes. createEffect(() => { const timer = setInterval(refresh, prefs().pollMs) onCleanup(() => clearInterval(timer)) }) const unsubs = [ props.api.event.on('session.updated', scheduleRefresh), props.api.event.on('message.updated', scheduleRefresh), ] setTimeout(refresh, 300) onCleanup(() => { if (debounce) clearTimeout(debounce) for (const u of unsubs) u() }) const theme = () => props.api.theme.current const enabledFallbacks = () => (state().fallbacks ?? []).filter((f) => f.enabled) const hasData = () => state().main?.quota != null || enabledFallbacks().length > 0 const headerLabel = () => { const name = prefs().header.label return !hasData() ? name : collapsed() ? `\u25b6 ${name}` : `\u25bc ${name}` } const sessionRouting = () => resolveSessionSidebarRouting(state(), props.sessionId) const sessionState = (): SidebarState => ({ ...state(), activeId: sessionRouting().activeId, route: sessionRouting().route, }) const activeAccount = () => resolveActiveAccount(sessionState()) const activeQuotaSummary = () => getCollapsedQuotaSummary(activeAccount().quota) const activePacingDeficit = () => { if (!prefs().sections.pacing) return false return buildQuotaRowsForDisplay( activeAccount().quota, Date.now(), true, ).some((row) => row.pacing?.state === 'deficit') } const activeQuotaTone = (): Tone => { const summary = activeQuotaSummary() const values = [ summary.primaryUsedPercent, summary.secondaryUsedPercent, ].filter((value): value is number => value != null) // Pacing deficit is an advisory projection, not actual quota exhaustion, // so it can only BUMP the usage tone up to warn at most — never soften a // real warn/err usage reading and never paint a true red. const base: Tone = values.length > 0 ? usageTone(Math.max(...values), prefs().appearance) : 'muted' if (!activePacingDeficit()) return base return base === 'ok' || base === 'muted' ? 'warn' : base } const killedNames = () => [ state().main?.killed ? 'main' : '', ...enabledFallbacks() .filter((f) => f.killed) .map((f) => f.label ?? f.id), ].filter(Boolean) const quotaBackedOff = () => state().main?.quotaBackedOff === true const refreshBackedOff = () => state().main?.refreshBackedOff === true const degraded = () => killedNames().length > 0 || quotaBackedOff() || refreshBackedOff() return ( {/* Header: ▼/▶ OPENAI badge (click to collapse) + version, or LIMITED badge */} {/* biome-ignore lint/a11y/noStaticElementInteractions: opentui renders to a terminal, not the DOM — ARIA roles do not apply */} { if (hasData()) props.controller.toggleCollapsed() }} > {headerLabel()} {`v${PLUGIN_VERSION}`} } > {'LIMITED'} {/* Collapsed: active account's present quota windows + dot (red ⊘ when killed) */} {'\u2014'}} > {activeQuotaSummary().text} {activeAccount().killed ? ' \u2298' : ' \u25cf'} {/* Expanded: full sections. Also render when there's no data so the sidebar can never go blank if data clears while collapsed. */} {'Waiting for quota\u2026'} } > {/* Quota */} {(fb) => ( )} {/* Routing */} {(row) => ( )} {/* Plan and credits — whichever are present */} {(row) => ( )} {/* Health — only when something is wrong */} 0}> ) } const tui: TuiPlugin = async (api) => { const root = await readTuiPreferencesFile() const controller = createSidebarController(resolveOpenaiAuthPrefs(root)) api.slots.register({ order: computeEffectiveOrder(root, PLUGIN_KEY, DEFAULT_SLOT_ORDER), slots: { sidebar_content(_ctx: unknown, props: { session_id: string }) { return ( ) }, }, }) if (!rpcPollStarted) { rpcPollStarted = true const myPid = process.pid const rpcDir = await resolveRpcDir(api.state.path.directory ?? '') const rpcClient = createRpcClient(rpcDir.dir, myPid, (entry) => { log.debug('rpc tui pid', { myPid, matchedPortFilePid: entry?.pid ?? null, rpcPort: entry?.port ?? null, }) }) let lastNotificationId = 0 let rpcInFlight = false setInterval(() => { if (rpcInFlight) return const current = (api.route as { current?: unknown }).current const resolved = typeof current === 'function' ? (current as () => unknown)() : current const sessionId = ( resolved as { params?: { sessionID?: string } } | undefined )?.params?.sessionID rpcInFlight = true void rpcClient .pending(lastNotificationId, sessionId) .then((messages) => { for (const message of [...messages].sort((a, b) => a.id - b.id)) { lastNotificationId = Math.max(lastNotificationId, message.id) if (message.payload.command === 'openai-quota') { api.ui.dialog.setSize('xlarge') api.ui.dialog.replace(() => ( )) continue } openCommandDialog( api, message.payload, (command, args) => rpcClient.apply( buildApplyRequest(command, args, sessionId), command === 'openai-reset' ? 90_000 : undefined, ), sessionId, ) } }) .catch(() => {}) .finally(() => { rpcInFlight = false }) }, RPC_POLL_MS) } } const plugin: TuiPluginModule & { id: string } = { id: ID, tui, } export default plugin