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