@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/dist/cli.js +36 -36
- package/dist/{index-f2ne65w4.js → index-62x1p34p.js} +1 -1
- package/dist/index-88zg9ztr.js +2 -0
- package/dist/{index-x325vp4e.js → index-8ysjtqek.js} +13 -13
- package/dist/{index-ah5s0bew.js → index-ppdrzs41.js} +1 -1
- package/dist/index-xbwa4x53.js +2 -0
- package/dist/index.js +1 -1
- package/dist/rpc/port-file.js +1 -1
- package/dist/rpc/protocol.js +1 -1
- package/dist/rpc/rpc-client.js +1 -1
- package/dist/rpc/rpc-dir.js +1 -1
- package/dist/sidebar-state.d.ts +54 -1
- package/dist/sidebar-state.js +1 -1
- package/dist/transform.d.ts +4 -0
- package/dist/tui/claude-sidebar.d.ts +99 -0
- package/dist/tui-preferences.js +1 -1
- package/dist/v2/menu-contract.d.ts +3 -0
- package/dist/v2/rpc.d.ts +9 -0
- package/dist/v2/server.js +4 -4
- package/dist/v2/sidebar.d.ts +13 -0
- package/dist/v2/source.d.ts +2 -0
- package/dist/v2/tui.d.ts +9 -1
- package/dist/v2/tui.js +2 -2
- package/dist/v2/wire.d.ts +4 -0
- package/package.json +5 -5
- package/src/sidebar-state.ts +133 -3
- package/src/tui/claude-sidebar.tsx +1146 -0
- package/src/tui/entry.mjs +15 -1
- package/src/tui-compiled/sidebar-state.ts +133 -3
- package/src/tui-compiled/tui/claude-sidebar.tsx +1310 -0
- package/src/tui-compiled/tui.tsx +46 -1137
- package/src/tui.tsx +15 -886
- package/dist/index-53nnxq39.js +0 -2
- package/dist/index-jexctf5w.js +0 -2
|
@@ -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
|
+
}
|