SLOPSHOPPER

statusbar

A one-row bar above the prompt: the model and the effort, each switched from a picker; the context, 5-hour and weekly quota gauges, coloured from green to red…

bandtoasttimer
v0.1.0MITupdated 2026-10-17devohmycode/ccmods/statusbar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusbar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 ◆: opus ▾ ⚡: low ▾ Contexte █████░░░░░ 49 % Quota 5 h ███░░░░░░░ 31 % Quota hebdo — $0.42 30 min [ Compact ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆: opus ▾ ⚡: low ▾ Contexte █████░░░░░ 49 % Quota 5 h ███░░░░░░░ 31 % Quota hebdo — $0.42 30 m
README

statusbar

One row in the band above the prompt, for the Claude desktop app and the terminal:

  • ◆ model and ⚡ effort, each a picker. A pick runs /model or /effort; where the host refuses the command, the bar sends the pick on every request instead and says so in a toast.
  • Context, 5-hour quota and weekly quota gauges: SVG bars whose gradient runs green, lime, yellow, orange, red, so the end of the fill takes the colour of the rate reached. Hovering one shows the tokens used or when the quota resets. The terminal, which draws no SVG, gets coloured block bars.
  • The session's cost in dollars and its duration.
  • Compact, which compacts the session between turns.

The model and the effort show once the session has sent its first request; the quotas only on a subscription, after a first answer.

Install

/plugin install statusbar --marketplace devohmycode/ccmods

Develop

claude --plugin-dir ./statusbar
Source 3 files
hooks/register.tsx 143 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionUsage } from 'claude-code'
3
4import type { Mesure } from '../types'
5import { barreTexte, couleur, duree, kTokens, reinit, svgJauge } from './jauge'
6
7const mesure = atom({ plugin: 'statusbar', key: 'mesure' } as const, null)
8const modele = atom({ plugin: 'statusbar', key: 'modele' } as const, null)
9const effort = atom({ plugin: 'statusbar', key: 'effort' } as const, null)
10const maintenant = atom({ plugin: 'statusbar', key: 'maintenant' } as const, 0)
11const surcharge = atom({ plugin: 'statusbar', key: 'surcharge' } as const, {})
12
13const MODELES = ['opus', 'sonnet', 'haiku', 'fable']
14const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
15
16const versMesure = (u: Omit<SessionUsage, 'startedAt'>, startedAt: number): Mesure => ({
17  startedAt,
18  contextePct: u.context.percent ?? null,
19  contexteTokens: u.context.tokens ?? null,
20  fenetre: u.context.window,
21  coutUsd: u.cost?.usd ?? null,
22  quotas: u.rateLimits.map(q => ({ kind: q.kind, percentUsed: q.percentUsed, resetsAt: q.resetsAt })),
23})
24
25// /model et /effort sont le vrai changement ; si l'hôte les refuse, on réécrit chaque requête (turn.step).
26async function changer($: EngineInterface, quoi: 'model' | 'effort', valeur: string) {
27  const champ = quoi === 'model' ? 'modele' : 'effort'
28  if (quoi === 'model') await update($, modele, () => valeur)
29  else await update($, effort, () => valeur)
30  try {
31    if (quoi === 'model') await $.command.run({ command: 'model', args: valeur })
32    else await $.command.run({ command: 'effort', args: valeur })
33    await update($, surcharge, s => ({ ...s, [champ]: undefined }))
34  } catch {
35    await update($, surcharge, s => ({ ...s, [champ]: valeur }))
36    $.ui.toast(`/${quoi} indisponible ici : ${valeur} appliqué à chaque requête par la barre`)
37  }
38}
39
40export const register: Register = on => {
41  on('session.start', async ($, e, next) => {
42    const u = await $.session.usage()
43    await update($, mesure, () => versMesure(u, u.startedAt))
44    const t0 = await $.clock.now()
45    await update($, maintenant, () => t0)
46    $.clock.every(30_000, () => void $.clock.now().then(t => update($, maintenant, () => t)))
47    return next(e)
48  })
49
50  on('session.measure', async ($, e, next) => {
51    const avant = await read($, mesure)
52    const debut = avant?.startedAt ?? (await $.clock.now())
53    await update($, mesure, () => versMesure(e, debut))
54    return next(e)
55  })
56
57  on('turn.step', async function* ($, e, next) {
58    if (e.agentId !== undefined) return yield* next(e)
59    const s = await read($, surcharge)
60    const vers = {
61      ...e,
62      ...(s.modele ? { model: s.modele } : {}),
63      ...(s.effort ? { effort: s.effort as typeof e.effort } : {}),
64    }
65    await update($, modele, () => vers.model)
66    await update($, effort, () => (vers.effort === undefined ? null : String(vers.effort)))
67    return yield* next(vers)
68  })
69
70  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
71    if (e.props.hasSurvey) return next(e)
72    const m = await read($, mesure)
73    const mod = await read($, modele)
74    const eff = await read($, effort)
75    const now = await read($, maintenant)
76    const T = $.ui.resolve(e)
77    const { Box, Text, Button } = T
78    const svg = 'Svg' in T ? T.Svg : undefined
79    const select = 'Select' in T ? T.Select : undefined
80
81    const jauge = (cle: string, etiquette: string, pct: number | null, infobulle: string) => {
82      if (pct === null) return <Text key={cle} dimColor>{etiquette} —</Text>
83      if (svg) {
84        const Svg = svg
85        return (
86          <Svg key={cle} source={svgJauge(pct, etiquette, infobulle)} alt={`${etiquette} ${Math.round(pct)} %`} width={168} height={30} isInteractive />
87        )
88      }
89      return (
90        <Text key={cle}>
91          {etiquette} <Text color={couleur(pct)}>{barreTexte(pct)}</Text> {Math.round(pct)} %
92        </Text>
93      )
94    }
95
96    const q5 = m?.quotas.find(q => q.kind === 'five_hour')
97    const q7 = m?.quotas.find(q => q.kind === 'seven_day')
98    const modeleCourant = mod ? (MODELES.find(a => mod.includes(a)) ?? mod) : undefined
99    const optionsModele = [...new Set([...MODELES, ...(modeleCourant ? [modeleCourant] : [])])].map(v => ({ value: v }))
100    const ctx =
101      m?.contexteTokens != null ? `${kTokens(m.contexteTokens)} / ${kTokens(m.fenetre)} tokens` : `fenêtre ${kTokens(m?.fenetre ?? 0)}`
102
103    return (
104      <Box flexDirection="row" flexWrap="wrap" justifyContent="center" alignItems="center" columnGap={2} rowGap={0}>
105        <Box alignSelf="center" columnGap={2}>
106          {select ? (
107            (() => {
108              const Select = select
109              return [
110                <Select key="modele" label="◆" options={optionsModele} value={modeleCourant} onSelect={v => void changer($, 'model', v)} />,
111                <Select key="effort" label="⚡" options={EFFORTS.map(v => ({ value: v }))} value={eff ?? undefined} onSelect={v => void changer($, 'effort', v)} />,
112              ]
113            })()
114          ) : (
115            <Text dimColor>
116              {mod ?? '…'} · {eff ?? '—'}
117            </Text>
118          )}
119        </Box>
120        {jauge('ctx', 'Contexte', m?.contextePct ?? null, ctx)}
121        {jauge('q5', 'Quota 5 h', q5?.percentUsed ?? null, q5 ? `Réinitialisé ${reinit(q5.resetsAt)}` : 'Aucune lecture')}
122        {jauge('q7', 'Quota hebdo', q7?.percentUsed ?? null, q7 ? `Réinitialisé ${reinit(q7.resetsAt)}` : 'Aucune lecture')}
123        <Box alignSelf="center" columnGap={2}>
124          <Text>{m?.coutUsd != null ? `$${m.coutUsd.toFixed(2)}` : '—'}</Text>
125          <Text>{m ? duree(now - m.startedAt) : '—'}</Text>
126        </Box>
127        <Box alignSelf="center">
128        <Button
129          key="compacter"
130          label="Compact"
131          dimColor={e.props.isWorking}
132          onPress={async () => {
133            if (e.props.isWorking) return void $.ui.toast('Compactage impossible pendant un tour')
134            const r = await $.session.compact().catch((err: unknown) => ({ erreur: String(err) }))
135            $.ui.toast('erreur' in r ? `Compactage refusé : ${r.erreur}` : 'skip' in r && r.skip ? 'Compactage annulé par un hook' : 'Session compactée')
136          }}
137        />
138        </Box>
139      </Box>
140    )
141  })
142}
143
hooks/jauge.ts 79 lines
1// Du vert au rouge : les paliers servent à la fois à la couleur unie et au dégradé SVG.
2const PALIERS: [number, [number, number, number]][] = [
3  [0, [34, 197, 94]], // vert
4  [40, [163, 230, 53]], // vert-jaune
5  [60, [250, 204, 21]], // jaune
6  [75, [251, 146, 60]], // orange
7  [88, [244, 63, 94]], // rose-rouge
8  [100, [220, 38, 38]], // rouge
9]
10
11const hex = (c: [number, number, number]) =>
12  '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('')
13
14export function couleur(pct: number): string {
15  const p = Math.max(0, Math.min(100, pct))
16  for (let i = 1; i < PALIERS.length; i++) {
17    const [p1, c1] = PALIERS[i]!
18    const [p0, c0] = PALIERS[i - 1]!
19    if (p <= p1) {
20      const t = (p - p0) / (p1 - p0)
21      const m = (a: number, b: number) => a + (b - a) * t
22      return hex([m(c0[0], c1[0]), m(c0[1], c1[1]), m(c0[2], c1[2])])
23    }
24  }
25  return hex(PALIERS[PALIERS.length - 1]![1])
26}
27
28const echapper = (s: string) =>
29  s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
30
31/** Jauge SVG : piste, remplissage en dégradé sur toute la piste (la fin du remplissage a la couleur du taux), reflet, halo. */
32export function svgJauge(pct: number, etiquette: string, infobulle: string): string {
33  const L = 168
34  const H = 30
35  const x0 = 2
36  const piste = L - 4
37  const y = 18
38  const h = 8
39  const p = Math.max(0, Math.min(100, pct))
40  const w = Math.max(p > 0 ? h : 0, (piste * p) / 100)
41  const stops = PALIERS.map(([o, c]) => `<stop offset="${o}%" stop-color="${hex(c)}"/>`).join('')
42  const fin = couleur(p)
43  return `<svg xmlns="http://www.w3.org/2000/svg" width="${L}" height="${H}" viewBox="0 0 ${L} ${H}">
44<title>${echapper(infobulle)}</title>
45<defs>
46<linearGradient id="g" gradientUnits="userSpaceOnUse" x1="${x0}" y1="0" x2="${x0 + piste}" y2="0">${stops}</linearGradient>
47<linearGradient id="reflet" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".45"/><stop offset=".6" stop-color="#fff" stop-opacity="0"/></linearGradient>
48<filter id="halo" x="-20%" y="-150%" width="140%" height="400%"><feGaussianBlur stdDeviation="2.2"/></filter>
49<clipPath id="c"><rect x="${x0}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/></clipPath>
50</defs>
51<text x="${x0}" y="12" font-family="ui-sans-serif,system-ui,sans-serif" font-size="10.5" fill="#9ca3af">${echapper(etiquette)}</text>
52<text x="${L - 2}" y="12" text-anchor="end" font-family="ui-monospace,monospace" font-size="10.5" font-weight="600" fill="${fin}">${Math.round(p)} %</text>
53<rect x="${x0}" y="${y}" width="${piste}" height="${h}" rx="${h / 2}" fill="#8881" stroke="#8883"/>
54<rect x="${x0}" y="${y}" width="${w}" height="${h}" rx="${h / 2}" fill="${fin}" opacity=".55" filter="url(#halo)"/>
55<g clip-path="url(#c)"><rect x="${x0}" y="${y}" width="${piste}" height="${h}" fill="url(#g)"/><rect x="${x0}" y="${y}" width="${piste}" height="${h}" fill="url(#reflet)"/></g>
56</svg>`
57}
58
59/** Repli terminal : dix cases colorées par le taux. */
60export function barreTexte(pct: number): string {
61  const n = Math.round(Math.max(0, Math.min(100, pct)) / 10)
62  return '█'.repeat(n) + '░'.repeat(10 - n)
63}
64
65export function duree(ms: number): string {
66  const min = Math.max(0, Math.floor(ms / 60000))
67  return min < 60 ? `${min} min` : `${Math.floor(min / 60)} h ${String(min % 60).padStart(2, '0')}`
68}
69
70export function reinit(iso?: string): string {
71  if (!iso) return ''
72  const d = new Date(iso)
73  return Number.isNaN(d.getTime())
74    ? ''
75    : d.toLocaleString('fr-FR', { weekday: 'short', hour: '2-digit', minute: '2-digit' })
76}
77
78export const kTokens = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
79
types/index.d.ts 21 lines
1export type Mesure = {
2  startedAt: number
3  contextePct: number | null
4  contexteTokens: number | null
5  fenetre: number
6  coutUsd: number | null
7  quotas: { kind: string; percentUsed: number; resetsAt?: string }[]
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    'statusbar': {
13      mesure: Mesure | null
14      modele: string | null
15      effort: string | null
16      maintenant: number
17      surcharge: { modele?: string; effort?: string }
18    }
19  }
20}
21