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