@cortexkit/opencode-anthropic-auth 2.1.2 → 2.2.1

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 CHANGED
@@ -1,61 +1,34 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
 
3
- import { readFileSync } from 'node:fs'
4
- import { dirname, join } from 'node:path'
5
- import { fileURLToPath } from 'node:url'
6
- import {
7
- formatCreditAmounts,
8
- type QuotaWindowPart,
9
- quotaDisplayProjection,
10
- quotaWindowParts,
11
- } from '@cortexkit/anthropic-auth-core'
12
3
  import type {
13
4
  TuiPlugin,
14
5
  TuiPluginApi,
15
6
  TuiPluginModule,
16
7
  } from '@opencode-ai/plugin/tui'
17
- import { type BoxRenderable, TextAttributes } from '@opentui/core'
18
- import {
19
- createEffect,
20
- createSignal,
21
- For,
22
- Index,
23
- type JSX,
24
- onCleanup,
25
- Show,
26
- } from 'solid-js'
8
+ import { createEffect, createSignal, For, onCleanup, Show } from 'solid-js'
27
9
  import { createRpcClient } from './rpc/rpc-client.js'
28
10
  import { getRpcDir } from './rpc/rpc-dir.js'
29
11
  import {
30
- type AccountQuota,
31
- computeQuotaPacing,
32
12
  DEFAULT_SIDEBAR_STATE,
33
- FIVE_HOUR_MS,
34
- formatPrimeAccountValue,
35
- formatQuotaRowLabel,
36
- getCollapsedAccounts,
37
- getCollapsedQuotaSummary,
38
- getFableRecoverySummary,
39
13
  getSidebarState,
40
- type QuotaPacing,
41
- resolveActiveAccount,
42
- SEVEN_DAY_MS,
43
14
  type SidebarState,
44
15
  } from './sidebar-state.js'
16
+ import {
17
+ AccountBlock,
18
+ ClaudeSidebar,
19
+ createSidebarController,
20
+ type SidebarController,
21
+ } from './tui/claude-sidebar.js'
45
22
  import {
46
23
  openCommandDialog,
47
24
  openNativeMenuDialog,
48
25
  } from './tui/command-dialogs.js'
49
26
  import {
50
- type AnthropicAuthTuiPrefs,
51
- type AppearancePrefs,
52
27
  computeEffectiveOrder,
53
28
  DEFAULT_SLOT_ORDER,
54
29
  PLUGIN_KEY,
55
- queueTuiPreferenceUpdate,
56
30
  readTuiPreferencesFile,
57
31
  resolveAnthropicAuthPrefs,
58
- watchTuiPreferences,
59
32
  } from './tui-preferences.js'
60
33
 
61
34
  const RPC_POLL_MS = 500
@@ -63,473 +36,6 @@ let rpcPollStarted = false
63
36
 
64
37
  const ID = 'cortexkit.anthropic-auth'
65
38
 
66
- // Read package metadata from either the raw src/ entry or its generated
67
- // src/tui-compiled/ counterpart. Avoid a JSON import because package.json sits
68
- // outside the declaration build's rootDir.
69
- const PLUGIN_VERSION: string = (() => {
70
- const here = dirname(fileURLToPath(import.meta.url))
71
- for (const packageFile of [
72
- join(here, '..', 'package.json'),
73
- join(here, '..', '..', 'package.json'),
74
- ]) {
75
- try {
76
- const raw = readFileSync(packageFile, 'utf8')
77
- const version = (JSON.parse(raw) as { version?: string }).version
78
- if (version) return version
79
- } catch {
80
- // Try the path for the other TUI entry layout.
81
- }
82
- }
83
- return ''
84
- })()
85
-
86
- // biome-ignore lint/suspicious/noExplicitAny: opentui border prop not typed in plugin tui surface
87
- const SINGLE_BORDER = { type: 'single' } as any
88
-
89
- type Tone = 'ok' | 'warn' | 'err' | 'muted' | 'accent' | 'text'
90
- type ThemeCurrent = TuiPluginApi['theme']['current']
91
- type ThemeColor = ThemeCurrent['text']
92
-
93
- // Tone -> theme token. Theme tokens only — never hardcoded colors. Fallbacks
94
- // resolve to other theme tokens so the sidebar always tracks the active theme.
95
- function toneColor(theme: ThemeCurrent, tone: Tone): ThemeColor {
96
- switch (tone) {
97
- case 'ok':
98
- return theme.success ?? theme.accent
99
- case 'warn':
100
- return theme.warning ?? theme.accent
101
- case 'err':
102
- return theme.error ?? theme.accent
103
- case 'muted':
104
- return theme.textMuted ?? theme.text
105
- case 'accent':
106
- return theme.accent ?? theme.text
107
- default:
108
- return theme.text
109
- }
110
- }
111
-
112
- function usageTone(usedPct: number, appearance: AppearancePrefs): Tone {
113
- if (usedPct < appearance.warnThreshold) return 'ok'
114
- if (usedPct < appearance.errorThreshold) return 'warn'
115
- return 'err'
116
- }
117
-
118
- interface BarSegment {
119
- text: string
120
- tone: Tone
121
- }
122
-
123
- const PACE_RESERVE_CHAR = '\u2592'
124
- const PACE_DEFICIT_CHAR = '\u2593'
125
-
126
- // Variant C pacing bar: normal fill up to min(used, pace), then a pace
127
- // segment covering the gap — headroom being banked (reserve, ok tone) or the
128
- // overshoot itself (deficit, err tone) — then empty cells. Without pacing the
129
- // bar renders as a single fill+empty pair, identical to the pre-pacing look.
130
- // Empty text nodes still occupy a phantom cell in the opentui flex row, so
131
- // zero-length segments are dropped from every return path (plain and pacing).
132
- function quotaBarSegments(
133
- usedPct: number,
134
- appearance: AppearancePrefs,
135
- pacing: QuotaPacing | null,
136
- ): BarSegment[] {
137
- const width = appearance.barWidth
138
- const cells = (pct: number) =>
139
- Math.max(0, Math.min(Math.round((pct / 100) * width), width))
140
- const usedCells = cells(usedPct)
141
- const fillTone = usageTone(usedPct, appearance)
142
- const plain: BarSegment[] = [
143
- { text: appearance.barFilledChar.repeat(usedCells), tone: fillTone },
144
- {
145
- text: appearance.barEmptyChar.repeat(width - usedCells),
146
- tone: fillTone,
147
- },
148
- ]
149
- if (!pacing) return plain.filter((segment) => segment.text.length > 0)
150
- const paceCells = cells(pacing.pacePercent)
151
- const lo = Math.min(usedCells, paceCells)
152
- const hi = Math.max(usedCells, paceCells)
153
- if (hi === lo) return plain.filter((segment) => segment.text.length > 0)
154
- const overspent = usedCells > paceCells
155
- return (
156
- [
157
- { text: appearance.barFilledChar.repeat(lo), tone: fillTone },
158
- {
159
- text: (overspent ? PACE_DEFICIT_CHAR : PACE_RESERVE_CHAR).repeat(
160
- hi - lo,
161
- ),
162
- tone: overspent ? 'err' : 'ok',
163
- },
164
- { text: appearance.barEmptyChar.repeat(width - hi), tone: fillTone },
165
- ] as BarSegment[]
166
- ).filter((segment) => segment.text.length > 0)
167
- }
168
-
169
- function formatResetIn(resetsAt: string | undefined): string {
170
- if (!resetsAt) return ''
171
- const ms = new Date(resetsAt).getTime() - Date.now()
172
- if (ms <= 0) return 'now'
173
- const mins = Math.floor(ms / 60_000)
174
- if (mins < 60) return `${mins}m`
175
- const hrs = Math.floor(mins / 60)
176
- if (hrs < 24) {
177
- const rm = mins % 60
178
- return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h`
179
- }
180
- const days = Math.floor(hrs / 24)
181
- const rh = hrs % 24
182
- return rh > 0 ? `${days}d${rh}h` : `${days}d`
183
- }
184
-
185
- function formatUntil(until: number | undefined): string {
186
- if (!until) return ''
187
- const ms = until - Date.now()
188
- if (ms <= 0) return 'now'
189
- const mins = Math.ceil(ms / 60_000)
190
- if (mins < 60) return `${mins}m`
191
- const hrs = Math.floor(mins / 60)
192
- const rm = mins % 60
193
- return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h`
194
- }
195
-
196
- // --- Reusable components (aft-style) ---------------------------------------
197
-
198
- function SectionHeader(props: {
199
- theme: ThemeCurrent
200
- title: string
201
- marginTop?: number
202
- }) {
203
- return (
204
- <box width='100%' marginTop={props.marginTop ?? 1}>
205
- <text fg={props.theme.text}>
206
- <b>{props.title}</b>
207
- </text>
208
- </box>
209
- )
210
- }
211
-
212
- function StatRow(props: {
213
- theme: ThemeCurrent
214
- label: string
215
- value: string
216
- tone?: Tone
217
- }) {
218
- return (
219
- <box width='100%' flexDirection='row' justifyContent='space-between'>
220
- <text fg={props.theme.textMuted}>{props.label}</text>
221
- <text fg={toneColor(props.theme, props.tone ?? 'text')}>
222
- <b>{props.value}</b>
223
- </text>
224
- </box>
225
- )
226
- }
227
-
228
- // Compact row for the collapsed view: muted label left, caller-provided value
229
- // right. Mirrors StatRow's layout so columns line up.
230
- function CollapsedRow(props: {
231
- theme: ThemeCurrent
232
- label: string
233
- children: JSX.Element
234
- }) {
235
- return (
236
- <box width='100%' flexDirection='row' justifyContent='space-between'>
237
- <text fg={props.theme.textMuted}>{props.label}</text>
238
- {props.children}
239
- </box>
240
- )
241
- }
242
-
243
- function CollapsedAccountRow(props: {
244
- theme: ThemeCurrent
245
- account: ReturnType<typeof getCollapsedAccounts>[number]
246
- tone: Tone
247
- }) {
248
- const [width, setWidth] = createSignal(40)
249
- let row: BoxRenderable | undefined
250
- const summary = () => getCollapsedQuotaSummary(props.account.quota)
251
- const value = () => summary().text ?? '—'
252
- const labelWidth = () => Math.max(0, width() - Bun.stringWidth(value()) - 1)
253
- const dim = () =>
254
- !props.account.active || props.account.quotaDated || summary().stale
255
- const attributes = () => (dim() ? TextAttributes.DIM : TextAttributes.BOLD)
256
- const color = () =>
257
- dim() ? props.theme.textMuted : toneColor(props.theme, props.tone)
258
- return (
259
- <box
260
- width='100%'
261
- height={1}
262
- flexDirection='row'
263
- ref={(element: BoxRenderable) => {
264
- row = element
265
- setWidth(element.width || 40)
266
- }}
267
- onSizeChange={() => setWidth(row?.width || 40)}
268
- >
269
- <Show when={labelWidth() > 0}>
270
- <text
271
- width={labelWidth()}
272
- flexShrink={0}
273
- wrapMode='none'
274
- fg={color()}
275
- attributes={attributes()}
276
- >
277
- {formatQuotaRowLabel(props.account.name, labelWidth())}
278
- </text>
279
- <text width={1} flexShrink={0}>
280
- {' '}
281
- </text>
282
- </Show>
283
- <text
284
- width={Bun.stringWidth(value())}
285
- flexShrink={0}
286
- wrapMode='none'
287
- fg={color()}
288
- attributes={attributes()}
289
- >
290
- {value()}
291
- </text>
292
- </box>
293
- )
294
- }
295
-
296
- // Quota window row: muted label left, tone-colored bar + shared figure right,
297
- // with an optional muted reset suffix. When pacing data is present, the bar
298
- // gains a pace segment and an off-pace window adds a muted subline with the
299
- // reserve/deficit delta and the projected runout.
300
- function QuotaRow(props: {
301
- theme: ThemeCurrent
302
- appearance: AppearancePrefs
303
- label: string
304
- part: QuotaWindowPart | undefined
305
- usedPercent: number | undefined
306
- pacing: QuotaPacing | null
307
- binding?: boolean
308
- }) {
309
- const used = () => props.usedPercent ?? 0
310
- const reset = () => props.part?.resetIn
311
- const paceLine = () => {
312
- const pacing = props.pacing
313
- if (!pacing || pacing.state === 'on-pace') return null
314
- const pct = Math.round(Math.abs(pacing.deltaPercent))
315
- if (pacing.state === 'reserve') return `reserve ${pct}% \u00b7 lasts`
316
- return pacing.runsOutAt
317
- ? `deficit ${pct}% \u00b7 out in ${formatResetIn(pacing.runsOutAt)}`
318
- : `deficit ${pct}% \u00b7 lasts`
319
- }
320
- return (
321
- <Show
322
- when={props.part}
323
- fallback={
324
- <box width='100%' flexDirection='row'>
325
- <text
326
- fg={props.theme.textMuted}
327
- >{`${formatQuotaRowLabel(props.label)} `}</text>
328
- <text fg={props.theme.textMuted}>{'\u2014'}</text>
329
- </box>
330
- }
331
- >
332
- {/* Left group (label · bar · figure) stays left-aligned in fixed columns so
333
- bars and figures line up across rows; the reset time is pushed to
334
- the right edge so reset times align in their own right column. */}
335
- <box width='100%' flexDirection='row' justifyContent='space-between'>
336
- <box flexDirection='row'>
337
- <text
338
- fg={props.theme.textMuted}
339
- >{`${formatQuotaRowLabel(props.label)} `}</text>
340
- <For each={quotaBarSegments(used(), props.appearance, props.pacing)}>
341
- {(segment) => (
342
- <text fg={toneColor(props.theme, segment.tone)}>
343
- {segment.text}
344
- </text>
345
- )}
346
- </For>
347
- <text
348
- fg={toneColor(props.theme, usageTone(used(), props.appearance))}
349
- >
350
- {` ${props.part?.figure}${props.binding ? ' •' : ''}`}
351
- </text>
352
- </box>
353
- <Show when={reset()}>
354
- <text fg={props.theme.textMuted}>{reset()}</text>
355
- </Show>
356
- </box>
357
- <Show when={paceLine()}>
358
- <box width='100%' flexDirection='row'>
359
- <text fg={props.theme.textMuted}>{formatQuotaRowLabel('', 11)}</text>
360
- <text
361
- fg={toneColor(
362
- props.theme,
363
- props.pacing?.state === 'deficit' ? 'warn' : 'muted',
364
- )}
365
- >
366
- {paceLine()}
367
- </text>
368
- </box>
369
- </Show>
370
- </Show>
371
- )
372
- }
373
-
374
- // The shared credit figure followed by the spent and limit amounts:
375
- // `credits 75% left · $25.00/$100.00`. When that line is wider than the
376
- // measured width, the amounts drop their cents (`$1,250/$5,000`) instead of
377
- // wrapping onto a second line.
378
- function creditLine(
379
- figure: string,
380
- extraUsage: AccountQuota['extraUsage'],
381
- width: number | undefined,
382
- ) {
383
- const amounts = formatCreditAmounts(extraUsage)
384
- if (!amounts) return figure
385
- const line = `${figure} · ${amounts}`
386
- if (width === undefined || Bun.stringWidth(line) <= width) return line
387
- return `${figure} · ${formatCreditAmounts(extraUsage, { wholeUnits: true })}`
388
- }
389
-
390
- // Account block: header row (name + status word) then per-window quota rows.
391
- // Pacing is computed here — per window, against the wall clock at render time
392
- // (re-evaluated on every state poll) — and disabled by passing false.
393
- function AccountBlock(props: {
394
- theme: ThemeCurrent
395
- appearance: AppearancePrefs
396
- name: string
397
- quota: AccountQuota | null
398
- active: boolean
399
- pacingEnabled: boolean
400
- needsReauth?: boolean
401
- vaultReauth?: boolean
402
- stateLine?: string
403
- quotaDated?: boolean
404
- tierLabel?: string
405
- marginTop?: number
406
- }) {
407
- const statusWord = () =>
408
- props.quotaDated && props.quota
409
- ? 'dated'
410
- : props.needsReauth
411
- ? 're-login'
412
- : props.vaultReauth
413
- ? 'needs sign-in in the vault'
414
- : props.active
415
- ? 'active'
416
- : 'idle'
417
- const statusTone = (): Tone =>
418
- props.needsReauth || props.vaultReauth || props.stateLine
419
- ? 'warn'
420
- : props.active
421
- ? 'ok'
422
- : 'muted'
423
- const pacingFor = (
424
- window:
425
- | { usedPercent: number; remainingPercent: number; resetsAt?: string }
426
- | undefined,
427
- windowMs: number,
428
- ) =>
429
- props.pacingEnabled && !props.quotaDated && window
430
- ? computeQuotaPacing(window, windowMs, Date.now())
431
- : null
432
- const parts = () => {
433
- const now = Date.now()
434
- return quotaWindowParts(quotaDisplayProjection(props.quota, now), { now })
435
- }
436
- const partFor = (name: string) =>
437
- parts().windows.find((part) => part.name === name)
438
- return (
439
- <box width='100%' flexDirection='column' marginTop={props.marginTop ?? 0}>
440
- <box width='100%' flexDirection='row' justifyContent='space-between'>
441
- <text fg={props.theme.text}>
442
- <b>{`${props.name}${props.tierLabel ? ` · ${props.tierLabel}` : ''}`}</b>
443
- </text>
444
- <text fg={toneColor(props.theme, statusTone())}>
445
- <b>{statusWord()}</b>
446
- </text>
447
- </box>
448
- <Show when={props.stateLine}>
449
- <text fg={props.theme.textMuted}>{props.stateLine}</text>
450
- </Show>
451
- <Show
452
- when={props.quota}
453
- fallback={<text fg={props.theme.textMuted}>{'checking\u2026'}</text>}
454
- >
455
- <QuotaRow
456
- theme={props.theme}
457
- appearance={props.appearance}
458
- label='5h'
459
- part={partFor('5h')}
460
- usedPercent={props.quota?.five_hour?.usedPercent}
461
- pacing={pacingFor(props.quota?.five_hour, FIVE_HOUR_MS)}
462
- binding={props.quota?.bindingWindow === 'five_hour'}
463
- />
464
- <QuotaRow
465
- theme={props.theme}
466
- appearance={props.appearance}
467
- label='7d'
468
- part={partFor('7d')}
469
- usedPercent={props.quota?.seven_day?.usedPercent}
470
- pacing={pacingFor(props.quota?.seven_day, SEVEN_DAY_MS)}
471
- binding={props.quota?.bindingWindow === 'seven_day'}
472
- />
473
- <For each={props.quota?.scoped ?? []}>
474
- {(window) => (
475
- <QuotaRow
476
- theme={props.theme}
477
- appearance={props.appearance}
478
- label={partFor(window.title)?.name ?? window.title}
479
- part={partFor(window.title)}
480
- usedPercent={window.usedPercent}
481
- pacing={pacingFor(window, SEVEN_DAY_MS)}
482
- binding={props.quota?.bindingWindow === window.id}
483
- />
484
- )}
485
- </For>
486
- <Show when={partFor('credits')}>
487
- {(budget: () => QuotaWindowPart) => {
488
- // The line box's laid-out width, read when it mounts and on each
489
- // resize. It is undefined (amounts keep their cents) until layout.
490
- const [width, setWidth] = createSignal<number>()
491
- let line: BoxRenderable | undefined
492
- return (
493
- <box
494
- width='100%'
495
- ref={(element: BoxRenderable) => {
496
- line = element
497
- setWidth(element.width || undefined)
498
- }}
499
- onSizeChange={() => setWidth(line?.width || undefined)}
500
- >
501
- <text
502
- fg={toneColor(
503
- props.theme,
504
- props.quota?.extraUsage?.severity === 'critical'
505
- ? 'err'
506
- : props.quota?.extraUsage?.severity === 'warning' ||
507
- props.quota?.extraUsage?.exhausted
508
- ? 'warn'
509
- : 'muted',
510
- )}
511
- >
512
- {creditLine(
513
- budget().figure,
514
- props.quota?.extraUsage,
515
- width(),
516
- )}
517
- </text>
518
- </box>
519
- )
520
- }}
521
- </Show>
522
- <Show when={parts().age}>
523
- <text fg={props.theme.textMuted}>{parts().age}</text>
524
- </Show>
525
- <Show when={props.quota?.fallbackAdvised === true}>
526
- <text fg={props.theme.textMuted}>{'→ fallback advised'}</text>
527
- </Show>
528
- </Show>
529
- </box>
530
- )
531
- }
532
-
533
39
  // --- Quota dialog content ---------------------------------------------------
534
40
 
535
41
  // Renders the same rich visualization as the sidebar (account blocks with
@@ -576,6 +82,7 @@ function QuotaDialogContent(props: {
576
82
  tierLabel={state().main?.tierLabel}
577
83
  stateLine={state().main?.stateLine}
578
84
  quotaDated={state().main?.quotaDated}
85
+ now={Date.now()}
579
86
  />
580
87
  <Show when={prefs().sections.fallbackAccounts}>
581
88
  <For each={enabledFallbacks()}>
@@ -593,6 +100,7 @@ function QuotaDialogContent(props: {
593
100
  stateLine={fb.stateLine}
594
101
  quotaDated={fb.quotaDated}
595
102
  marginTop={1}
103
+ now={Date.now()}
596
104
  />
597
105
  )}
598
106
  </For>
@@ -608,77 +116,12 @@ async function readStateFromFile(): Promise<SidebarState> {
608
116
  return getSidebarState()
609
117
  }
610
118
 
611
- interface SidebarController {
612
- prefs: () => AnthropicAuthTuiPrefs
613
- collapsed: () => boolean
614
- toggleCollapsed: () => void
615
- }
616
-
617
- // The TUI may unmount and remount sidebar_content when the user switches
618
- // views (e.g. main -> subagent -> main). A remount re-runs the component
619
- // body, so any signal created inside the component would reset to its
620
- // seed. The controller lives in the plugin closure (process lifetime)
621
- // and owns the durable prefs/collapse signals plus the single shared
622
- // watcher subscription, so collapse and live pref reloads survive the
623
- // remount. No effects or memos here — those need a Solid owner, so the
624
- // poll-interval createEffect stays inside the component.
625
- function createSidebarController(
626
- initialPrefs: AnthropicAuthTuiPrefs,
627
- ): SidebarController {
628
- const [prefs, setPrefs] = createSignal<AnthropicAuthTuiPrefs>(initialPrefs)
629
- const seedCollapsed =
630
- initialPrefs.rememberCollapsed && initialPrefs.collapsed != null
631
- ? initialPrefs.collapsed
632
- : initialPrefs.startCollapsed
633
- const [collapsed, setCollapsed] = createSignal(seedCollapsed)
634
- let lastPersistedCollapsed: boolean | null = initialPrefs.collapsed
635
- let lastApplied = JSON.stringify(initialPrefs)
636
-
637
- // The watcher lives for the plugin/process lifetime — it is intentionally
638
- // never disposed. Collapse guard mirrors the race-fix in toggleCollapsed:
639
- // lastPersistedCollapsed is advanced only once our own write lands, so
640
- // watcher echoes of the previous persisted value are rejected by the
641
- // `!==` check and cannot revert a user click.
642
- watchTuiPreferences(() => {
643
- void (async () => {
644
- const next = resolveAnthropicAuthPrefs(await readTuiPreferencesFile())
645
- const serialized = JSON.stringify(next)
646
- if (serialized === lastApplied) return
647
- lastApplied = serialized
648
- setPrefs(next)
649
- if (
650
- next.rememberCollapsed &&
651
- next.collapsed != null &&
652
- next.collapsed !== lastPersistedCollapsed
653
- ) {
654
- lastPersistedCollapsed = next.collapsed
655
- setCollapsed(next.collapsed)
656
- }
657
- })()
658
- })
659
-
660
- function toggleCollapsed() {
661
- const next = !collapsed()
662
- setCollapsed(next)
663
- if (prefs().rememberCollapsed) {
664
- void queueTuiPreferenceUpdate(PLUGIN_KEY, ['collapsed'], next).then(
665
- () => {
666
- lastPersistedCollapsed = next
667
- },
668
- )
669
- }
670
- }
671
-
672
- return { prefs, collapsed, toggleCollapsed }
673
- }
674
-
675
119
  function QuotaSidebar(props: {
676
120
  api: TuiPluginApi
677
121
  controller: SidebarController
678
122
  sessionId: string
679
123
  }) {
680
124
  const prefs = props.controller.prefs
681
- const collapsed = props.controller.collapsed
682
125
  const [state, setState] = createSignal<SidebarState>(DEFAULT_SIDEBAR_STATE)
683
126
  let lastUpdated = 0
684
127
  let debounce: ReturnType<typeof setTimeout> | null = null
@@ -716,327 +159,13 @@ function QuotaSidebar(props: {
716
159
  if (debounce) clearTimeout(debounce)
717
160
  for (const u of unsubs) u()
718
161
  })
719
-
720
- const theme = () => props.api.theme.current
721
- const enabledFallbacks = () =>
722
- (state().fallbacks ?? []).filter(
723
- (f) => f.enabled || (f.custodyState && f.custodyState !== 'off'),
724
- )
725
- const hasData = () =>
726
- state().main?.quota != null ||
727
- state().main?.stateLine != null ||
728
- enabledFallbacks().length > 0
729
-
730
- const headerLabel = () => {
731
- const name = prefs().header.label
732
- return !hasData() ? name : collapsed() ? `\u25b6 ${name}` : `\u25bc ${name}`
733
- }
734
- const activeAccount = () => resolveActiveAccount(state())
735
- const activeQuotaSummary = () =>
736
- getCollapsedQuotaSummary(activeAccount().quota)
737
- const activePacingDeficit = () => {
738
- if (!prefs().sections.pacing) return false
739
- if (activeAccount().quotaDated) return null
740
- const quota = activeAccount().quota
741
- if (!quota) return false
742
- const now = Date.now()
743
- const windows: Array<
744
- [
745
- (
746
- | { usedPercent: number; remainingPercent: number; resetsAt?: string }
747
- | undefined
748
- ),
749
- number,
750
- ]
751
- > = [
752
- [quota.five_hour, FIVE_HOUR_MS],
753
- [quota.seven_day, SEVEN_DAY_MS],
754
- ...(quota.scoped ?? []).map((window): [typeof window, number] => [
755
- window,
756
- SEVEN_DAY_MS,
757
- ]),
758
- ]
759
- return windows.some(
760
- ([w, ms]) =>
761
- w != null && computeQuotaPacing(w, ms, now)?.state === 'deficit',
762
- )
763
- }
764
- const activeQuotaTone = (): Tone => {
765
- const summary = activeQuotaSummary()
766
- const values = [
767
- summary.fiveHourUsedPercent,
768
- summary.sevenDayUsedPercent,
769
- ...summary.scopedUsedPercents,
770
- ].filter((value): value is number => value != null)
771
- // Pacing deficit is an advisory projection, not actual quota exhaustion,
772
- // so it can only BUMP the usage tone up to warn at most — never soften a
773
- // real warn/err usage reading and never paint a true red.
774
- const base: Tone =
775
- values.length > 0
776
- ? usageTone(Math.max(...values), prefs().appearance)
777
- : 'muted'
778
- if (!activePacingDeficit()) return base
779
- return base === 'ok' || base === 'muted' ? 'warn' : base
780
- }
781
-
782
- const quotaBackedOff = () => state().main?.quotaBackedOff === true
783
- const refreshBackedOff = () => state().main?.refreshBackedOff === true
784
- const needsReauth = () => enabledFallbacks().some((f) => f.needsReauth)
785
- const vaultReauth = () => enabledFallbacks().some((f) => f.vaultReauth)
786
- const degraded = () =>
787
- quotaBackedOff() || refreshBackedOff() || needsReauth() || vaultReauth()
788
-
789
- const fableRecoverySummary = () =>
790
- getFableRecoverySummary(state(), props.sessionId)
791
- const fableRecoveryTone = (): Tone =>
792
- state().fableRecoveries?.find(
793
- (recovery) => recovery.sessionId === props.sessionId,
794
- )?.mode === 'fable'
795
- ? 'ok'
796
- : 'warn'
797
- const cacheKeep = () => state().cacheKeep
798
- const showCache = () =>
799
- prefs().sections.cache && cacheKeep() != null && cacheKeep()?.window != null
800
- const prime = () => state().prime
801
- const showPrime = () => prime() != null
802
- const primeHasError = () =>
803
- prime()?.accounts.some((account) => account.lastResult === 'error') ?? false
804
- const relayValue = () => {
805
- const r = state().relay
806
- if (!r) return '\u2014'
807
- return `${r.transport} \u00b7 ${r.enabled ? 'on' : 'off'}`
808
- }
809
-
810
162
  return (
811
- <box
812
- width='100%'
813
- flexDirection='column'
814
- border={SINGLE_BORDER}
815
- borderColor={theme().borderActive}
816
- paddingTop={1}
817
- paddingBottom={1}
818
- paddingLeft={1}
819
- paddingRight={1}
820
- >
821
- {/* Header: ▼/▶ CLAUDE badge (click to collapse) + version, or LIMITED badge */}
822
- {/* biome-ignore lint/a11y/noStaticElementInteractions: opentui renders to a terminal, not the DOM — ARIA roles do not apply */}
823
- <box
824
- width='100%'
825
- flexDirection='row'
826
- justifyContent='space-between'
827
- alignItems='center'
828
- onMouseDown={() => {
829
- if (hasData()) props.controller.toggleCollapsed()
830
- }}
831
- >
832
- <box paddingLeft={1} paddingRight={1} backgroundColor={theme().accent}>
833
- <text fg={theme().background}>
834
- <b>{headerLabel()}</b>
835
- </text>
836
- </box>
837
- <Show
838
- when={degraded()}
839
- fallback={
840
- <Show when={prefs().header.showVersion && PLUGIN_VERSION !== ''}>
841
- <text fg={theme().textMuted}>{`v${PLUGIN_VERSION}`}</text>
842
- </Show>
843
- }
844
- >
845
- <box
846
- paddingLeft={1}
847
- paddingRight={1}
848
- backgroundColor={theme().warning}
849
- >
850
- <text fg={theme().background}>
851
- <b>{'LIMITED'}</b>
852
- </text>
853
- </box>
854
- </Show>
855
- </box>
856
-
857
- {/* Collapsed accounts keep placement order; only a fresh active row is emphasized. */}
858
- <Show when={collapsed() && hasData()}>
859
- {/* Index keys rows by position: each state update builds new account
860
- objects, so For would rebuild every row and its measured width.
861
- Passing the accessor's value as a prop keeps the row reactive. */}
862
- <Index
863
- each={getCollapsedAccounts(
864
- state(),
865
- prefs().sections.fallbackAccounts,
866
- )}
867
- >
868
- {(account) => (
869
- <CollapsedAccountRow
870
- theme={theme()}
871
- account={account()}
872
- tone={activeQuotaTone()}
873
- />
874
- )}
875
- </Index>
876
- <Show when={state().fastMode}>
877
- <CollapsedRow theme={theme()} label='Mode'>
878
- <text fg={toneColor(theme(), 'accent')}>
879
- <b>{'fast'}</b>
880
- </text>
881
- </CollapsedRow>
882
- </Show>
883
- <Show when={fableRecoverySummary()}>
884
- {(summary: () => string) => (
885
- <CollapsedRow theme={theme()} label='Recovery'>
886
- <text fg={toneColor(theme(), fableRecoveryTone())}>
887
- <b>{summary()}</b>
888
- </text>
889
- </CollapsedRow>
890
- )}
891
- </Show>
892
- </Show>
893
-
894
- {/* Expanded: full sections. Also render when there's no data so the
895
- sidebar can never go blank if data clears while collapsed. */}
896
- <Show when={!collapsed() || !hasData()}>
897
- <Show
898
- when={hasData()}
899
- fallback={
900
- <box marginTop={1} width='100%'>
901
- <text fg={theme().textMuted}>{'Waiting for quota\u2026'}</text>
902
- </box>
903
- }
904
- >
905
- {/* Quota */}
906
- <Show when={prefs().sections.quota}>
907
- <SectionHeader theme={theme()} title='Quota' />
908
- <AccountBlock
909
- theme={theme()}
910
- appearance={prefs().appearance}
911
- name='main'
912
- quota={state().main?.quota}
913
- active={state().activeId === 'main'}
914
- pacingEnabled={prefs().sections.pacing}
915
- tierLabel={state().main?.tierLabel}
916
- stateLine={state().main?.stateLine}
917
- quotaDated={state().main?.quotaDated}
918
- />
919
- <Show when={prefs().sections.fallbackAccounts}>
920
- <For each={enabledFallbacks()}>
921
- {(fb) => (
922
- <AccountBlock
923
- theme={theme()}
924
- appearance={prefs().appearance}
925
- name={fb.label ?? fb.id}
926
- quota={fb.quota}
927
- active={state().activeId === fb.id}
928
- pacingEnabled={prefs().sections.pacing}
929
- tierLabel={fb.tierLabel}
930
- needsReauth={fb.needsReauth}
931
- vaultReauth={fb.vaultReauth}
932
- stateLine={fb.stateLine}
933
- quotaDated={fb.quotaDated}
934
- marginTop={1}
935
- />
936
- )}
937
- </For>
938
- </Show>
939
- </Show>
940
- </Show>
941
-
942
- {/* Routing */}
943
- <Show when={prefs().sections.routing}>
944
- <SectionHeader theme={theme()} title='Routing' />
945
- <StatRow
946
- theme={theme()}
947
- label='Route'
948
- value={state().route}
949
- tone='accent'
950
- />
951
- <StatRow
952
- theme={theme()}
953
- label='Mode'
954
- value={state().fastMode ? 'fast' : 'std'}
955
- tone={state().fastMode ? 'accent' : 'muted'}
956
- />
957
- <StatRow
958
- theme={theme()}
959
- label='Relay'
960
- value={relayValue()}
961
- tone={state().relay?.enabled ? 'ok' : 'muted'}
962
- />
963
- </Show>
964
-
965
- <Show when={fableRecoverySummary()}>
966
- {(summary: () => string) => (
967
- <StatRow
968
- theme={theme()}
969
- label='Recovery'
970
- value={summary()}
971
- tone={fableRecoveryTone()}
972
- />
973
- )}
974
- </Show>
975
-
976
- {/* Cache */}
977
- <Show when={showCache()}>
978
- <SectionHeader theme={theme()} title='Cache' />
979
- <StatRow
980
- theme={theme()}
981
- label='1h cache'
982
- value={`${cacheKeep()?.window} \u00b7 ${cacheKeep()?.enabled ? 'on' : 'off'}`}
983
- tone={cacheKeep()?.enabled ? 'ok' : 'muted'}
984
- />
985
- <Show when={(cacheKeep()?.trackedSessions ?? 0) > 0}>
986
- <StatRow
987
- theme={theme()}
988
- label='Tracked'
989
- value={String(cacheKeep()?.trackedSessions)}
990
- tone='text'
991
- />
992
- </Show>
993
- </Show>
994
-
995
- {/* Prime — only when enabled. Expanded view only (no collapsed surface). */}
996
- <Show when={showPrime()}>
997
- <StatRow
998
- theme={theme()}
999
- label='Prime'
1000
- value={primeHasError() ? 'err' : 'on'}
1001
- tone={primeHasError() ? 'warn' : 'ok'}
1002
- />
1003
- <For each={prime()?.accounts ?? []}>
1004
- {(account) => {
1005
- const value = () => formatPrimeAccountValue(account)
1006
- return (
1007
- <StatRow
1008
- theme={theme()}
1009
- label={account.label}
1010
- value={value().text}
1011
- tone={value().hasError ? 'warn' : 'text'}
1012
- />
1013
- )
1014
- }}
1015
- </For>
1016
- </Show>
1017
-
1018
- {/* Health — only when something is wrong */}
1019
- <Show when={degraded() && prefs().sections.health}>
1020
- <SectionHeader theme={theme()} title='Health' />
1021
- <Show when={quotaBackedOff()}>
1022
- <StatRow
1023
- theme={theme()}
1024
- label='Quota API'
1025
- value={`backoff ${formatUntil(state().main?.quotaBackoffUntil)}`}
1026
- tone='warn'
1027
- />
1028
- </Show>
1029
- <Show when={refreshBackedOff()}>
1030
- <StatRow
1031
- theme={theme()}
1032
- label='Token refresh'
1033
- value={`backoff ${formatUntil(state().main?.refreshBackoffUntil)}`}
1034
- tone='warn'
1035
- />
1036
- </Show>
1037
- </Show>
1038
- </Show>
1039
- </box>
163
+ <ClaudeSidebar
164
+ state={state}
165
+ theme={() => props.api.theme.current}
166
+ controller={props.controller}
167
+ sessionId={props.sessionId}
168
+ />
1040
169
  )
1041
170
  }
1042
171