/** @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