SLOPSHOPPER

cost-band

Bande au-dessus du prompt : coût du dernier message / de la session, temps de travail de Claude | durée d'ouverture

newpanebandtimer
v0.2.0no licenseupdated 2026-10-26theo-stoffelbach/cost-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cost-band
│ ┃ cost-band-contexte ✕ › fix the failing auth test and add an audit log call │ ┃ Contexte indisponible │ ⏺ 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 │ │ │ │ $0.000 / $0.420 (0%/0%) 97.4k tokens · ◑ 49% 42s | 30m00s (69% | –) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
$0.000 / $0.420 (0%/0%) 97.4k tokens · ◑ 49% 42s | 30m00s (69% | –)
Pane · cost-band-contexte
Contexte indisponible
README

cost-band

Mod Claude Code : une bande au-dessus du prompt qui affiche

$0.052 / $4.99 (0%/15%) 45.2k tokens · ◔ 23%     7m03s | 1h52m16s (63% | 18%)
  • À gauche : coût du dernier message / coût de la session (part de la limite 5 h consommée par le dernier message / par la session), puis les tokens de la conversation et la part du contexte qu'ils occupent, derrière un rond qui se remplit (bleu, orange dès 50 %, rouge dès 80 %). Un clic sur ce % ouvre un panneau « Contexte » avec le détail par catégorie, comme /context
  • À droite : temps de travail de Claude | durée d'ouverture de la session (limite 5 h restante | limite hebdo restante)

Les valeurs passent à l'orange puis au rouge au-delà des seuils définis dans THRESHOLDS (hooks/register.tsx).

Installation

Dans un terminal Claude Code :

/plugin install cost-band --marketplace theo-stoffelbach/cost-band

Répondre y pour ajouter le marketplace, puis valider la portée « user ».

Source 2 files
hooks/register.tsx 270 lines
1import { atom, read, update } from 'claude-code'
2import type { CoreEngineInterface, Register, SessionUsage } from 'claude-code'
3
4import type { Turn } from '../types'
5
6const lastUsd = atom({ plugin: 'cost-band', key: 'lastUsd' } as const, null)
7const lastPct = atom({ plugin: 'cost-band', key: 'lastPct' } as const, null)
8const sessionPct = atom({ plugin: 'cost-band', key: 'sessionPct' } as const, 0)
9const workMs = atom({ plugin: 'cost-band', key: 'workMs' } as const, 0)
10const running = atom({ plugin: 'cost-band', key: 'running' } as const, null)
11
12const formatUsd = (usd: number) => `$${usd.toFixed(usd < 1 ? 3 : 2)}`
13
14const formatPct = (pct: number) => `${Math.round(pct * 10) / 10}%`
15
16// Pourcentage utilisé de la fenêtre de limite glissante de 5 h, s'il est connu
17const usedPct = (usage: SessionUsage, kind: string) =>
18  usage.rateLimits.find(limit => limit.kind === kind)?.percentUsed ?? null
19
20const fiveHourPct = (usage: SessionUsage) => usedPct(usage, 'five_hour')
21
22// Ce qui reste d'une fenêtre de limite (%), ou « – » si elle est inconnue
23const remaining = (usage: SessionUsage, kind: string) => {
24  const used = usedPct(usage, kind)
25  const pct = used === null ? null : Math.max(0, 100 - used)
26  return { text: pct === null ? '–' : formatPct(pct), pct }
27}
28
29// Seuils d'alerte [orange, rouge] : en dessous la valeur reste neutre
30const THRESHOLDS = {
31  lastUsd: [0.5, 1],
32  sessionUsd: [5, 10],
33  workMs: [30 * 60_000, 60 * 60_000],
34  openMs: [2 * 3600_000, 4 * 3600_000],
35} as const
36
37// Neutre sous le premier seuil, orange au-delà, rouge au-delà du second
38const alertColor = (value: number, [warn, danger]: readonly [number, number], calm: string) => {
39  if (value >= danger) return 'error'
40  if (value >= warn) return 'warning'
41  return calm
42}
43
44// Vert tant qu'il reste de la marge, orange sous 50 %, rouge sous 20 %
45const levelColor = (pct: number | null) => {
46  if (pct === null) return 'inactive'
47  if (pct < 20) return 'error'
48  if (pct < 50) return 'warning'
49  return 'success'
50}
51
52// 45200 → « 45.2k », 1250000 → « 1.25M »
53const formatTokens = (tokens: number) => {
54  if (tokens >= 1_000_000) return `${Math.round(tokens / 10_000) / 100}M`
55  if (tokens >= 1_000) return `${Math.round(tokens / 100) / 10}k`
56  return String(tokens)
57}
58
59// Le panneau qu'ouvre un clic sur le % du contexte : le détail, comme /context
60const CONTEXT_PANE = 'cost-band-contexte'
61
62// Arc du rond de contexte : bleu, orange à partir de 50 %, rouge à partir de 80 %
63const ringColor = (pct: number) => {
64  if (pct >= 80) return '#e5534b'
65  if (pct >= 50) return '#d29922'
66  return '#4a9eff'
67}
68
69// Le rond de contexte en SVG ; l'app le dessine comme une image, d'où les couleurs en dur
70const ring = (pct: number) => {
71  const r = 5
72  const length = 2 * Math.PI * r
73  const arc = (Math.min(100, Math.max(0, pct)) / 100) * length
74
75  return (
76    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="12" height="12">' +
77    `<circle cx="6" cy="6" r="${r}" fill="none" stroke="#8b8b8b" stroke-opacity="0.35" stroke-width="2"/>` +
78    `<circle cx="6" cy="6" r="${r}" fill="none" stroke="${ringColor(pct)}" stroke-width="2" ` +
79    `stroke-dasharray="${arc.toFixed(2)} ${length.toFixed(2)}" transform="rotate(-90 6 6)"/></svg>`
80  )
81}
82
83// Le même rond en un caractère, pour le terminal qui ne dessine pas de SVG
84const ringGlyph = (pct: number) => {
85  if (pct >= 88) return '●'
86  if (pct >= 63) return '◕'
87  if (pct >= 38) return '◑'
88  if (pct >= 13) return '◔'
89  return '○'
90}
91
92const formatDuration = (ms: number) => {
93  const total = Math.max(0, Math.floor(ms / 1000))
94  const h = Math.floor(total / 3600)
95  const m = Math.floor((total % 3600) / 60)
96  const s = total % 60
97  const pad = (n: number) => String(n).padStart(2, '0')
98
99  if (h > 0) return `${h}h${pad(m)}m${pad(s)}s`
100  if (m > 0) return `${m}m${pad(s)}s`
101  return `${s}s`
102}
103
104// Un seul minuteur par chargement du module : il redessine la bande chaque seconde
105let ticker: { cancel: () => void } | null = null
106
107const startTicker = ($: Pick<CoreEngineInterface, 'clock' | 'ui'>) => {
108  if (ticker !== null) return
109  ticker = $.clock.every(1000, () => $.ui.invalidate('ui.render'))
110}
111
112export const register: Register = on => {
113
114  on('session.start', ($, e, next) => {
115    startTicker($)
116
117    return next(e)
118  })
119
120  // Début d'un tour de la boucle principale (les sous-agents n'en lèvent pas)
121  on('turn.start', async ($, e, next) => {
122    const usage = await $.session.usage()
123    const turn: Turn = {
124      startedAt: await $.clock.now(),
125      baseUsd: usage.cost?.usd ?? 0,
126      basePct: fiveHourPct(usage),
127    }
128    await update($, running, () => turn)
129
130    return next(e)
131  })
132
133  on('turn.complete', async ($, e, next) => {
134    const result = await next(e)
135
136    // On ignore les tours des sous-agents : ils comptent dans le tour principal
137    if (e.agentId !== undefined) return result
138
139    const turn = await read($, running)
140    const usage = await $.session.usage()
141    const usd = usage.cost?.usd ?? 0
142    const pct = fiveHourPct(usage)
143
144    await update($, workMs, ms => (ms ?? 0) + e.durationMs)
145    if (turn !== null) {
146      await update($, lastUsd, () => Math.max(0, usd - turn.baseUsd))
147
148      if (pct !== null && turn.basePct !== null) {
149        // Si la fenêtre s'est réinitialisée pendant le tour, tout le % courant vient de lui
150        const delta = pct >= turn.basePct ? pct - turn.basePct : pct
151        await update($, lastPct, () => delta)
152        await update($, sessionPct, total => (total ?? 0) + delta)
153      }
154    }
155    await update($, running, () => null)
156
157    return result
158  })
159
160  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
161    if (e.props.hasSurvey) return next(e)
162
163    const usage = await $.session.usage()
164    // Filet de sécurité : relance le minuteur si session.start ne l'a pas fait
165    startTicker($)
166
167    const t = await $.clock.now()
168    const turn = await read($, running)
169    const last = await read($, lastUsd)
170    const total = usage.cost?.usd
171
172    const work = (await read($, workMs)) + (turn !== null ? t - turn.startedAt : 0)
173    const open = t - usage.startedAt
174
175    const lastP = await read($, lastPct)
176    const hasLimits = usage.rateLimits.length > 0
177    // Tokens occupés par la conversation dans la fenêtre de contexte
178    const tokens = usage.context?.tokens
179    // Part de la fenêtre de contexte qu'ils occupent (%, entier), comme le `ctx` de la barre d'état
180    const contextPct = usage.context?.percent
181    const fiveHour = remaining(usage, 'five_hour')
182    const week = remaining(usage, 'seven_day')
183
184    const elements = $.ui.resolve(e)
185    const { Box, Text, Button } = elements
186    // Le terminal n'a pas de SVG : il dessine le rond en un caractère dans le bouton
187    const Svg = e.surface !== 'terminal' && 'Svg' in elements ? elements.Svg : undefined
188    const openContext = () => void $.ui.open({ id: CONTEXT_PANE, title: 'Contexte' })
189
190    // Séparateurs et parenthèses en atténué, valeurs en couleurs du thème
191    const dim = (text: string) => <Text dimColor>{text}</Text>
192    const limit = (left: { text: string; pct: number | null }) => (
193      <Text color={levelColor(left.pct)}>{left.text}</Text>
194    )
195
196    return (
197      <Box flexDirection="row" justifyContent="space-between" width={e.props.bodyColumns}>
198        {total === undefined ? (
199          <Box flexDirection="row">{dim('coût indisponible')}</Box>
200        ) : (
201          <Box flexDirection="row">
202            <Text color={alertColor(last ?? 0, THRESHOLDS.lastUsd, 'text')}>
203              {last === null ? '–' : formatUsd(last)}
204            </Text>
205            {dim(' / ')}
206            <Text color={alertColor(total, THRESHOLDS.sessionUsd, 'subtle')}>{formatUsd(total)}</Text>
207            {fiveHourPct(usage) !== null && dim(' (')}
208            {fiveHourPct(usage) !== null && (
209              <Text color="suggestion">{lastP === null ? '–' : formatPct(lastP)}</Text>
210            )}
211            {fiveHourPct(usage) !== null && dim('/')}
212            {fiveHourPct(usage) !== null && (
213              <Text color="subtle">{formatPct(await read($, sessionPct))}</Text>
214            )}
215            {fiveHourPct(usage) !== null && dim(')')}
216            {tokens !== undefined && dim(` ${formatTokens(tokens)} tokens`)}
217            {contextPct !== undefined && dim(' · ')}
218            {contextPct !== undefined && Svg !== undefined && (
219              <Svg source={ring(contextPct)} alt={`Contexte ${contextPct} %`} width={12} height={12} />
220            )}
221            {contextPct !== undefined && (
222              <Button
223                key="contexte"
224                plain
225                dimColor
226                label={Svg === undefined ? `${ringGlyph(contextPct)} ${contextPct}%` : `${contextPct}%`}
227                onPress={openContext}
228              />
229            )}
230          </Box>
231        )}
232        <Box flexDirection="row">
233          <Text color={alertColor(work, THRESHOLDS.workMs, 'text')}>{formatDuration(work)}</Text>
234          {dim(' | ')}
235          <Text color={alertColor(open, THRESHOLDS.openMs, 'subtle')}>{formatDuration(open)}</Text>
236          {hasLimits && dim(' (')}
237          {hasLimits && limit(fiveHour)}
238          {hasLimits && dim(' | ')}
239          {hasLimits && limit(week)}
240          {hasLimits && dim(')')}
241        </Box>
242      </Box>
243    )
244  })
245
246  // Le détail du contexte par catégorie, estimé sur place (aucune requête), comme /context
247  on('ui.render', { component: 'Pane', requestId: CONTEXT_PANE }, async ($, e) => {
248    const { Box, Text } = $.ui.resolve(e)
249    const detail = (await $.session.usage({ breakdown: 'summary' })).context?.breakdown
250
251    if (detail === undefined) return <Text dimColor>Contexte indisponible</Text>
252
253    const rows = detail.categories.filter(row => !row.isDeferred && row.tokens > 0)
254
255    return (
256      <Box flexDirection="column" width={e.props.bodyColumns}>
257        <Text>
258          {formatTokens(detail.totalTokens)} / {formatTokens(detail.rawMaxTokens)} tokens ({detail.percentage}%)
259        </Text>
260        {rows.map(row => (
261          <Box flexDirection="row" justifyContent="space-between">
262            <Text dimColor={row.kind !== 'used'}>{row.name}</Text>
263            <Text dimColor>{formatTokens(row.tokens)}</Text>
264          </Box>
265        ))}
266      </Box>
267    )
268  })
269}
270
types/index.d.ts 19 lines
1export type Turn = { startedAt: number; baseUsd: number; basePct: number | null }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'cost-band': {
6      // Coût (USD) du dernier tour terminé, null avant le premier
7      lastUsd: number | null
8      // Part de la limite 5 h consommée par le dernier tour (%), null si inconnue
9      lastPct: number | null
10      // Part de la limite 5 h consommée par la session depuis l'activation du mod (%)
11      sessionPct: number
12      // Temps de travail cumulé de Claude sur les tours terminés (ms)
13      workMs: number
14      // Tour en cours : son début et le coût de la session à ce moment
15      running: Turn | null
16    }
17  }
18}
19