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

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%)
/contextLes valeurs passent à l'orange puis au rouge au-delà des seuils définis dans THRESHOLDS (hooks/register.tsx).
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 ».
hooks/register.tsx 270 lines1import { 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}
270types/index.d.ts 19 lines1export 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