@cortexkit/opencode-openai-auth 0.1.1 → 0.1.3

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.
Files changed (64) hide show
  1. package/README.md +27 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +14 -0
  4. package/dist/commands.d.ts +49 -0
  5. package/dist/core/accounts.d.ts +227 -0
  6. package/dist/core/atomic-write.d.ts +1 -0
  7. package/dist/core/backoff.d.ts +53 -0
  8. package/dist/core/cachekeep.d.ts +95 -0
  9. package/dist/core/oauth.d.ts +126 -0
  10. package/dist/core/provider.d.ts +59 -0
  11. package/dist/core/quota-manager.d.ts +136 -0
  12. package/dist/core/refresh-all-quota.d.ts +52 -0
  13. package/dist/core/refresh-file-lock.d.ts +15 -0
  14. package/dist/index-0s5tftne.js +3 -0
  15. package/dist/index-6qjg8qfw.js +2 -0
  16. package/dist/index-dz7tcrxb.js +2 -0
  17. package/dist/index-eks9xm6s.js +1 -0
  18. package/dist/index-hrg6bdgb.js +2 -0
  19. package/dist/index-jshhys8t.js +2 -0
  20. package/dist/index-mrh6k1zf.js +92 -0
  21. package/dist/index-sgfft5d3.js +2 -0
  22. package/dist/index.d.ts +3 -19
  23. package/dist/index.js +118 -124
  24. package/dist/logger.d.ts +11 -1
  25. package/dist/quota-normalize-qh5n0p7v.js +1 -0
  26. package/dist/quota-normalize.d.ts +33 -0
  27. package/dist/response-stream-error.d.ts +12 -1
  28. package/dist/rpc/notifications.d.ts +5 -0
  29. package/dist/rpc/port-file.d.ts +12 -0
  30. package/dist/rpc/port-file.js +1 -0
  31. package/dist/rpc/protocol.d.ts +20 -0
  32. package/dist/rpc/protocol.js +0 -0
  33. package/dist/rpc/rpc-client.d.ts +7 -0
  34. package/dist/rpc/rpc-client.js +1 -0
  35. package/dist/rpc/rpc-dir.d.ts +3 -0
  36. package/dist/rpc/rpc-dir.js +1 -0
  37. package/dist/rpc/rpc-server.d.ts +13 -0
  38. package/dist/sidebar-state.d.ts +83 -0
  39. package/dist/sidebar-state.js +1 -0
  40. package/dist/token-util-sqwtzxz0.js +1 -0
  41. package/dist/token-zzzp7w2y.js +1 -0
  42. package/dist/tui/command-dialogs.d.ts +14 -0
  43. package/dist/tui-preferences.d.ts +40 -0
  44. package/dist/tui-preferences.js +6 -0
  45. package/dist/tui.d.ts +9 -0
  46. package/dist/util/open-url.d.ts +6 -0
  47. package/dist/version.d.ts +1 -1
  48. package/dist/ws-pool.d.ts +8 -0
  49. package/dist/ws.d.ts +4 -0
  50. package/package.json +25 -4
  51. package/src/rpc/notifications.ts +54 -0
  52. package/src/rpc/port-file.ts +70 -0
  53. package/src/rpc/protocol.ts +31 -0
  54. package/src/rpc/rpc-client.ts +78 -0
  55. package/src/rpc/rpc-dir.ts +22 -0
  56. package/src/rpc/rpc-server.ts +129 -0
  57. package/src/sidebar-state.ts +312 -0
  58. package/src/tui/command-dialogs.tsx +742 -0
  59. package/src/tui-preferences.ts +328 -0
  60. package/src/tui.tsx +837 -0
  61. package/dist/index-6z8w05g9.js +0 -2
  62. package/dist/index-xqe8xm96.js +0 -2
  63. package/dist/token-util-fs3z8kkp.js +0 -1
  64. package/dist/token-yyjm093c.js +0 -1
package/src/tui.tsx ADDED
@@ -0,0 +1,837 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import { readFileSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import type {
7
+ TuiPlugin,
8
+ TuiPluginApi,
9
+ TuiPluginModule,
10
+ } from '@opencode-ai/plugin/tui'
11
+ import {
12
+ createEffect,
13
+ createSignal,
14
+ For,
15
+ type JSX,
16
+ onCleanup,
17
+ Show,
18
+ } from 'solid-js'
19
+ import { createLogger } from './logger.js'
20
+ import { createRpcClient } from './rpc/rpc-client.js'
21
+ import { getRpcDir } from './rpc/rpc-dir.js'
22
+ import {
23
+ type AccountQuota,
24
+ computeQuotaPacing,
25
+ DEFAULT_SIDEBAR_STATE,
26
+ FIVE_HOUR_MS,
27
+ getCollapsedQuotaSummary,
28
+ getSidebarState,
29
+ type QuotaPacing,
30
+ resolveActiveAccount,
31
+ SEVEN_DAY_MS,
32
+ type SidebarState,
33
+ } from './sidebar-state.js'
34
+ import { openCommandDialog } from './tui/command-dialogs.js'
35
+ import {
36
+ type AppearancePrefs,
37
+ computeEffectiveOrder,
38
+ DEFAULT_SLOT_ORDER,
39
+ type OpenaiAuthTuiPrefs,
40
+ PLUGIN_KEY,
41
+ queueTuiPreferenceUpdate,
42
+ readTuiPreferencesFile,
43
+ resolveOpenaiAuthPrefs,
44
+ watchTuiPreferences,
45
+ } from './tui-preferences.js'
46
+
47
+ const RPC_POLL_MS = 500
48
+ let rpcPollStarted = false
49
+ const log = createLogger('rpc-tui')
50
+
51
+ const ID = 'cortexkit.openai-auth'
52
+
53
+ // Plugin version for the header (mirrors the Magic Context / AFT convention).
54
+ // Read at runtime from package.json relative to this module — NOT a TS JSON
55
+ // import, which would break the declaration build (package.json is outside
56
+ // rootDir). tui.tsx ships as source (package.json exports["./tui"] →
57
+ // "./src/tui.tsx") and is never compiled into a deeper dist tree, so `..` from
58
+ // src/ is always the package root. Empty string on any failure → header shows
59
+ // the badge with no version.
60
+ const PLUGIN_VERSION: string = (() => {
61
+ try {
62
+ const here = dirname(fileURLToPath(import.meta.url))
63
+ const raw = readFileSync(join(here, '..', 'package.json'), 'utf8')
64
+ return (JSON.parse(raw) as { version?: string }).version ?? ''
65
+ } catch {
66
+ return ''
67
+ }
68
+ })()
69
+
70
+ // biome-ignore lint/suspicious/noExplicitAny: opentui border prop not typed in plugin tui surface
71
+ const SINGLE_BORDER = { type: 'single' } as any
72
+
73
+ type Tone = 'ok' | 'warn' | 'err' | 'muted' | 'accent' | 'text'
74
+ type ThemeCurrent = TuiPluginApi['theme']['current']
75
+ type ThemeColor = ThemeCurrent['text']
76
+
77
+ // Tone -> theme token. Theme tokens only — never hardcoded colors. Fallbacks
78
+ // resolve to other theme tokens so the sidebar always tracks the active theme.
79
+ function toneColor(theme: ThemeCurrent, tone: Tone): ThemeColor {
80
+ switch (tone) {
81
+ case 'ok':
82
+ return theme.success ?? theme.accent
83
+ case 'warn':
84
+ return theme.warning ?? theme.accent
85
+ case 'err':
86
+ return theme.error ?? theme.accent
87
+ case 'muted':
88
+ return theme.textMuted ?? theme.text
89
+ case 'accent':
90
+ return theme.accent ?? theme.text
91
+ default:
92
+ return theme.text
93
+ }
94
+ }
95
+
96
+ function usageTone(usedPct: number, appearance: AppearancePrefs): Tone {
97
+ if (usedPct < appearance.warnThreshold) return 'ok'
98
+ if (usedPct < appearance.errorThreshold) return 'warn'
99
+ return 'err'
100
+ }
101
+
102
+ interface BarSegment {
103
+ text: string
104
+ tone: Tone
105
+ }
106
+
107
+ const PACE_RESERVE_CHAR = '\u2592'
108
+ const PACE_DEFICIT_CHAR = '\u2593'
109
+
110
+ // Variant C pacing bar: normal fill up to min(used, pace), then a pace
111
+ // segment covering the gap — headroom being banked (reserve, ok tone) or the
112
+ // overshoot itself (deficit, err tone) — then empty cells. Without pacing the
113
+ // bar renders as a single fill+empty pair, identical to the pre-pacing look.
114
+ // Empty text nodes still occupy a phantom cell in the opentui flex row, so
115
+ // zero-length segments are dropped from every return path (plain and pacing).
116
+ function quotaBarSegments(
117
+ usedPct: number,
118
+ appearance: AppearancePrefs,
119
+ pacing: QuotaPacing | null,
120
+ ): BarSegment[] {
121
+ const width = appearance.barWidth
122
+ const cells = (pct: number) =>
123
+ Math.max(0, Math.min(Math.round((pct / 100) * width), width))
124
+ const usedCells = cells(usedPct)
125
+ const fillTone = usageTone(usedPct, appearance)
126
+ const plain: BarSegment[] = [
127
+ { text: appearance.barFilledChar.repeat(usedCells), tone: fillTone },
128
+ {
129
+ text: appearance.barEmptyChar.repeat(width - usedCells),
130
+ tone: fillTone,
131
+ },
132
+ ]
133
+ if (!pacing) return plain.filter((segment) => segment.text.length > 0)
134
+ const paceCells = cells(pacing.pacePercent)
135
+ const lo = Math.min(usedCells, paceCells)
136
+ const hi = Math.max(usedCells, paceCells)
137
+ if (hi === lo) return plain.filter((segment) => segment.text.length > 0)
138
+ const overspent = usedCells > paceCells
139
+ return (
140
+ [
141
+ { text: appearance.barFilledChar.repeat(lo), tone: fillTone },
142
+ {
143
+ text: (overspent ? PACE_DEFICIT_CHAR : PACE_RESERVE_CHAR).repeat(
144
+ hi - lo,
145
+ ),
146
+ tone: overspent ? 'err' : 'ok',
147
+ },
148
+ { text: appearance.barEmptyChar.repeat(width - hi), tone: fillTone },
149
+ ] as BarSegment[]
150
+ ).filter((segment) => segment.text.length > 0)
151
+ }
152
+
153
+ export function formatResetIn(resetsAt: string | undefined): string {
154
+ if (!resetsAt) return ''
155
+ const ms = new Date(resetsAt).getTime() - Date.now()
156
+ if (Number.isNaN(ms)) return ''
157
+ if (ms <= 0) return 'now'
158
+ const mins = Math.floor(ms / 60_000)
159
+ if (mins < 60) return `${mins}m`
160
+ const hrs = Math.floor(mins / 60)
161
+ if (hrs < 24) {
162
+ const rm = mins % 60
163
+ return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h`
164
+ }
165
+ const days = Math.floor(hrs / 24)
166
+ const rh = hrs % 24
167
+ return rh > 0 ? `${days}d${rh}h` : `${days}d`
168
+ }
169
+
170
+ function formatUntil(until: number | undefined): string {
171
+ if (!until) return ''
172
+ const ms = until - Date.now()
173
+ if (ms <= 0) return 'now'
174
+ const mins = Math.ceil(ms / 60_000)
175
+ if (mins < 60) return `${mins}m`
176
+ const hrs = Math.floor(mins / 60)
177
+ const rm = mins % 60
178
+ return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h`
179
+ }
180
+
181
+ // --- Reusable components (aft-style) ---------------------------------------
182
+
183
+ function SectionHeader(props: {
184
+ theme: ThemeCurrent
185
+ title: string
186
+ marginTop?: number
187
+ }) {
188
+ return (
189
+ <box width='100%' marginTop={props.marginTop ?? 1}>
190
+ <text fg={props.theme.text}>
191
+ <b>{props.title}</b>
192
+ </text>
193
+ </box>
194
+ )
195
+ }
196
+
197
+ function StatRow(props: {
198
+ theme: ThemeCurrent
199
+ label: string
200
+ value: string
201
+ tone?: Tone
202
+ }) {
203
+ return (
204
+ <box width='100%' flexDirection='row' justifyContent='space-between'>
205
+ <text fg={props.theme.textMuted}>{props.label}</text>
206
+ <text fg={toneColor(props.theme, props.tone ?? 'text')}>
207
+ <b>{props.value}</b>
208
+ </text>
209
+ </box>
210
+ )
211
+ }
212
+
213
+ // Compact row for the collapsed view: muted label left, caller-provided value
214
+ // right. Mirrors StatRow's layout so columns line up.
215
+ function CollapsedRow(props: {
216
+ theme: ThemeCurrent
217
+ label: string
218
+ children: JSX.Element
219
+ }) {
220
+ return (
221
+ <box width='100%' flexDirection='row' justifyContent='space-between'>
222
+ <text fg={props.theme.textMuted}>{props.label}</text>
223
+ {props.children}
224
+ </box>
225
+ )
226
+ }
227
+
228
+ // Quota window row: muted label left, tone-colored bar + percentage right,
229
+ // with an optional muted reset suffix. When pacing data is present, the bar
230
+ // gains a pace segment and an off-pace window adds a muted subline with the
231
+ // reserve/deficit delta and the projected runout.
232
+ function QuotaRow(props: {
233
+ theme: ThemeCurrent
234
+ appearance: AppearancePrefs
235
+ label: string
236
+ window: { usedPercent: number; resetsAt?: string } | undefined
237
+ pacing: QuotaPacing | null
238
+ }) {
239
+ const used = () => props.window?.usedPercent ?? 0
240
+ const reset = () => formatResetIn(props.window?.resetsAt)
241
+ const paceLine = () => {
242
+ const pacing = props.pacing
243
+ if (!pacing || pacing.state === 'on-pace') return null
244
+ const pct = Math.round(Math.abs(pacing.deltaPercent))
245
+ if (pacing.state === 'reserve') return `reserve ${pct}% \u00b7 lasts`
246
+ return pacing.runsOutAt
247
+ ? `deficit ${pct}% \u00b7 out in ${formatResetIn(pacing.runsOutAt)}`
248
+ : `deficit ${pct}% \u00b7 lasts`
249
+ }
250
+ return (
251
+ <Show
252
+ when={props.window}
253
+ fallback={
254
+ <box width='100%' flexDirection='row'>
255
+ <text fg={props.theme.textMuted}>{props.label.padEnd(3)}</text>
256
+ <text fg={props.theme.textMuted}>{'\u2014'}</text>
257
+ </box>
258
+ }
259
+ >
260
+ {/* Left group (label · bar · pct) stays left-aligned in fixed columns so
261
+ bars and percentages line up across rows; the reset time is pushed to
262
+ the right edge so reset times align in their own right column. */}
263
+ <box width='100%' flexDirection='row' justifyContent='space-between'>
264
+ <box flexDirection='row'>
265
+ <text fg={props.theme.textMuted}>{props.label.padEnd(3)}</text>
266
+ <For each={quotaBarSegments(used(), props.appearance, props.pacing)}>
267
+ {(segment) => (
268
+ <text fg={toneColor(props.theme, segment.tone)}>
269
+ {segment.text}
270
+ </text>
271
+ )}
272
+ </For>
273
+ <text
274
+ fg={toneColor(props.theme, usageTone(used(), props.appearance))}
275
+ >
276
+ {` ${String(Math.round(used())).padStart(3)}%`}
277
+ </text>
278
+ </box>
279
+ <Show when={reset()}>
280
+ <text fg={props.theme.textMuted}>{reset()}</text>
281
+ </Show>
282
+ </box>
283
+ <Show when={paceLine()}>
284
+ <box width='100%' flexDirection='row'>
285
+ <text fg={props.theme.textMuted}>{' '}</text>
286
+ <text
287
+ fg={toneColor(
288
+ props.theme,
289
+ props.pacing?.state === 'deficit' ? 'warn' : 'muted',
290
+ )}
291
+ >
292
+ {paceLine()}
293
+ </text>
294
+ </box>
295
+ </Show>
296
+ </Show>
297
+ )
298
+ }
299
+
300
+ // Account block: header row (name + status word) then per-window quota rows.
301
+ // Pacing is computed here — per window, against the wall clock at render time
302
+ // (re-evaluated on every state poll) — and disabled by passing false.
303
+ function AccountBlock(props: {
304
+ theme: ThemeCurrent
305
+ appearance: AppearancePrefs
306
+ name: string
307
+ quota: AccountQuota | null
308
+ killed: boolean
309
+ active: boolean
310
+ pacingEnabled: boolean
311
+ marginTop?: number
312
+ }) {
313
+ const statusWord = () =>
314
+ props.killed ? 'blocked' : props.active ? 'active' : 'idle'
315
+ const statusTone = (): Tone =>
316
+ props.killed ? 'err' : props.active ? 'ok' : 'muted'
317
+ const pacingFor = (
318
+ window:
319
+ | { usedPercent: number; remainingPercent: number; resetsAt?: string }
320
+ | undefined,
321
+ windowMs: number,
322
+ ) =>
323
+ props.pacingEnabled && window
324
+ ? computeQuotaPacing(window, windowMs, Date.now())
325
+ : null
326
+ return (
327
+ <box width='100%' flexDirection='column' marginTop={props.marginTop ?? 0}>
328
+ <box width='100%' flexDirection='row' justifyContent='space-between'>
329
+ <text fg={props.theme.text}>
330
+ <b>{props.name}</b>
331
+ </text>
332
+ <text fg={toneColor(props.theme, statusTone())}>
333
+ <b>{statusWord()}</b>
334
+ </text>
335
+ </box>
336
+ <Show
337
+ when={props.quota}
338
+ fallback={<text fg={props.theme.textMuted}>{'checking\u2026'}</text>}
339
+ >
340
+ <QuotaRow
341
+ theme={props.theme}
342
+ appearance={props.appearance}
343
+ label='5h'
344
+ window={props.quota?.primary}
345
+ pacing={pacingFor(props.quota?.primary, FIVE_HOUR_MS)}
346
+ />
347
+ <QuotaRow
348
+ theme={props.theme}
349
+ appearance={props.appearance}
350
+ label='7d'
351
+ window={props.quota?.secondary}
352
+ pacing={pacingFor(props.quota?.secondary, SEVEN_DAY_MS)}
353
+ />
354
+ </Show>
355
+ </box>
356
+ )
357
+ }
358
+
359
+ // --- Quota dialog content ---------------------------------------------------
360
+
361
+ // Renders the same rich visualization as the sidebar (account blocks with
362
+ // quota bars + pacing) so the modal matches the sidebar look.
363
+ function QuotaDialogContent(props: {
364
+ api: TuiPluginApi
365
+ controller: SidebarController
366
+ }) {
367
+ const prefs = props.controller.prefs
368
+ const [state, setState] = createSignal<SidebarState>(DEFAULT_SIDEBAR_STATE)
369
+ let lastUpdated = 0
370
+ async function refresh() {
371
+ const next = await readStateFromFile()
372
+ if (next.lastUpdated !== lastUpdated) {
373
+ lastUpdated = next.lastUpdated
374
+ setState(next)
375
+ }
376
+ }
377
+ createEffect(() => {
378
+ const timer = setInterval(refresh, prefs().pollMs)
379
+ onCleanup(() => clearInterval(timer))
380
+ })
381
+ setTimeout(refresh, 0)
382
+ const theme = () => props.api.theme.current
383
+ const enabledFallbacks = () =>
384
+ (state().fallbacks ?? []).filter((f) => f.enabled)
385
+ return (
386
+ <box flexDirection='column' padding={2} width='100%' alignItems='center'>
387
+ <box flexDirection='column' width={58}>
388
+ <box width='100%' justifyContent='center' marginBottom={1}>
389
+ <text fg={theme().text}>
390
+ <b>Codex Quota</b>
391
+ </text>
392
+ </box>
393
+ <AccountBlock
394
+ theme={theme()}
395
+ appearance={prefs().appearance}
396
+ name='main'
397
+ quota={state().main?.quota ?? null}
398
+ killed={state().main?.killed ?? false}
399
+ active={state().activeId === 'main'}
400
+ pacingEnabled={prefs().sections.pacing}
401
+ />
402
+ <Show when={prefs().sections.fallbackAccounts}>
403
+ <For each={enabledFallbacks()}>
404
+ {(fb) => (
405
+ <AccountBlock
406
+ theme={theme()}
407
+ appearance={prefs().appearance}
408
+ name={fb.label ?? fb.id}
409
+ quota={fb.quota}
410
+ killed={fb.killed}
411
+ active={state().activeId === fb.id}
412
+ pacingEnabled={prefs().sections.pacing}
413
+ marginTop={1}
414
+ />
415
+ )}
416
+ </For>
417
+ </Show>
418
+ </box>
419
+ </box>
420
+ )
421
+ }
422
+
423
+ // --- State plumbing ---------------------------------------------------------
424
+
425
+ export async function readStateFromFile(): Promise<SidebarState> {
426
+ return getSidebarState()
427
+ }
428
+
429
+ interface SidebarController {
430
+ prefs: () => OpenaiAuthTuiPrefs
431
+ collapsed: () => boolean
432
+ toggleCollapsed: () => void
433
+ }
434
+
435
+ // The TUI may unmount and remount sidebar_content when the user switches
436
+ // views (e.g. main -> subagent -> main). A remount re-runs the component
437
+ // body, so any signal created inside the component would reset to its
438
+ // seed. The controller lives in the plugin closure (process lifetime)
439
+ // and owns the durable prefs/collapse signals plus the single shared
440
+ // watcher subscription, so collapse and live pref reloads survive the
441
+ // remount. No effects or memos here — those need a Solid owner, so the
442
+ // poll-interval createEffect stays inside the component.
443
+ function createSidebarController(
444
+ initialPrefs: OpenaiAuthTuiPrefs,
445
+ ): SidebarController {
446
+ const [prefs, setPrefs] = createSignal<OpenaiAuthTuiPrefs>(initialPrefs)
447
+ const seedCollapsed =
448
+ initialPrefs.rememberCollapsed && initialPrefs.collapsed != null
449
+ ? initialPrefs.collapsed
450
+ : initialPrefs.startCollapsed
451
+ const [collapsed, setCollapsed] = createSignal(seedCollapsed)
452
+ let lastPersistedCollapsed: boolean | null = initialPrefs.collapsed
453
+ let lastApplied = JSON.stringify(initialPrefs)
454
+
455
+ // The watcher lives for the plugin/process lifetime — it is intentionally
456
+ // never disposed. Collapse guard mirrors the race-fix in toggleCollapsed:
457
+ // lastPersistedCollapsed is advanced only once our own write lands, so
458
+ // watcher echoes of the previous persisted value are rejected by the
459
+ // `!==` check and cannot revert a user click.
460
+ watchTuiPreferences(() => {
461
+ void (async () => {
462
+ const next = resolveOpenaiAuthPrefs(await readTuiPreferencesFile())
463
+ const serialized = JSON.stringify(next)
464
+ if (serialized === lastApplied) return
465
+ lastApplied = serialized
466
+ setPrefs(next)
467
+ if (
468
+ next.rememberCollapsed &&
469
+ next.collapsed != null &&
470
+ next.collapsed !== lastPersistedCollapsed
471
+ ) {
472
+ lastPersistedCollapsed = next.collapsed
473
+ setCollapsed(next.collapsed)
474
+ }
475
+ })()
476
+ })
477
+
478
+ function toggleCollapsed() {
479
+ const next = !collapsed()
480
+ setCollapsed(next)
481
+ if (prefs().rememberCollapsed) {
482
+ void queueTuiPreferenceUpdate(PLUGIN_KEY, ['collapsed'], next).then(
483
+ () => {
484
+ lastPersistedCollapsed = next
485
+ },
486
+ )
487
+ }
488
+ }
489
+
490
+ return { prefs, collapsed, toggleCollapsed }
491
+ }
492
+
493
+ function QuotaSidebar(props: {
494
+ api: TuiPluginApi
495
+ controller: SidebarController
496
+ }) {
497
+ const prefs = props.controller.prefs
498
+ const collapsed = props.controller.collapsed
499
+ const [state, setState] = createSignal<SidebarState>(DEFAULT_SIDEBAR_STATE)
500
+ let lastUpdated = 0
501
+ let debounce: ReturnType<typeof setTimeout> | null = null
502
+
503
+ async function refresh() {
504
+ const next = await readStateFromFile()
505
+ if (next.lastUpdated !== lastUpdated) {
506
+ lastUpdated = next.lastUpdated
507
+ setState(next)
508
+ }
509
+ }
510
+
511
+ function scheduleRefresh() {
512
+ if (debounce) clearTimeout(debounce)
513
+ debounce = setTimeout(() => {
514
+ debounce = null
515
+ void refresh()
516
+ }, prefs().refreshDebounceMs)
517
+ }
518
+
519
+ // Background poller (server and TUI run as separate module instances, so we
520
+ // sync through the state file) plus event-driven refreshes for low latency.
521
+ // The interval rebuilds whenever pollMs changes.
522
+ createEffect(() => {
523
+ const timer = setInterval(refresh, prefs().pollMs)
524
+ onCleanup(() => clearInterval(timer))
525
+ })
526
+ const unsubs = [
527
+ props.api.event.on('session.updated', scheduleRefresh),
528
+ props.api.event.on('message.updated', scheduleRefresh),
529
+ ]
530
+ setTimeout(refresh, 300)
531
+
532
+ onCleanup(() => {
533
+ if (debounce) clearTimeout(debounce)
534
+ for (const u of unsubs) u()
535
+ })
536
+
537
+ const theme = () => props.api.theme.current
538
+ const enabledFallbacks = () =>
539
+ (state().fallbacks ?? []).filter((f) => f.enabled)
540
+ const hasData = () =>
541
+ state().main?.quota != null || enabledFallbacks().length > 0
542
+
543
+ const headerLabel = () => {
544
+ const name = prefs().header.label
545
+ return !hasData() ? name : collapsed() ? `\u25b6 ${name}` : `\u25bc ${name}`
546
+ }
547
+ const activeAccount = () => resolveActiveAccount(state())
548
+ const activeQuotaSummary = () =>
549
+ getCollapsedQuotaSummary(activeAccount().quota)
550
+ const activePacingDeficit = () => {
551
+ if (!prefs().sections.pacing) return false
552
+ const quota = activeAccount().quota
553
+ if (!quota) return false
554
+ const now = Date.now()
555
+ const windows: Array<[typeof quota.primary, number]> = [
556
+ [quota.primary, FIVE_HOUR_MS],
557
+ [quota.secondary, SEVEN_DAY_MS],
558
+ ]
559
+ return windows.some(
560
+ ([w, ms]) =>
561
+ w != null && computeQuotaPacing(w, ms, now)?.state === 'deficit',
562
+ )
563
+ }
564
+ const activeQuotaTone = (): Tone => {
565
+ const summary = activeQuotaSummary()
566
+ const values = [
567
+ summary.primaryUsedPercent,
568
+ summary.secondaryUsedPercent,
569
+ ].filter((value): value is number => value != null)
570
+ // Pacing deficit is an advisory projection, not actual quota exhaustion,
571
+ // so it can only BUMP the usage tone up to warn at most — never soften a
572
+ // real warn/err usage reading and never paint a true red.
573
+ const base: Tone =
574
+ values.length > 0
575
+ ? usageTone(Math.max(...values), prefs().appearance)
576
+ : 'muted'
577
+ if (!activePacingDeficit()) return base
578
+ return base === 'ok' || base === 'muted' ? 'warn' : base
579
+ }
580
+ const killedNames = () =>
581
+ [
582
+ state().main?.killed ? 'main' : '',
583
+ ...enabledFallbacks()
584
+ .filter((f) => f.killed)
585
+ .map((f) => f.label ?? f.id),
586
+ ].filter(Boolean)
587
+
588
+ const quotaBackedOff = () => state().main?.quotaBackedOff === true
589
+ const refreshBackedOff = () => state().main?.refreshBackedOff === true
590
+ const degraded = () =>
591
+ killedNames().length > 0 || quotaBackedOff() || refreshBackedOff()
592
+
593
+ return (
594
+ <box
595
+ width='100%'
596
+ flexDirection='column'
597
+ border={SINGLE_BORDER}
598
+ borderColor={theme().borderActive}
599
+ paddingTop={1}
600
+ paddingBottom={1}
601
+ paddingLeft={1}
602
+ paddingRight={1}
603
+ >
604
+ {/* Header: ▼/▶ OPENAI badge (click to collapse) + version, or LIMITED badge */}
605
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: opentui renders to a terminal, not the DOM — ARIA roles do not apply */}
606
+ <box
607
+ width='100%'
608
+ flexDirection='row'
609
+ justifyContent='space-between'
610
+ alignItems='center'
611
+ onMouseDown={() => {
612
+ if (hasData()) props.controller.toggleCollapsed()
613
+ }}
614
+ >
615
+ <box paddingLeft={1} paddingRight={1} backgroundColor={theme().accent}>
616
+ <text fg={theme().background}>
617
+ <b>{headerLabel()}</b>
618
+ </text>
619
+ </box>
620
+ <Show
621
+ when={degraded()}
622
+ fallback={
623
+ <Show when={prefs().header.showVersion && PLUGIN_VERSION !== ''}>
624
+ <text fg={theme().textMuted}>{`v${PLUGIN_VERSION}`}</text>
625
+ </Show>
626
+ }
627
+ >
628
+ <box
629
+ paddingLeft={1}
630
+ paddingRight={1}
631
+ backgroundColor={theme().warning}
632
+ >
633
+ <text fg={theme().background}>
634
+ <b>{'LIMITED'}</b>
635
+ </text>
636
+ </box>
637
+ </Show>
638
+ </box>
639
+
640
+ {/* Collapsed: active account 5h + 7d quota + dot (red ⊘ when killed) */}
641
+ <Show when={collapsed() && hasData()}>
642
+ <CollapsedRow theme={theme()} label={activeAccount().name}>
643
+ <Show
644
+ when={activeQuotaSummary().text != null}
645
+ fallback={<text fg={theme().textMuted}>{'\u2014'}</text>}
646
+ >
647
+ <box flexDirection='row'>
648
+ <text fg={toneColor(theme(), activeQuotaTone())}>
649
+ <b>{activeQuotaSummary().text}</b>
650
+ </text>
651
+ <text
652
+ fg={toneColor(
653
+ theme(),
654
+ activeAccount().killed ? 'err' : activeQuotaTone(),
655
+ )}
656
+ >
657
+ {activeAccount().killed ? ' \u2298' : ' \u25cf'}
658
+ </text>
659
+ </box>
660
+ </Show>
661
+ </CollapsedRow>
662
+ </Show>
663
+
664
+ {/* Expanded: full sections. Also render when there's no data so the
665
+ sidebar can never go blank if data clears while collapsed. */}
666
+ <Show when={!collapsed() || !hasData()}>
667
+ <Show
668
+ when={hasData()}
669
+ fallback={
670
+ <box marginTop={1} width='100%'>
671
+ <text fg={theme().textMuted}>{'Waiting for quota\u2026'}</text>
672
+ </box>
673
+ }
674
+ >
675
+ {/* Quota */}
676
+ <Show when={prefs().sections.quota}>
677
+ <SectionHeader theme={theme()} title='Quota' />
678
+ <AccountBlock
679
+ theme={theme()}
680
+ appearance={prefs().appearance}
681
+ name='main'
682
+ quota={state().main?.quota ?? null}
683
+ killed={state().main?.killed ?? false}
684
+ active={state().activeId === 'main'}
685
+ pacingEnabled={prefs().sections.pacing}
686
+ />
687
+ <Show when={prefs().sections.fallbackAccounts}>
688
+ <For each={enabledFallbacks()}>
689
+ {(fb) => (
690
+ <AccountBlock
691
+ theme={theme()}
692
+ appearance={prefs().appearance}
693
+ name={fb.label ?? fb.id}
694
+ quota={fb.quota}
695
+ killed={fb.killed}
696
+ active={state().activeId === fb.id}
697
+ pacingEnabled={prefs().sections.pacing}
698
+ marginTop={1}
699
+ />
700
+ )}
701
+ </For>
702
+ </Show>
703
+ </Show>
704
+ </Show>
705
+
706
+ {/* Routing */}
707
+ <Show when={prefs().sections.routing}>
708
+ <SectionHeader theme={theme()} title='Routing' />
709
+ <StatRow
710
+ theme={theme()}
711
+ label='Route'
712
+ value={state().route}
713
+ tone='accent'
714
+ />
715
+ </Show>
716
+
717
+ {/* Plan + Credits */}
718
+ <Show when={state().planType || state().credits != null}>
719
+ <Show when={state().planType}>
720
+ <StatRow
721
+ theme={theme()}
722
+ label='Plan'
723
+ value={state().planType ?? '\u2014'}
724
+ tone='text'
725
+ />
726
+ </Show>
727
+ <Show when={state().credits != null}>
728
+ <StatRow
729
+ theme={theme()}
730
+ label='Credits'
731
+ value={String(state().credits)}
732
+ tone='text'
733
+ />
734
+ </Show>
735
+ </Show>
736
+
737
+ {/* Health — only when something is wrong */}
738
+ <Show when={degraded() && prefs().sections.health}>
739
+ <SectionHeader theme={theme()} title='Health' />
740
+ <Show when={quotaBackedOff()}>
741
+ <StatRow
742
+ theme={theme()}
743
+ label='Quota API'
744
+ value={`backoff ${formatUntil(state().main?.quotaBackoffUntil)}`}
745
+ tone='warn'
746
+ />
747
+ </Show>
748
+ <Show when={refreshBackedOff()}>
749
+ <StatRow
750
+ theme={theme()}
751
+ label='Token refresh'
752
+ value={`backoff ${formatUntil(state().main?.refreshBackoffUntil)}`}
753
+ tone='warn'
754
+ />
755
+ </Show>
756
+ </Show>
757
+ <Show when={killedNames().length > 0}>
758
+ <StatRow
759
+ theme={theme()}
760
+ label='Killswitch'
761
+ value={`${killedNames().join(', ')} blocked`}
762
+ tone='err'
763
+ />
764
+ </Show>
765
+ </Show>
766
+ </box>
767
+ )
768
+ }
769
+
770
+ const tui: TuiPlugin = async (api) => {
771
+ const root = await readTuiPreferencesFile()
772
+ const controller = createSidebarController(resolveOpenaiAuthPrefs(root))
773
+ api.slots.register({
774
+ order: computeEffectiveOrder(root, PLUGIN_KEY, DEFAULT_SLOT_ORDER),
775
+ slots: {
776
+ sidebar_content(_ctx: unknown, _props: { session_id: string }) {
777
+ return <QuotaSidebar api={api} controller={controller} />
778
+ },
779
+ },
780
+ })
781
+
782
+ if (!rpcPollStarted) {
783
+ rpcPollStarted = true
784
+ const myPid = process.pid
785
+ const rpcClient = createRpcClient(
786
+ getRpcDir(api.state.path.directory ?? ''),
787
+ myPid,
788
+ (entry) => {
789
+ log.debug('rpc tui pid', {
790
+ myPid,
791
+ matchedPortFilePid: entry?.pid ?? null,
792
+ rpcPort: entry?.port ?? null,
793
+ })
794
+ },
795
+ )
796
+ let lastNotificationId = 0
797
+ let rpcInFlight = false
798
+ setInterval(() => {
799
+ if (rpcInFlight) return
800
+ const current = (api.route as { current?: unknown }).current
801
+ const resolved =
802
+ typeof current === 'function' ? (current as () => unknown)() : current
803
+ const sessionId = (
804
+ resolved as { params?: { sessionID?: string } } | undefined
805
+ )?.params?.sessionID
806
+ rpcInFlight = true
807
+ void rpcClient
808
+ .pending(lastNotificationId, sessionId)
809
+ .then((messages) => {
810
+ for (const message of [...messages].sort((a, b) => a.id - b.id)) {
811
+ lastNotificationId = Math.max(lastNotificationId, message.id)
812
+ if (message.payload.command === 'openai-quota') {
813
+ api.ui.dialog.setSize('xlarge')
814
+ api.ui.dialog.replace(() => (
815
+ <QuotaDialogContent api={api} controller={controller} />
816
+ ))
817
+ continue
818
+ }
819
+ openCommandDialog(api, message.payload, (command, args) =>
820
+ rpcClient.apply({ command, arguments: args }),
821
+ )
822
+ }
823
+ })
824
+ .catch(() => {})
825
+ .finally(() => {
826
+ rpcInFlight = false
827
+ })
828
+ }, RPC_POLL_MS)
829
+ }
830
+ }
831
+
832
+ const plugin: TuiPluginModule & { id: string } = {
833
+ id: ID,
834
+ tui,
835
+ }
836
+
837
+ export default plugin