@cortexkit/opencode-anthropic-auth 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui.tsx ADDED
@@ -0,0 +1,401 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import type {
4
+ TuiPlugin,
5
+ TuiPluginApi,
6
+ TuiPluginModule,
7
+ } from '@opencode-ai/plugin/tui'
8
+ import { For, Show, createSignal, onCleanup } from 'solid-js'
9
+
10
+ import {
11
+ type AccountQuota,
12
+ DEFAULT_SIDEBAR_STATE,
13
+ type SidebarState,
14
+ getSidebarState,
15
+ } from './sidebar-state.js'
16
+
17
+ const POLL_MS = 1500
18
+ const REFRESH_DEBOUNCE_MS = 200
19
+
20
+ const ID = 'cortexkit.anthropic-auth'
21
+ const BAR_WIDTH = 10
22
+ const BAR_FILLED = '\u2588'
23
+ const BAR_EMPTY = '\u2591'
24
+
25
+ // biome-ignore lint/suspicious/noExplicitAny: opentui border prop not typed in plugin tui surface
26
+ const SINGLE_BORDER = { type: 'single' } as any
27
+
28
+ type Tone = 'ok' | 'warn' | 'err' | 'muted' | 'accent' | 'text'
29
+ type ThemeCurrent = TuiPluginApi['theme']['current']
30
+ type ThemeColor = ThemeCurrent['text']
31
+
32
+ // Tone -> theme token. Theme tokens only — never hardcoded colors. Fallbacks
33
+ // resolve to other theme tokens so the sidebar always tracks the active theme.
34
+ function toneColor(theme: ThemeCurrent, tone: Tone): ThemeColor {
35
+ switch (tone) {
36
+ case 'ok':
37
+ return theme.success ?? theme.accent
38
+ case 'warn':
39
+ return theme.warning ?? theme.accent
40
+ case 'err':
41
+ return theme.error ?? theme.accent
42
+ case 'muted':
43
+ return theme.textMuted ?? theme.text
44
+ case 'accent':
45
+ return theme.accent ?? theme.text
46
+ default:
47
+ return theme.text
48
+ }
49
+ }
50
+
51
+ function usageTone(usedPct: number): Tone {
52
+ if (usedPct < 50) return 'ok'
53
+ if (usedPct < 80) return 'warn'
54
+ return 'err'
55
+ }
56
+
57
+ function quotaBar(usedPct: number, width = BAR_WIDTH): string {
58
+ const filled = Math.max(
59
+ 0,
60
+ Math.min(Math.round((usedPct / 100) * width), width),
61
+ )
62
+ return BAR_FILLED.repeat(filled) + BAR_EMPTY.repeat(width - filled)
63
+ }
64
+
65
+ function formatResetIn(resetsAt: string | undefined): string {
66
+ if (!resetsAt) return ''
67
+ const ms = new Date(resetsAt).getTime() - Date.now()
68
+ if (ms <= 0) return 'now'
69
+ const mins = Math.floor(ms / 60_000)
70
+ if (mins < 60) return `${mins}m`
71
+ const hrs = Math.floor(mins / 60)
72
+ const rm = mins % 60
73
+ return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h`
74
+ }
75
+
76
+ function formatUntil(until: number | undefined): string {
77
+ if (!until) return ''
78
+ const ms = until - Date.now()
79
+ if (ms <= 0) return 'now'
80
+ const mins = Math.ceil(ms / 60_000)
81
+ if (mins < 60) return `${mins}m`
82
+ const hrs = Math.floor(mins / 60)
83
+ const rm = mins % 60
84
+ return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h`
85
+ }
86
+
87
+ // --- Reusable components (aft-style) ---------------------------------------
88
+
89
+ function SectionHeader(props: {
90
+ theme: ThemeCurrent
91
+ title: string
92
+ marginTop?: number
93
+ }) {
94
+ return (
95
+ <box width='100%' marginTop={props.marginTop ?? 1}>
96
+ <text fg={props.theme.text}>
97
+ <b>{props.title}</b>
98
+ </text>
99
+ </box>
100
+ )
101
+ }
102
+
103
+ function StatRow(props: {
104
+ theme: ThemeCurrent
105
+ label: string
106
+ value: string
107
+ tone?: Tone
108
+ }) {
109
+ return (
110
+ <box width='100%' flexDirection='row' justifyContent='space-between'>
111
+ <text fg={props.theme.textMuted}>{props.label}</text>
112
+ <text fg={toneColor(props.theme, props.tone ?? 'text')}>
113
+ <b>{props.value}</b>
114
+ </text>
115
+ </box>
116
+ )
117
+ }
118
+
119
+ // Quota window row: muted label left, tone-colored bar + percentage right,
120
+ // with an optional muted reset suffix.
121
+ function QuotaRow(props: {
122
+ theme: ThemeCurrent
123
+ label: string
124
+ window: { usedPercent: number; resetsAt?: string } | undefined
125
+ }) {
126
+ const used = () => props.window?.usedPercent ?? 0
127
+ const reset = () => formatResetIn(props.window?.resetsAt)
128
+ return (
129
+ <Show
130
+ when={props.window}
131
+ fallback={
132
+ <box width='100%' flexDirection='row'>
133
+ <text fg={props.theme.textMuted}>{props.label.padEnd(3)}</text>
134
+ <text fg={props.theme.textMuted}>{'\u2014'}</text>
135
+ </box>
136
+ }
137
+ >
138
+ {/* Left group (label · bar · pct) stays left-aligned in fixed columns so
139
+ bars and percentages line up across rows; the reset time is pushed to
140
+ the right edge so reset times align in their own right column. */}
141
+ <box width='100%' flexDirection='row' justifyContent='space-between'>
142
+ <box flexDirection='row'>
143
+ <text fg={props.theme.textMuted}>{props.label.padEnd(3)}</text>
144
+ <text fg={toneColor(props.theme, usageTone(used()))}>
145
+ {quotaBar(used())}
146
+ </text>
147
+ <text fg={toneColor(props.theme, usageTone(used()))}>
148
+ {` ${String(Math.round(used())).padStart(3)}%`}
149
+ </text>
150
+ </box>
151
+ <Show when={reset()}>
152
+ <text fg={props.theme.textMuted}>{reset()}</text>
153
+ </Show>
154
+ </box>
155
+ </Show>
156
+ )
157
+ }
158
+
159
+ // Account block: header row (name + status word) then per-window quota rows.
160
+ function AccountBlock(props: {
161
+ theme: ThemeCurrent
162
+ name: string
163
+ quota: AccountQuota | null
164
+ active: boolean
165
+ marginTop?: number
166
+ }) {
167
+ const statusWord = () => (props.active ? 'active' : 'idle')
168
+ const statusTone = (): Tone => (props.active ? 'ok' : 'muted')
169
+ return (
170
+ <box width='100%' flexDirection='column' marginTop={props.marginTop ?? 0}>
171
+ <box width='100%' flexDirection='row' justifyContent='space-between'>
172
+ <text fg={props.theme.text}>
173
+ <b>{props.name}</b>
174
+ </text>
175
+ <text fg={toneColor(props.theme, statusTone())}>
176
+ <b>{statusWord()}</b>
177
+ </text>
178
+ </box>
179
+ <Show
180
+ when={props.quota}
181
+ fallback={<text fg={props.theme.textMuted}>{'checking\u2026'}</text>}
182
+ >
183
+ <QuotaRow
184
+ theme={props.theme}
185
+ label='5h'
186
+ window={props.quota?.five_hour}
187
+ />
188
+ <QuotaRow
189
+ theme={props.theme}
190
+ label='7d'
191
+ window={props.quota?.seven_day}
192
+ />
193
+ </Show>
194
+ </box>
195
+ )
196
+ }
197
+
198
+ // --- State plumbing ---------------------------------------------------------
199
+
200
+ async function readStateFromFile(): Promise<SidebarState> {
201
+ return getSidebarState()
202
+ }
203
+
204
+ function QuotaSidebar(props: { api: TuiPluginApi }) {
205
+ const [state, setState] = createSignal<SidebarState>(DEFAULT_SIDEBAR_STATE)
206
+ let lastUpdated = 0
207
+ let debounce: ReturnType<typeof setTimeout> | null = null
208
+
209
+ async function refresh() {
210
+ const next = await readStateFromFile()
211
+ if (next.lastUpdated !== lastUpdated) {
212
+ lastUpdated = next.lastUpdated
213
+ setState(next)
214
+ }
215
+ }
216
+
217
+ function scheduleRefresh() {
218
+ if (debounce) clearTimeout(debounce)
219
+ debounce = setTimeout(() => {
220
+ debounce = null
221
+ void refresh()
222
+ }, REFRESH_DEBOUNCE_MS)
223
+ }
224
+
225
+ // Background poller (server and TUI run as separate module instances, so we
226
+ // sync through the state file) plus event-driven refreshes for low latency.
227
+ const timer = setInterval(refresh, POLL_MS)
228
+ const unsubs = [
229
+ props.api.event.on('session.updated', scheduleRefresh),
230
+ props.api.event.on('message.updated', scheduleRefresh),
231
+ ]
232
+ setTimeout(refresh, 300)
233
+ onCleanup(() => {
234
+ clearInterval(timer)
235
+ if (debounce) clearTimeout(debounce)
236
+ for (const u of unsubs) u()
237
+ })
238
+
239
+ const theme = () => props.api.theme.current
240
+ const enabledFallbacks = () => state().fallbacks.filter((f) => f.enabled)
241
+ const hasData = () =>
242
+ state().main.quota != null || enabledFallbacks().length > 0
243
+
244
+ const quotaBackedOff = () => state().main.quotaBackedOff === true
245
+ const refreshBackedOff = () => state().main.refreshBackedOff === true
246
+ const degraded = () => quotaBackedOff() || refreshBackedOff()
247
+
248
+ const cacheKeep = () => state().cacheKeep
249
+ const showCache = () => cacheKeep() != null && cacheKeep()?.window != null
250
+ const relayValue = () => {
251
+ const r = state().relay
252
+ if (!r) return '\u2014'
253
+ return `${r.transport} \u00b7 ${r.enabled ? 'on' : 'off'}`
254
+ }
255
+
256
+ return (
257
+ <box
258
+ width='100%'
259
+ flexDirection='column'
260
+ border={SINGLE_BORDER}
261
+ borderColor={theme().borderActive}
262
+ paddingTop={1}
263
+ paddingBottom={1}
264
+ paddingLeft={1}
265
+ paddingRight={1}
266
+ >
267
+ {/* Header: CLAUDE badge + optional LIMITED degraded badge */}
268
+ <box
269
+ width='100%'
270
+ flexDirection='row'
271
+ justifyContent='space-between'
272
+ alignItems='center'
273
+ >
274
+ <box paddingLeft={1} paddingRight={1} backgroundColor={theme().accent}>
275
+ <text fg={theme().background}>
276
+ <b>{'CLAUDE'}</b>
277
+ </text>
278
+ </box>
279
+ <Show when={degraded()}>
280
+ <box
281
+ paddingLeft={1}
282
+ paddingRight={1}
283
+ backgroundColor={theme().warning}
284
+ >
285
+ <text fg={theme().background}>
286
+ <b>{'LIMITED'}</b>
287
+ </text>
288
+ </box>
289
+ </Show>
290
+ </box>
291
+
292
+ <Show
293
+ when={hasData()}
294
+ fallback={
295
+ <box marginTop={1} width='100%'>
296
+ <text fg={theme().textMuted}>{'Waiting for quota\u2026'}</text>
297
+ </box>
298
+ }
299
+ >
300
+ {/* Quota */}
301
+ <SectionHeader theme={theme()} title='Quota' />
302
+ <AccountBlock
303
+ theme={theme()}
304
+ name='main'
305
+ quota={state().main.quota}
306
+ active={state().activeId === 'main'}
307
+ />
308
+ <For each={enabledFallbacks()}>
309
+ {(fb) => (
310
+ <AccountBlock
311
+ theme={theme()}
312
+ name={fb.label ?? fb.id}
313
+ quota={fb.quota}
314
+ active={state().activeId === fb.id}
315
+ marginTop={1}
316
+ />
317
+ )}
318
+ </For>
319
+ </Show>
320
+
321
+ {/* Routing */}
322
+ <SectionHeader theme={theme()} title='Routing' />
323
+ <StatRow
324
+ theme={theme()}
325
+ label='Route'
326
+ value={state().route}
327
+ tone='accent'
328
+ />
329
+ <StatRow
330
+ theme={theme()}
331
+ label='Mode'
332
+ value={state().fastMode ? 'fast' : 'std'}
333
+ tone={state().fastMode ? 'accent' : 'muted'}
334
+ />
335
+ <StatRow
336
+ theme={theme()}
337
+ label='Relay'
338
+ value={relayValue()}
339
+ tone={state().relay?.enabled ? 'ok' : 'muted'}
340
+ />
341
+
342
+ {/* Cache */}
343
+ <Show when={showCache()}>
344
+ <SectionHeader theme={theme()} title='Cache' />
345
+ <StatRow
346
+ theme={theme()}
347
+ label='1h cache'
348
+ value={`${cacheKeep()?.window} \u00b7 ${cacheKeep()?.enabled ? 'on' : 'off'}`}
349
+ tone={cacheKeep()?.enabled ? 'ok' : 'muted'}
350
+ />
351
+ <Show when={(cacheKeep()?.trackedSessions ?? 0) > 0}>
352
+ <StatRow
353
+ theme={theme()}
354
+ label='Tracked'
355
+ value={String(cacheKeep()?.trackedSessions)}
356
+ tone='text'
357
+ />
358
+ </Show>
359
+ </Show>
360
+
361
+ {/* Health — only when something is wrong */}
362
+ <Show when={degraded()}>
363
+ <SectionHeader theme={theme()} title='Health' />
364
+ <Show when={quotaBackedOff()}>
365
+ <StatRow
366
+ theme={theme()}
367
+ label='Quota API'
368
+ value={`backoff ${formatUntil(state().main.quotaBackoffUntil)}`}
369
+ tone='warn'
370
+ />
371
+ </Show>
372
+ <Show when={refreshBackedOff()}>
373
+ <StatRow
374
+ theme={theme()}
375
+ label='Token refresh'
376
+ value={`backoff ${formatUntil(state().main.refreshBackoffUntil)}`}
377
+ tone='warn'
378
+ />
379
+ </Show>
380
+ </Show>
381
+ </box>
382
+ )
383
+ }
384
+
385
+ const tui: TuiPlugin = async (api) => {
386
+ api.slots.register({
387
+ order: 160,
388
+ slots: {
389
+ sidebar_content(_ctx: unknown, _props: { session_id: string }) {
390
+ return <QuotaSidebar api={api} />
391
+ },
392
+ },
393
+ })
394
+ }
395
+
396
+ const plugin: TuiPluginModule & { id: string } = {
397
+ id: ID,
398
+ tui,
399
+ }
400
+
401
+ export default plugin