/** @jsxImportSource @opentui/solid */
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { formatQuotaMoney } from '@cortexkit/anthropic-auth-core'
import type {
TuiPlugin,
TuiPluginApi,
TuiPluginModule,
} from '@opencode-ai/plugin/tui'
import {
createEffect,
createSignal,
For,
type JSX,
onCleanup,
Show,
} from 'solid-js'
import { createRpcClient } from './rpc/rpc-client.js'
import { getRpcDir } from './rpc/rpc-dir.js'
import {
type AccountQuota,
computeQuotaPacing,
DEFAULT_SIDEBAR_STATE,
FIVE_HOUR_MS,
formatScopedQuotaLabel,
getCollapsedQuotaSummary,
getFableRecoverySummary,
getSidebarState,
type QuotaPacing,
resolveActiveAccount,
SEVEN_DAY_MS,
type SidebarState,
} from './sidebar-state.js'
import { openCommandDialog } from './tui/command-dialogs.js'
import {
type AnthropicAuthTuiPrefs,
type AppearancePrefs,
computeEffectiveOrder,
DEFAULT_SLOT_ORDER,
PLUGIN_KEY,
queueTuiPreferenceUpdate,
readTuiPreferencesFile,
resolveAnthropicAuthPrefs,
watchTuiPreferences,
} from './tui-preferences.js'
const RPC_POLL_MS = 500
let rpcPollStarted = false
const ID = 'cortexkit.anthropic-auth'
// 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)
}
function formatResetIn(resetsAt: string | undefined): string {
if (!resetsAt) return ''
const ms = new Date(resetsAt).getTime() - Date.now()
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}
)
}
// 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
binding?: boolean
}) {
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)}%${props.binding ? ' •' : ''}`}
{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
active: boolean
pacingEnabled: boolean
needsReauth?: boolean
tierLabel?: string
marginTop?: number
}) {
const statusWord = () =>
props.needsReauth ? 're-login' : props.active ? 'active' : 'idle'
const statusTone = (): Tone =>
props.needsReauth ? 'err' : props.active ? 'ok' : 'muted'
const pacingFor = (
window:
| { usedPercent: number; remainingPercent: number; resetsAt?: string }
| undefined,
windowMs: number,
) =>
props.pacingEnabled && window
? computeQuotaPacing(window, windowMs, Date.now())
: null
return (
{`${props.name}${props.tierLabel ? ` · ${props.tierLabel}` : ''}`}
{statusWord()}
{'checking\u2026'}}
>
{(window) => (
)}
{(extraUsage: () => NonNullable) => (
{`credits ${formatQuotaMoney(extraUsage().used)}/${formatQuotaMoney(extraUsage().limit)}${extraUsage().exhausted ? ' · exhausted' : ''}`}
)}
{'→ fallback advised'}
)
}
export { formatQuotaMoney }
// --- 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
}) {
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)
return (
Quota
{(fb) => (
)}
)
}
// --- State plumbing ---------------------------------------------------------
async function readStateFromFile(): Promise {
return getSidebarState()
}
interface SidebarController {
prefs: () => AnthropicAuthTuiPrefs
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: AnthropicAuthTuiPrefs,
): 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 = resolveAnthropicAuthPrefs(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 activeAccount = () => resolveActiveAccount(state())
const activeQuotaSummary = () =>
getCollapsedQuotaSummary(activeAccount().quota)
const activePacingDeficit = () => {
if (!prefs().sections.pacing) return false
const quota = activeAccount().quota
if (!quota) return false
const now = Date.now()
const windows: Array<
[
(
| { usedPercent: number; remainingPercent: number; resetsAt?: string }
| undefined
),
number,
]
> = [
[quota.five_hour, FIVE_HOUR_MS],
[quota.seven_day, SEVEN_DAY_MS],
...(quota.scoped ?? []).map((window): [typeof window, number] => [
window,
SEVEN_DAY_MS,
]),
]
return windows.some(
([w, ms]) =>
w != null && computeQuotaPacing(w, ms, now)?.state === 'deficit',
)
}
const activeQuotaTone = (): Tone => {
const summary = activeQuotaSummary()
const values = [
summary.fiveHourUsedPercent,
summary.sevenDayUsedPercent,
...summary.scopedUsedPercents,
].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 quotaBackedOff = () => state().main?.quotaBackedOff === true
const refreshBackedOff = () => state().main?.refreshBackedOff === true
const needsReauth = () => enabledFallbacks().some((f) => f.needsReauth)
const degraded = () => quotaBackedOff() || refreshBackedOff() || needsReauth()
const fableRecoverySummary = () =>
getFableRecoverySummary(state(), props.sessionId)
const fableRecoveryTone = (): Tone =>
state().fableRecoveries?.find(
(recovery) => recovery.sessionId === props.sessionId,
)?.mode === 'opus'
? 'warn'
: 'ok'
const cacheKeep = () => state().cacheKeep
const showCache = () =>
prefs().sections.cache && cacheKeep() != null && cacheKeep()?.window != null
const relayValue = () => {
const r = state().relay
if (!r) return '\u2014'
return `${r.transport} \u00b7 ${r.enabled ? 'on' : 'off'}`
}
return (
{/* Header: ▼/▶ CLAUDE 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 5h + 7d quota, plus fast-mode when on */}
{'\u2014'}}
>
{activeQuotaSummary().text}
{'fast'}
{(summary: () => string) => (
{summary()}
)}
{/* 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 */}
{(summary: () => string) => (
)}
{/* Cache */}
0}>
{/* Health — only when something is wrong */}
)
}
const tui: TuiPlugin = async (api) => {
const root = await readTuiPreferencesFile()
const controller = createSidebarController(resolveAnthropicAuthPrefs(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 rpcClient = createRpcClient(
getRpcDir(api.state.path.directory ?? ''),
process.pid,
)
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 === 'claude-quota') {
api.ui.dialog.setSize('xlarge')
api.ui.dialog.replace(() => (
))
continue
}
openCommandDialog(api, message.payload, (command, args) =>
rpcClient.apply({ command, arguments: args, sessionId }),
)
}
})
.catch(() => {})
.finally(() => {
rpcInFlight = false
})
}, RPC_POLL_MS)
}
}
const plugin: TuiPluginModule & { id: string } = {
id: ID,
tui,
}
export default plugin