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