ccswap inside Claude Code: /ccswap to list and switch accounts, an accounts pane, a switch toast, and a quota band above the prompt

A Claude Code mod that shows your ccswap accounts inside the session. It reads everything from the ccswap CLI on your PATH (ccswap list --json), so install ccswap first (uv tool install ccswap).
/ccswap lists accounts with 5h and 7d usage. /ccswap best, /ccswap next, or /ccswap <slot|email> switches accounts. /ccswap pane opens the accounts pane, and /ccswap refresh re-reads usage. Each command answers right away, without starting a Claude turn.ccswap auto or the menu bar.c Claude (5h and 7d bars, the slot's digit switches to it), x Codex (the slot's digit switches; restart Codex afterwards), h History (recent switches from ccswap's log) and s Settings (t threshold, g strategy, w binding windows, l statusline). r refreshes the open tab, Esc closes the pane.autoswitch.threshold (default 90%). It names the account with the most headroom, and its button switches there. Typing that digit into an empty prompt presses it.claude plugin marketplace add errhythm/ccswap
claude plugin install ccswap@ccswap
Or, inside a session: /plugin install ccswap --marketplace errhythm/ccswap.
After a switch, Claude Code can take about 30s to pick up the new login (macOS Keychain cache). If it doesn't, restart Claude Code.
claude plugin validate mod
claude plugin test mod
claude --plugin-dir modhooks/register.tsx 573 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5 CcswapAccount,
6 CcswapCodexView,
7 CcswapHistoryView,
8 CcswapSettingsView,
9 CcswapSnapshot,
10 CcswapSwitchEntry,
11 CcswapTab,
12 CcswapTone,
13 CcswapWindow,
14} from '../types'
15
16type $ = EngineInterface
17
18const PANE = 'ccswap'
19const POLL_MS = 60_000
20const NOT_FOUND = 'ccswap not found on PATH. Install: uv tool install ccswap'
21const KEYCHAIN_NOTE =
22 "Claude Code may take ~30s to pick up the new login (macOS Keychain cache); restart if it doesn't."
23const USAGE = [
24 'Usage: /ccswap [best | next | <number|email> | pane | refresh]',
25 ' (no args) list accounts',
26 ' best switch to the account with the most headroom',
27 ' next rotate to the next account that is not at its limit',
28 ' <n|email> switch to that account',
29 ' pane open the accounts pane',
30 ' refresh re-read usage now',
31].join('\n')
32
33const snapshot = atom({ plugin: 'ccswap', key: 'snapshot' } as const, null)
34const tone = atom({ plugin: 'ccswap', key: 'tone' } as const, 'theme')
35const switching = atom({ plugin: 'ccswap', key: 'switching' } as const, false)
36const tab = atom({ plugin: 'ccswap', key: 'tab' } as const, 'claude')
37const codexView = atom({ plugin: 'ccswap', key: 'codex' } as const, null)
38const historyView = atom({ plugin: 'ccswap', key: 'history' } as const, null)
39const settingsView = atom({ plugin: 'ccswap', key: 'settings' } as const, null)
40
41const PALETTES = {
42 dark: { accent: '#d7875f', fg: '#e8e4de', muted: '#8a8a8a', ok: '#87af87', warn: '#d7af5f', crit: '#d75f5f', track: '#3a3a3a' },
43 light: { accent: '#954c2a', fg: '#2b2723', muted: '#635d55', ok: '#3d6b3d', warn: '#795911', crit: '#ad3128', track: '#cec7ba' },
44 theme: { accent: 'claude', fg: 'text', muted: 'inactive', ok: 'success', warn: 'warning', crit: 'error', track: 'subtle' },
45} as const
46
47// Letters, so the digits stay free to switch to that slot on the open tab.
48const TABS: { id: CcswapTab; label: string; hotkey: string }[] = [
49 { id: 'claude', label: 'Claude', hotkey: 'c' },
50 { id: 'codex', label: 'Codex', hotkey: 'x' },
51 { id: 'history', label: 'History', hotkey: 'h' },
52 { id: 'settings', label: 'Settings', hotkey: 's' },
53]
54
55// What each Settings press moves to next.
56const THRESHOLDS = [70, 80, 85, 90, 95]
57const STRATEGIES = ['best', 'consume-first']
58const WINDOWS = ['both', '5h', '7d']
59
60type Raw = { ok: true; stdout: string; stderr: string; exitCode: number } | { ok: false; error: string }
61type Ran = { ok: true; data: any } | { ok: false; error: string }
62
63/** Runs `ccswap <args>`; never throws. */
64async function runRaw($: $, args: string[]): Promise<Raw> {
65 try {
66 const out = await $.process.run(['ccswap', ...args], { timeoutMs: 20_000 })
67 if (out.exitCode === 127) return { ok: false, error: NOT_FOUND }
68 return { ok: true, stdout: out.stdout, stderr: out.stderr, exitCode: out.exitCode }
69 } catch (err) {
70 const msg = err instanceof Error ? err.message : String(err)
71 return { ok: false, error: /timed? ?out|still running/i.test(msg) ? `ccswap timed out: ${msg}` : NOT_FOUND }
72 }
73}
74
75/** Runs `ccswap <args>` expecting JSON on stdout; never throws. */
76async function ccswap($: $, args: string[]): Promise<Ran> {
77 const out = await runRaw($, args)
78 if (!out.ok) return out
79 let data: any
80 try {
81 data = JSON.parse(out.stdout)
82 } catch {
83 return { ok: false, error: out.stderr.trim() || `ccswap exited ${out.exitCode} without JSON` }
84 }
85 if (data?.error) return { ok: false, error: String(data.error.message ?? data.error) }
86 return { ok: true, data }
87}
88
89function toWindow(w: any): CcswapWindow | null {
90 return typeof w?.pct === 'number' ? { pct: Math.round(w.pct), countdown: w.countdown ?? null } : null
91}
92
93function toSnapshot(data: any, threshold: number): CcswapSnapshot {
94 const accounts: CcswapAccount[] = (Array.isArray(data?.accounts) ? data.accounts : []).map((a: any) => ({
95 number: Number(a.number),
96 email: String(a.email ?? ''),
97 active: a.active === true,
98 status: String(a.usageStatus ?? 'ok'),
99 fiveHour: toWindow(a.usage?.fiveHour),
100 sevenDay: toWindow(a.usage?.sevenDay),
101 }))
102 const active = typeof data?.activeAccountNumber === 'number' ? data.activeAccountNumber : null
103 return { active, threshold, accounts, error: null }
104}
105
106/** The higher of the 5h and 7d windows: the one that binds. */
107export function binding(a: CcswapAccount): { label: '5h' | '7d'; pct: number } | null {
108 const five = a.fiveHour?.pct
109 const seven = a.sevenDay?.pct
110 if (five === undefined && seven === undefined) return null
111 return (five ?? -1) >= (seven ?? -1) ? { label: '5h', pct: five ?? 0 } : { label: '7d', pct: seven ?? 0 }
112}
113
114function bestOther(snap: CcswapSnapshot): CcswapAccount | null {
115 let best: CcswapAccount | null = null
116 for (const a of snap.accounts) {
117 const b = binding(a)
118 if (a.active || a.status !== 'ok' || b === null) continue
119 if (best === null || b.pct < (binding(best)?.pct ?? 101)) best = a
120 }
121 return best
122}
123
124// Polls overlap (timer, Refresh, post-switch, /ccswap). Each takes a number when it
125// starts; a result older than the last one written is dropped, toast included.
126let issued = 0
127let applied = 0
128
129async function poll($: $): Promise<CcswapSnapshot> {
130 const gen = ++issued
131 const [list, cfg] = await Promise.all([
132 ccswap($, ['list', '--provider', 'claude', '--json']),
133 ccswap($, ['config', 'get', 'autoswitch.threshold', '--json']),
134 ])
135 const threshold = cfg.ok && Number.isFinite(Number(cfg.data?.value)) ? Number(cfg.data.value) : 90
136 let prev: CcswapSnapshot | null = null
137 let snap: CcswapSnapshot | null = null
138 // update() re-runs this on a version miss, so the staleness check and the
139 // write it guards happen together.
140 await update($, snapshot, p => {
141 prev = p
142 if (gen < applied) {
143 snap = null
144 return p
145 }
146 applied = gen
147 snap = list.ok
148 ? toSnapshot(list.data, threshold)
149 : { active: p?.active ?? null, threshold, accounts: [], error: list.error }
150 return snap
151 })
152 const was = prev as CcswapSnapshot | null
153 const now = snap as CcswapSnapshot | null
154 if (now === null) return was ?? { active: null, threshold, accounts: [], error: null }
155
156 if (was && was.active !== null && now.active !== null && was.active !== now.active) {
157 const acct = now.accounts.find(a => a.number === now.active)
158 const five = acct?.fiveHour ? ` (5h ${acct.fiveHour.pct}%)` : ''
159 $.ui.toast(`ccswap: now on #${now.active} ${acct?.email ?? ''}${five}`)
160 }
161 return now
162}
163
164function pollQuietly($: $): void {
165 poll($).catch(() => undefined)
166}
167
168async function loadCodex($: $): Promise<void> {
169 const ran = await ccswap($, ['list', '--provider', 'codex', '--json'])
170 const view: CcswapCodexView = ran.ok
171 ? {
172 accounts: (Array.isArray(ran.data?.accounts) ? ran.data.accounts : []).map((a: any) => ({
173 number: Number(a.number),
174 email: String(a.email ?? ''),
175 label: String(a.label ?? a.authMode ?? ''),
176 active: a.active === true,
177 })),
178 error: null,
179 }
180 : { accounts: [], error: ran.error }
181 await update($, codexView, () => view)
182}
183
184const SWITCH_LINE = /^(\d{4}-\d\d-\d\d \d\d:\d\d).* - Switched from account (\d+) to (\d+)/
185
186/** Claude Code switches in ccswap's log, newest first, as the menu bar lists them. */
187export function parseHistory(text: string, limit = 15): CcswapSwitchEntry[] {
188 const out: CcswapSwitchEntry[] = []
189 for (const line of text.split('\n')) {
190 const m = SWITCH_LINE.exec(line)
191 if (m) out.push({ at: m[1]!, from: Number(m[2]), to: Number(m[3]) })
192 }
193 return out.slice(-limit).reverse()
194}
195
196async function loadHistory($: $): Promise<void> {
197 // The log sits next to settings.json in ccswap's backup folder.
198 const where = await runRaw($, ['config', 'path'])
199 let view: CcswapHistoryView
200 if (!where.ok || where.exitCode !== 0) {
201 view = { entries: [], error: where.ok ? where.stderr.trim() || 'ccswap config path failed' : where.error }
202 } else {
203 const settingsPath = where.stdout.trim()
204 const log = settingsPath.replace(/[^\\/]+$/, 'claude-swap.log')
205 let text = ''
206 try {
207 text = String(await $.fs.read(log))
208 } catch {
209 // no log yet: nothing has been switched
210 }
211 view = { entries: parseHistory(text), error: null }
212 }
213 await update($, historyView, () => view)
214}
215
216async function loadSettings($: $): Promise<void> {
217 const ran = await ccswap($, ['config', 'list', '--json'])
218 const rows: any[] = ran.ok && Array.isArray(ran.data?.settings) ? ran.data.settings : []
219 const value = (key: string) => rows.find(r => r?.key === key)?.value
220 const view: CcswapSettingsView = {
221 threshold: Number(value('autoswitch.threshold') ?? 90),
222 strategy: String(value('autoswitch.strategy') ?? 'best'),
223 windows: String(value('autoswitch.windows') ?? 'both'),
224 statusline: value('claude.statusline') === true,
225 mod: value('claude.mod') === true,
226 error: ran.ok ? null : ran.error,
227 }
228 await update($, settingsView, () => view)
229}
230
231async function loadTab($: $, t: CcswapTab): Promise<void> {
232 if (t === 'claude') await poll($)
233 else if (t === 'codex') await loadCodex($)
234 else if (t === 'history') await loadHistory($)
235 else await loadSettings($)
236}
237
238function nextOf<T>(list: readonly T[], current: T): T {
239 const i = list.indexOf(current)
240 return list[(i + 1) % list.length]!
241}
242
243/** `ccswap config set key value`, then re-reads the Settings tab. */
244async function setSetting($: $, key: string, value: string): Promise<void> {
245 const out = await runRaw($, ['config', 'set', key, value])
246 if (!out.ok) $.ui.toast(`ccswap: ${out.error}`)
247 else if (out.exitCode !== 0) $.ui.toast(`ccswap: ${(out.stderr.trim() || out.stdout.trim()).split('\n')[0]}`)
248 await loadSettings($)
249 if (key === 'autoswitch.threshold') await poll($).catch(() => undefined)
250}
251
252async function readTone($: $): Promise<CcswapTone> {
253 try {
254 const row = (await $.config.list()).find(r => r.key === 'theme')
255 const value = String(row?.value ?? '')
256 if (value.startsWith('light')) return 'light'
257 if (value.startsWith('dark')) return 'dark'
258 } catch {
259 // no config rows reachable: theme keys follow the person's theme anyway
260 }
261 return 'theme'
262}
263
264function cell(w: CcswapWindow | null): string {
265 if (w === null) return ' -'
266 return `${String(w.pct).padStart(3)}%${w.countdown ? ` (${w.countdown})` : ''}`
267}
268
269function listText(snap: CcswapSnapshot): string {
270 if (snap.error) return snap.error
271 if (snap.accounts.length === 0) return 'ccswap manages no Claude accounts yet. Run: ccswap add'
272 const width = Math.max(...snap.accounts.map(a => a.email.length))
273 const rows = snap.accounts.map(a => {
274 const mark = a.active ? '●' : '○'
275 const usage = a.status === 'ok' ? `5h ${cell(a.fiveHour)} 7d ${cell(a.sevenDay)}` : a.status
276 return `${mark} #${a.number} ${a.email.padEnd(width)} ${usage}`
277 })
278 return [`accounts (● active, switch threshold ${snap.threshold}%)`, ...rows].join('\n')
279}
280
281const BUSY = 'a switch is already running'
282// Module-level so the guard is synchronous; the `switching` atom mirrors it for drawing.
283let switchInFlight = false
284
285async function doSwitch(
286 $: $,
287 target: 'best' | 'next' | string,
288 provider: 'claude' | 'codex' = 'claude',
289): Promise<{ text: string; switched: boolean }> {
290 if (switchInFlight) return { text: BUSY, switched: false }
291 switchInFlight = true
292 await update($, switching, () => true).catch(() => undefined)
293 try {
294 if (provider === 'codex') {
295 const ran = await ccswap($, ['codex', 'switch', target, '--json'])
296 if (!ran.ok) return { text: ran.error, switched: false }
297 await loadCodex($).catch(() => undefined)
298 const to = ran.data?.to
299 return ran.data?.switched === true
300 ? { text: `Switched Codex to #${to?.number} ${to?.email ?? ''}. Restart Codex to use it.`, switched: true }
301 : { text: `Codex is already on #${to?.number ?? target}.`, switched: false }
302 }
303 const args =
304 target === 'best'
305 ? ['switch', '--strategy', 'best', '--json']
306 : target === 'next'
307 ? ['switch', '--strategy', 'next-available', '--json']
308 : ['switch', target, '--json']
309 const ran = await ccswap($, args)
310 if (!ran.ok) return { text: ran.error, switched: false }
311 await poll($).catch(() => undefined)
312 const warnings: string[] = Array.isArray(ran.data.warnings) ? ran.data.warnings.map(String) : []
313 const lines = [String(ran.data.message ?? (ran.data.switched ? 'Switched.' : 'No switch.')), ...warnings]
314 if (ran.data.switched) lines.push(KEYCHAIN_NOTE)
315 return { text: lines.join('\n'), switched: ran.data.switched === true }
316 } finally {
317 switchInFlight = false
318 await update($, switching, () => false).catch(() => undefined)
319 }
320}
321
322// Slots 1-9 double as hotkeys: the slot's own digit switches to it.
323function slotKey(n: number): string | undefined {
324 return n >= 1 && n <= 9 ? String(n) : undefined
325}
326
327function severity(pct: number, threshold: number): 'ok' | 'warn' | 'crit' {
328 return pct >= Math.min(90, threshold) ? 'crit' : pct >= 70 ? 'warn' : 'ok'
329}
330
331export const register: Register = on => {
332 on('session.start', async ($, e, next) => {
333 // $.state outlives a reload, switchInFlight does not: clear a flag a reload left set.
334 await update($, switching, () => false).catch(() => undefined)
335 await $.command.register({
336 name: 'ccswap',
337 description: 'ccswap accounts: list, switch (best, next, a slot or email), pane, refresh',
338 argumentHint: '[best|next|<n|email>|pane|refresh]',
339 immediate: true,
340 })
341 void (async () => {
342 const t = await readTone($)
343 await update($, tone, () => t)
344 await poll($)
345 })().catch(() => undefined)
346 $.clock.every(POLL_MS, () => pollQuietly($))
347
348 return next(e)
349 })
350
351 on('command.run', { command: 'ccswap' }, async ($, e) => {
352 const arg = e.args.trim()
353 if (arg === '' || arg === 'refresh') {
354 const snap = await poll($)
355 return { text: arg === '' ? listText(snap) : snap.error ?? `Refreshed: ${snap.accounts.length} accounts, active #${snap.active ?? '?'}.` }
356 }
357 if (arg === 'pane') {
358 void loadTab($, await read($, tab)).catch(() => undefined)
359 // focus so the buttons get keys; Esc hands them back to the prompt and closes
360 await $.ui.open({ id: PANE, title: 'ccswap', focus: true, closeOnEscape: true })
361 return { text: 'pane opened.' }
362 }
363 if (arg === 'best' || arg === 'next' || /^\d+$/.test(arg) || arg.includes('@')) {
364 return { text: (await doSwitch($, arg)).text }
365 }
366 return { text: USAGE }
367 })
368
369 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
370 const { Box, Text, Button } = $.ui.resolve(e)
371 const c = PALETTES[await read($, tone)]
372 const busy = await read($, switching)
373 const open = await read($, tab)
374
375 const tabBar = (
376 <Box flexDirection="row" columnGap={3}>
377 {TABS.map(t => (
378 <Button
379 key={`tab-${t.id}`}
380 label={t.label}
381 hotkey={t.hotkey}
382 plain
383 {...(t.id === open ? {} : { dimColor: true })}
384 onPress={async () => {
385 await update($, tab, () => t.id)
386 await loadTab($, t.id).catch(() => undefined)
387 }}
388 />
389 ))}
390 </Box>
391 )
392 const refresh = (
393 <Button key="refresh" label="Refresh" hotkey="r" plain onPress={() => loadTab($, open).catch(() => undefined)} />
394 )
395 const page = (...body: JSX.Element[]) => (
396 <Box flexDirection="column">
397 {tabBar}
398 <Text> </Text>
399 {...body}
400 {busy && <Text color={c.muted}>switching…</Text>}
401 {refresh}
402 </Box>
403 )
404 const switchButton = (n: number, provider: 'claude' | 'codex') => (
405 <Button
406 key={`${provider === 'codex' ? 'codex-' : ''}switch-${n}`}
407 label={`Switch to #${n}`}
408 plain
409 {...(slotKey(n) ? { hotkey: slotKey(n) } : {})}
410 onPress={async () => {
411 const done = await doSwitch($, String(n), provider)
412 // a real Claude switch already raised poll()'s "now on #N" toast
413 if (!done.switched || provider === 'codex') $.ui.toast(`ccswap: ${done.text.split('\n')[0] ?? ''}`)
414 }}
415 />
416 )
417
418 if (open === 'codex') {
419 const view = await read($, codexView)
420 if (view === null) return page(<Text color={c.muted}>Reading Codex accounts…</Text>)
421 if (view.error) return page(<Text color={c.crit}>{view.error}</Text>)
422 if (view.accounts.length === 0) {
423 return page(<Text color={c.muted}>No Codex accounts. Run `codex login`, then `ccswap codex add`.</Text>)
424 }
425 const width = Math.max(...view.accounts.map(a => a.email.length))
426 return page(
427 ...view.accounts.map(a => (
428 <Box key={`codex-row-${a.number}`} flexDirection="row">
429 <Text color={a.active ? c.accent : c.muted}>{a.active ? '● ' : '○ '}</Text>
430 <Text color={a.active ? c.accent : c.fg} bold={a.active}>
431 #{a.number} {a.email.padEnd(width)}{' '}
432 </Text>
433 <Text color={c.muted}>{a.label} </Text>
434 {!a.active && !busy && switchButton(a.number, 'codex')}
435 </Box>
436 )),
437 <Text color={c.muted}>Codex reads its login once at start: restart it after a switch.</Text>,
438 )
439 }
440
441 if (open === 'history') {
442 const view = await read($, historyView)
443 if (view === null) return page(<Text color={c.muted}>Reading the switch log…</Text>)
444 if (view.error) return page(<Text color={c.crit}>{view.error}</Text>)
445 if (view.entries.length === 0) return page(<Text color={c.muted}>No switches logged yet.</Text>)
446 const emails = new Map((await read($, snapshot))?.accounts.map(a => [a.number, a.email]) ?? [])
447 return page(
448 ...view.entries.map((s, i) => (
449 <Text key={`switch-log-${i}`}>
450 <Text color={c.muted}>{s.at} </Text>
451 <Text color={c.fg}>#{s.from}</Text>
452 <Text color={c.muted}> → </Text>
453 <Text color={c.accent}>#{s.to}</Text>
454 <Text color={c.muted}>{emails.has(s.to) ? ` ${emails.get(s.to)}` : ''}</Text>
455 </Text>
456 )),
457 )
458 }
459
460 if (open === 'settings') {
461 const view = await read($, settingsView)
462 if (view === null) return page(<Text color={c.muted}>Reading ccswap settings…</Text>)
463 if (view.error) return page(<Text color={c.crit}>{view.error}</Text>)
464 const row = (key: string, hotkey: string, label: string, value: string, onPress: () => Promise<void>) => (
465 <Box key={`setting-row-${key}`} flexDirection="row" columnGap={2}>
466 <Button key={`setting-${key}`} label={label} hotkey={hotkey} plain onPress={() => onPress().catch(() => undefined)} />
467 <Text color={c.accent}>{value}</Text>
468 </Box>
469 )
470 return page(
471 row('threshold', 't', 'Auto-switch threshold', `${view.threshold}%`, () =>
472 setSetting($, 'autoswitch.threshold', String(nextOf(THRESHOLDS, view.threshold) ?? 90)),
473 ),
474 row('strategy', 'g', 'Auto-switch strategy', view.strategy, () =>
475 setSetting($, 'autoswitch.strategy', nextOf(STRATEGIES, view.strategy)),
476 ),
477 row('windows', 'w', 'Windows that bind', view.windows, () =>
478 setSetting($, 'autoswitch.windows', nextOf(WINDOWS, view.windows)),
479 ),
480 row('statusline', 'l', 'Claude statusline', view.statusline ? 'on' : 'off', () =>
481 setSetting($, 'claude.statusline', view.statusline ? 'off' : 'on'),
482 ),
483 <Box key="setting-row-mod" flexDirection="row" columnGap={2}>
484 <Text color={c.fg}>Claude Code mod</Text>
485 <Text color={c.accent}>{view.mod ? 'on' : 'off'}</Text>
486 <Text color={c.muted}>(turn off with: ccswap config set claude.mod off)</Text>
487 </Box>,
488 <Text color={c.muted}>Press a setting's key to move it to its next value.</Text>,
489 )
490 }
491
492 const snap = await read($, snapshot)
493 if (snap === null || snap.error) {
494 return page(<Text color={snap?.error ? c.crit : c.muted}>{snap?.error ?? 'Reading ccswap…'}</Text>)
495 }
496 const bar = (w: CcswapWindow | null, label: string) => {
497 if (w === null) return <Text color={c.muted}>{label} - </Text>
498 const filled = Math.max(0, Math.min(10, Math.round(w.pct / 10)))
499 return (
500 <Text>
501 <Text color={c.muted}>{label} </Text>
502 <Text color={c[severity(w.pct, snap.threshold)]}>{'━'.repeat(filled)}</Text>
503 <Text color={c.track}>{'─'.repeat(10 - filled)}</Text>
504 <Text color={c.fg}> {String(w.pct).padStart(3)}% </Text>
505 </Text>
506 )
507 }
508 const width = Math.max(...snap.accounts.map(a => a.email.length))
509 return page(
510 ...snap.accounts.map(a => (
511 <Box key={`row-${a.number}`} flexDirection="row">
512 <Text color={a.active ? c.accent : c.muted}>{a.active ? '● ' : '○ '}</Text>
513 <Text color={a.active ? c.accent : c.fg} bold={a.active}>
514 #{a.number} {a.email.padEnd(width)}{' '}
515 </Text>
516 {a.status === 'ok' ? (
517 <Text>
518 {bar(a.fiveHour, '5h')}
519 {bar(a.sevenDay, '7d')}
520 </Text>
521 ) : (
522 <Text color={c.warn}>{a.status} </Text>
523 )}
524 {!a.active && !busy && switchButton(a.number, 'claude')}
525 </Box>
526 )),
527 )
528 })
529
530 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
531 if (e.props.hasSurvey) return next(e)
532 const snap = await read($, snapshot)
533 const active = snap?.accounts.find(a => a.active)
534 const bound = active ? binding(active) : null
535 if (!snap || snap.error || !active || bound === null || bound.pct < snap.threshold) return next(e)
536
537 const { Box, Text, Button } = $.ui.resolve(e)
538 const c = PALETTES[await read($, tone)]
539 const busy = await read($, switching)
540 const best = bestOther(snap)
541 const bestPct = best ? binding(best)?.pct : undefined
542 return (
543 <Box flexDirection="row">
544 <Text>
545 <Text color={c.accent} bold>ccswap</Text>
546 <Text color={c.muted}> · </Text>
547 <Text color={c[severity(bound.pct, snap.threshold)]}>
548 {bound.label} {bound.pct}%
549 </Text>
550 <Text color={c.fg}> on #{active.number}</Text>
551 <Text color={c.muted}> · </Text>
552 <Text color={best ? c.ok : c.muted}>{best ? `best: #${best.number} (${bestPct}%)` : 'no account with more headroom'}</Text>
553 <Text> </Text>
554 </Text>
555 {busy && <Text color={c.muted}>switching…</Text>}
556 {best && !busy && (
557 <Button
558 key="switch-best"
559 label={`Switch to #${best.number}`}
560 plain
561 // a digit hotkey on the band also fires from an empty prompt
562 {...(slotKey(best.number) ? { hotkey: slotKey(best.number) } : {})}
563 onPress={async () => {
564 const done = await doSwitch($, 'best')
565 if (!done.switched) $.ui.toast(`ccswap: ${done.text.split('\n')[0] ?? ''}`)
566 }}
567 />
568 )}
569 </Box>
570 )
571 })
572}
573types/index.d.ts 54 lines1export type CcswapWindow = { pct: number; countdown: string | null }
2
3export type CcswapAccount = {
4 number: number
5 email: string
6 active: boolean
7 status: string
8 fiveHour: CcswapWindow | null
9 sevenDay: CcswapWindow | null
10}
11
12export type CcswapSnapshot = {
13 active: number | null
14 threshold: number
15 accounts: CcswapAccount[]
16 error: string | null
17}
18
19// 'dark' / 'light' use ccswap's hex palette; 'theme' falls back to theme keys.
20export type CcswapTone = 'dark' | 'light' | 'theme'
21
22export type CcswapTab = 'claude' | 'codex' | 'history' | 'settings'
23
24export type CcswapCodexAccount = { number: number; email: string; label: string; active: boolean }
25
26export type CcswapCodexView = { accounts: CcswapCodexAccount[]; error: string | null }
27
28export type CcswapSwitchEntry = { from: number; to: number; at: string }
29
30export type CcswapHistoryView = { entries: CcswapSwitchEntry[]; error: string | null }
31
32export type CcswapSettingsView = {
33 threshold: number
34 strategy: string
35 windows: string
36 statusline: boolean
37 mod: boolean
38 error: string | null
39}
40
41declare module 'claude-code' {
42 interface PluginState {
43 ccswap: {
44 snapshot: CcswapSnapshot | null
45 tone: CcswapTone
46 switching: boolean
47 tab: CcswapTab
48 codex: CcswapCodexView | null
49 history: CcswapHistoryView | null
50 settings: CcswapSettingsView | null
51 }
52 }
53}
54