SLOPSHOPPER

ccswap

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

newpanebandcommandtoastprocess
★ 26v0.1.0MITupdated 2026-10-08errhythm/ccswap/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ccswap
│ ┃ ccswap ✕ › fix the failing auth test and add an audit log call │ ┃ c: Claude x: Codex h: History s: Setti │ ┃ ⏺ Read(src/auth.ts) │ ┃ ccswap exited 0 without JSON ⎿ Read 6 lines │ ┃ r: Refresh ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ccswap │ ⎿ ccswap: ccswap exited 0 without JSON │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ccswap
c: Claude x: Codex h: History s: Settings ccswap exited 0 without JSON r: Refresh
README

ccswap mod for Claude Code

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.
  • A toast appears when the active account changes. The mod polls every 60s, so it also catches switches made by ccswap auto or the menu bar.
  • The pane opens with keyboard focus and has four tabs: 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.
  • A band above the prompt appears once the active account's binding window reaches 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.

Install

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.

Develop

claude plugin validate mod
claude plugin test mod
claude --plugin-dir mod
Source 2 files
hooks/register.tsx 573 lines
1import { 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}
573
types/index.d.ts 54 lines
1export 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