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…

One row in the band above the prompt, for the Claude desktop app and the terminal:
/model or /effort; where the host refuses the command, the bar sends the pick on every request instead and says so in a toast.The model and the effort show once the session has sent its first request; the quotas only on a subscription, after a first answer.
/plugin install statusbar --marketplace devohmycode/ccmods
claude --plugin-dir ./statusbarhooks/register.tsx 143 lines1import { 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}
143hooks/jauge.ts 79 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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))
79types/index.d.ts 21 lines1export 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