Your Claude Code 5-hour and 7-day usage limits as animated bars under the prompt: percent, reset countdown, pace mark, projection, per-answer delta, styles…

<a href="https://github.com/pepperonas/usage-bars"><img src="docs/social.png" alt="usage-bars — your Claude Code 5-hour and 7-day limits as live bars under the prompt" width="100%"></a>
A Claude Code mod that shows your 5-hour and 7-day usage limits as live, animated bars right under the prompt — with reset countdown, pace mark, projection and a little drama.
<a href="#-install"><img alt="Install in 10 seconds" height="56" src="https://img.shields.io/badge/%E2%AC%87%EF%B8%8F_Install-in_10_seconds-2E9E5B?style=for-the-badge"></a> <a href="#-demo"><img alt="See it move" height="56" src="https://img.shields.io/badge/%F0%9F%8E%AC_Demo-see_it_move-7B4DFF?style=for-the-badge"></a>
<h3>👉 <code>/plugin marketplace add pepperonas/usage-bars</code> · <code>/plugin install usage-bars@pepperonas</code> — that's it.</h3>
[!NOTE] No guesswork, no network. The numbers are the rate-limit figures Claude Code itself received with its last answer. usage-bars makes no request of its own, reads no token and sends nothing anywhere. Countdown, reset detection and projection are computed locally.
<img src="docs/demo.gif" alt="An answer arrives: the 5h bar glides from 39 % to 42 % and +3% flashes and fades. Then the 7-day window resets: the bar drains and sparkles." width="100%">
<sub>An answer arrives — the 5-hour bar glides up and <b>+3%</b> flashes, so you see what that prompt cost. Then the 7-day window resets and the bar drains with a sparkle. Run <code>/usage-bars demo</code> to see it in your own terminal.</sub>
<img src="docs/hero.png" alt="Claude Code with the usage line under the prompt: 5h 42 % with +3 %, 7d 67 %" width="100%">
<sub>Where it lives: one line under the prompt's hint line. Everything else in Claude Code stays as it was.</sub>
<img src="docs/states.png" alt="Delta flash, pace mark, projection warning, seconds countdown, quip at 100 %, reset sparkle, compact mode" width="100%">
<img src="docs/styles.png" alt="The six styles: bars, pacman, beer, tank, battery, hourglass" width="100%">
<sub><code>bars</code> and <code>pacman</code> show what you <b>used</b>; <code>beer</code>, <code>tank</code>, <code>battery</code> and <code>hourglass</code> show what is <b>left</b>. Switch with <code>/usage-bars style <name></code> — or just <code>/usage-bars pacman</code>.</sub>
<img src="docs/hover.png" alt="Hovering the line shows reset times, burn rate in %/h and a sparkline of each window" width="100%">
<sub>Point at the line for reset times, your burn rate and a sparkline per window — the 5-hour level per hour over 24 hours, the 7-day level per day over 7 days — where the surface reports the pointer (the desktop app; terminals that pass mouse movement).</sub>
<sub>Every image above is rendered from the mod's own renderer (<code>hooks/row.ts</code>, <code>hooks/styles.ts</code>) by <code>npm run screenshots</code> — not drawn by hand.</sub>
▏▎▍▌▋▊▉█) give eight steps per cell, so even a 1 % move is visible.+3% appears next to the number, bold at first, then fading out over four seconds. You see what that prompt cost, not just the total.│ — where you would be if you spread the window evenly. Fill left of the mark: you're fine. Right of it: you're burning faster than the window lasts.⚠ empty ~16:40. Silent when you'll make it.↻ 2h13m, and in the last hour ↻ 12m41s.refuelled ✨.☕ Coffee break – back in 1h12m, one of seven, stable per window.bars, pacman (a ghost chases Pac-Man from 90 %), beer, tank, battery (⚡ flashes when nearly empty), hourglass.5h 42% 😎 ↻2h13m 7d 67% 😬 ↻3d 4h; automatic below 72 columns./usage-bars stats: burn rate, projection, sparklines of your 5-hour peaks over 24 h and 7 days, your record./usage-bars lang de./usage-bars and the line under the prompt lists the subcommands, narrowing as you type (st → stats · style), with what a single match does. Claude Code completes a command's name but not its arguments, so the mod shows them itself./usage-bars anim off), sound is off by default.The repository is its own plugin marketplace. In Claude Code:
/plugin marketplace add pepperonas/usage-bars
/plugin install usage-bars@pepperonas
or from the shell:
claude plugin marketplace add pepperonas/usage-bars
claude plugin install usage-bars@pepperonas
The bars appear after the first answer — that's when Claude Code first learns your limits. Update with /plugin marketplace update pepperonas, then claude plugin update usage-bars@pepperonas. Settings: /plugin configure usage-bars@pepperonas (every option has a default, so you can skip it).
Claude Code loads a plugin it finds in ~/.claude/skills/<name> by itself, in every session:
git clone https://github.com/pepperonas/usage-bars ~/.claude/skills/usage-bars
Update with git -C ~/.claude/skills/usage-bars pull. If you also install it from the marketplace, the marketplace copy wins and the skills-folder copy is not loaded.
git clone https://github.com/pepperonas/usage-bars
claude --plugin-dir ./usage-bars
Where you can't pass a flag, name the folder in CLAUDE_CODE_PLUGIN_DIRS — in your shell environment or in the env block of ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/src/usage-bars" } }
| Command | What it does | |
|---|---|---|
/usage-bars | Help and the current settings | |
/usage-bars stats | Burn rate, projection, sparklines (24 h / 7 d), record | |
/usage-bars demo | Plays the animation once | |
/usage-bars full · compact · off | Display mode | |
/usage-bars style <name> | bars · pacman · beer · tank · battery · hourglass (also /usage-bars pacman) | |
/usage-bars lang en · de | Language of the line, toasts and commands | |
/usage-bars anim · sound · toasts · face `[on\ | off]` | Switches; without on/off they toggle |
The command runs immediately, even while Claude is working. While you type it, the line under the prompt lists what may follow — after style the styles, after lang the languages, after a switch on · off. It's a list to read, not Tab completion: Claude Code doesn't let a mod complete arguments.
The defaults live in /config under usage-bars:
| Field | Values | Default |
|---|---|---|
style | bars pacman beer tank battery hourglass | bars |
mode | full compact off | full |
language | en de | en |
animation | on / off | on |
sound | on / off | off |
toasts | on / off | on |
face | on / off | on |
style, mode and language are typed in as text; a value the mod doesn't know falls back to the default. What you set with /usage-bars is stored and wins over /config — so a quick /usage-bars pacman sticks across sessions.
Claude Code answer ──► session.measure ──► apply() ──► $.state ──► ui.render (PromptHint)
(rate-limit figures) (a window moved glide · delta · limits engine's hint line
a whole point) toast · history motion + the bars line
deltas
1 s ticker ──────────► reset passed? ──► zero it + sparkle party
every 5 s ───────────► other sessions newer? ──► take them over
last hour? ──► redraw every second history ──► $.store (8 days)
animation ───────────► 30 fps while something moves, then stops
session.start reads $.session.usage(); after that, session.measure pushes new figures whenever a window moves by a whole point. The Claude API is never polled.latest in $.store (one file for all sessions, read from disk on every get), and each session merges latest back every 5 s and at its start. The merge needs no timestamps, so a stale session can't pull the figures back: a window whose reset passed reads 0, a later reset time is a later window and wins, and within one window the higher percent wins (usage only grows until the reset). The history is merged the same way instead of overwritten.ui.render hook on the PromptHint component returns the engine's own hint line plus one row — the shortcuts and pills keep working.elapsed = 1 − (resetsAt − now) / window; the mark sits at that share of the bar, and used / elapsed picks the face.$.store — that's what the sparklines, the record and the once-per-window toasts are built on. A reading only arrives when a window moves, so the ticker records a steady value again every 10 minutes; a · in a sparkline then really means no session was running.usage-bars makes no network request, reads no credentials and writes only its own key-value store (history, settings, which toasts were shown). Its hooks are listed by claude plugin validate .: $.audio.play, $.clock.*, $.command.register, $.session.usage, $.state.*, $.store.*, $.ui.invalidate, $.ui.resolve, $.ui.toast.
| File | Role |
|---|---|
hooks/register.tsx | The mod: hooks, state, ticker, animation loop, /usage-bars |
hooks/row.ts | Pure: one window's segment of the line, and the hover card |
hooks/styles.ts | Pure: the six styles, gradient, sparkle |
hooks/share.ts | Pure: merging the readings and history of concurrent sessions |
hooks/format.ts | Pure: countdown, clock, pace, face, burn rate, projection, history |
hooks/i18n.ts | English and German strings |
types/index.d.ts | The state contract (PluginState['usage-bars']) |
sounds/ | Two short chimes, generated, no third-party audio |
tools/screenshots.ts | Renders every image in this README from row.ts / styles.ts |
Everything that decides what is drawn is pure and engine-free — which is why it can be tested with plain Node and rendered into screenshots.
There are two suites, and the split is deliberate.
Node suite — tests/*.spec.ts, plain node:test, no Claude Code needed; this is what CI runs. It covers the pure logic (countdown, pace, projection, history, every style at every value, the row in every state, both languages) and drift guards that hold this README to the code: the version badge to plugin.json and package.json, the test-count badges to the real number of tests, every style and command to the docs, every /config field to the table above, the CHANGELOG to the version, the marketplace entry to the manifest, and that no lockfile ships (Claude Code would install the dev tools for every user).
Engine suite — hooks/*.test.ts(x), run by claude plugin test . against Claude Code's own engine: the line is drawn on the terminal and desktop surfaces, a measure makes the bar glide and the delta flash and fade, a threshold toasts exactly once, an idle reset drops to 0 with a party, an idle session takes over what another session published (and a stale one never pulls it back), /usage-bars switches mode, style, language and flags, and settings survive a new session.
Every new test is mutated once. A test that has never been seen red is not an assurance. So each guarded behaviour gets its bug put back (reset detection off, threshold check removed, delta dropped, glide skipped, projection hidden, settings not stored, ease-out removed) and the suite must go red — seven of seven did.
npm install # dev tools only; the mod itself has no dependencies
npm test # node suite (CI)
claude plugin test . # engine suite
claude plugin validate . # what the module hooks and calls
npm run screenshots # re-render docs/ (needs `npx playwright install chromium` and ffmpeg)
The bars don't show up. They appear after the first answer of a session — that's when Claude Code learns your limits. With an API key there are no 5-hour/7-day windows at all, so the line stays hidden. /usage-bars tells you whether the mod is loaded; if it says mode off, run /usage-bars full.
Why does the number lag behind /usage? The bars show what came with the last answer. Between answers nothing new arrives — except the reset, which the mod detects from the reset time.
The hover card never appears. It needs a surface that reports the pointer: the desktop app does; in a terminal it depends on mouse reporting. /usage-bars stats shows the same and more.
I hear nothing. Sound is off by default: /usage-bars sound on.
It's too much motion. /usage-bars anim off — bars jump, the delta stays without fading, no sparkle.
Can I add a style? Yes — one function in hooks/styles.ts, its name in STYLES, types/index.d.ts and plugin.json. The drift guards tell you if you forgot the README.
The full history is in CHANGELOG.md (Keep a Changelog).
/usage-bars lists what may follow, under the prompt./config fields the directory accepts./plugin install usage-bars@pepperonas./usage-bars.Issues and pull requests are welcome. Please keep both suites green (npm test, claude plugin test .) and put the bug back once before you trust a new test. A change to a command, a style, a setting or the version usually needs its counterpart in this README in the same PR — the drift guards will point at it.
usage-bars is free and stays that way. If it saves you a surprise at 100 %:
MIT © 2026 Martin Pfeffer · celox.io. See LICENSE.
usage-bars is an independent community project and is not affiliated with or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic, PBC.
hooks/register.tsx 451 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
3
4import type { Delta, History, Lang, Limit, Mode, Motion, Prefs, Style } from '../types'
5import {
6 COMPACT_BELOW,
7 DELTA_MS,
8 PARTY_MS,
9 SLIDE_MS,
10 WINDOWS,
11 appendSample,
12 barWidth,
13 clockTime,
14 glide,
15 heartbeat,
16 peaks,
17 pick,
18 quip,
19 rate,
20 reached,
21 runsDryAt,
22 spark,
23 untilReset,
24} from './format'
25import { STYLES } from './styles'
26import type { Seg } from './styles'
27import { detailText, label, rowSegs } from './row'
28import { LANGS, T, num1 } from './i18n'
29import { complete, isOurs } from './complete'
30import { asLimits, mergeHistory, mergeLimits, sameLimits } from './share'
31
32const limitsA = atom({ plugin: 'usage-bars', key: 'limits' } as const, [] as Limit[])
33const motionA = atom({ plugin: 'usage-bars', key: 'motion' } as const, {} as Record<string, Motion>)
34const deltasA = atom({ plugin: 'usage-bars', key: 'deltas' } as const, {} as Record<string, Delta>)
35const partyA = atom({ plugin: 'usage-bars', key: 'party' } as const, {} as Record<string, number>)
36const historyA = atom({ plugin: 'usage-bars', key: 'history' } as const, {} as History)
37const prefsA = atom({ plugin: 'usage-bars', key: 'prefs' } as const, {
38 mode: 'full',
39 style: 'bars',
40 animation: true,
41 sound: false,
42 toasts: true,
43 face: true,
44 lang: 'en',
45} as Prefs)
46
47const MODES: readonly Mode[] = ['full', 'compact', 'off']
48const FLAGS = ['animation', 'sound', 'toasts', 'face'] as const
49type Flag = (typeof FLAGS)[number]
50
51/** Whether the line is listing `/usage-bars` arguments right now. */
52let listing = false
53
54/** Redraw for a draft that is `/usage-bars …`, or that just stopped being one. */
55function redrawIfOurs($: EngineInterface, text: string): void {
56 const ours = isOurs(text)
57 if (ours || listing) $.ui.invalidate('ui.render')
58 listing = ours
59}
60
61const keep = (list: readonly Limit[]): Limit[] =>
62 list.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt }))
63
64/** userConfig defaults, overridden by what /usage-bars stored. */
65function prefsOf(options: PluginOptions, stored: unknown): Prefs {
66 const s = (stored && typeof stored === 'object' ? stored : {}) as Partial<Prefs>
67 const opt = <T,>(v: unknown, ok: (v: unknown) => v is T, d: T): T => (ok(v) ? v : d)
68 const isStyle = (v: unknown): v is Style => STYLES.includes(v as Style)
69 const isMode = (v: unknown): v is Mode => MODES.includes(v as Mode)
70 const isBool = (v: unknown): v is boolean => typeof v === 'boolean'
71 const isLang = (v: unknown): v is Lang => LANGS.includes(v as Lang)
72 const base: Prefs = {
73 mode: opt(options.mode, isMode, 'full'),
74 style: opt(options.style, isStyle, 'bars'),
75 animation: opt(options.animation, isBool, true),
76 sound: opt(options.sound, isBool, false),
77 toasts: opt(options.toasts, isBool, true),
78 face: opt(options.face, isBool, true),
79 lang: opt(options.language, isLang, 'en'),
80 }
81 return {
82 mode: opt(s.mode, isMode, base.mode),
83 style: opt(s.style, isStyle, base.style),
84 animation: opt(s.animation, isBool, base.animation),
85 sound: opt(s.sound, isBool, base.sound),
86 toasts: opt(s.toasts, isBool, base.toasts),
87 face: opt(s.face, isBool, base.face),
88 lang: opt(s.lang, isLang, base.lang),
89 }
90}
91
92
93let animTimer: Timer | undefined
94let animUntil = 0
95let tickTimer: Timer | undefined
96let ticks = 0
97
98
99/** Redraw at ~30 fps until `until`, then stop. */
100function animate($: EngineInterface, now: number, ms: number): void {
101 animUntil = Math.max(animUntil, now + ms)
102 if (animTimer) return
103 animTimer = $.clock.every(33, () => {
104 $.ui.invalidate('ui.render')
105 void $.clock.now().then(t => {
106 if (t > animUntil && animTimer) {
107 animTimer.cancel()
108 animTimer = undefined
109 $.ui.invalidate('ui.render')
110 }
111 })
112 })
113}
114
115/** Readings in the store, written by any session; nothing when unreadable. */
116async function shared($: EngineInterface): Promise<Limit[]> {
117 return asLimits(await $.store.get('latest').catch(() => undefined))
118}
119
120/** Hand this session's fresh readings to the others; returns what holds now. */
121async function publish($: EngineInterface, mine: readonly Limit[], now: number): Promise<Limit[]> {
122 const stored = await shared($)
123 const merged = mergeLimits(stored, mine, now)
124 if (!sameLimits(stored, merged)) await $.store.set('latest', merged).catch(() => undefined)
125 return merged
126}
127
128/** Record a steady reading again now and then, so the sparklines have no false gaps. */
129async function beat($: EngineInterface, list: readonly Limit[], now: number): Promise<void> {
130 const stored = ((await $.store.get('history').catch(() => undefined)) ?? {}) as History
131 const merged = mergeHistory(stored, await read($, historyA), now)
132 const next = heartbeat(merged, list, now)
133 if (next === merged) return
134 await update($, historyA, () => next)
135 await $.store.set('history', next).catch(() => undefined)
136}
137
138/** How often (in 1 s ticks) a session looks at what the others heard. */
139const SYNC_TICKS = 5
140
141/** New readings in: glide, delta, party, thresholds, history. */
142async function apply($: EngineInterface, next: readonly Limit[], now: number): Promise<void> {
143 const old = await read($, limitsA)
144 const prefs = await read($, prefsA)
145 const motion = { ...(await read($, motionA)) }
146 const deltas = { ...(await read($, deltasA)) }
147 const party = { ...(await read($, partyA)) }
148 // Fold in what the other sessions recorded, so the shared history keeps everyone's samples.
149 let history = mergeHistory(((await $.store.get('history').catch(() => undefined)) ?? {}) as History, await read($, historyA), now)
150 const warned = ((await $.store.get('warned')) ?? {}) as Record<string, number>
151 let warnedChanged = false
152 let sound: string | undefined
153
154 for (const n of next) {
155 const o = pick(old, n.kind)
156 const shown = glide(motion[n.kind], now) ?? o?.percentUsed ?? 0
157 const isReset = !!o && n.percentUsed < o.percentUsed && n.resetsAt !== o.resetsAt
158 if (o && n.percentUsed !== o.percentUsed) motion[n.kind] = { from: shown, to: n.percentUsed, at: now }
159 if (o && !isReset && n.percentUsed > o.percentUsed) {
160 const prev = deltas[n.kind]
161 const carry = prev && now - prev.at < DELTA_MS ? prev.value : 0
162 deltas[n.kind] = { value: carry + n.percentUsed - o.percentUsed, at: now }
163 }
164 if (isReset) {
165 party[n.kind] = now
166 sound = 'sounds/chime.wav'
167 }
168
169 const r = reached(n.percentUsed)
170 const key = `${n.kind}@${n.resetsAt ?? ''}`
171 if (r > (warned[key] ?? 0)) {
172 warned[key] = r
173 warnedChanged = true
174 if (prefs.toasts) {
175 const L = T[prefs.lang]
176 const until = untilReset(n.resetsAt, now, prefs.lang)
177 $.ui.toast(
178 r >= 100
179 ? L.toastFull(quip(n.resetsAt, prefs.lang), label(n.kind), until)
180 : `${r >= 90 ? '🥵' : '⚠'} ${L.toastAt(label(n.kind), Math.round(n.percentUsed), until)}`,
181 { timeoutMs: 6000 },
182 )
183 }
184 if (r >= 90) sound = 'sounds/warn.wav'
185 }
186 history = appendSample(history, n.kind, [now, n.percentUsed], now)
187 }
188
189 await update($, limitsA, () => keep(next))
190 await update($, motionA, () => motion)
191 await update($, deltasA, () => deltas)
192 await update($, partyA, () => party)
193 await update($, historyA, () => history)
194 await $.store.set('history', history)
195 if (warnedChanged) {
196 // only the current windows' marks are worth keeping
197 const live = new Set(next.map(n => `${n.kind}@${n.resetsAt ?? ''}`))
198 await $.store.set('warned', Object.fromEntries(Object.entries(warned).filter(([k]) => live.has(k))))
199 }
200 const five = pick(next, 'five_hour')?.percentUsed ?? 0
201 const record = ((await $.store.get('record')) ?? null) as { p: number; t: number } | null
202 if (!record || five > record.p) await $.store.set('record', { p: five, t: now })
203
204 if (sound && prefs.sound) void $.audio.play({ asset: sound }).catch(() => undefined)
205 if (prefs.animation) animate($, now, Math.max(SLIDE_MS, DELTA_MS, PARTY_MS))
206 else void $.clock.after(DELTA_MS + 50, () => $.ui.invalidate('ui.render'))
207}
208
209export const register: Register = (on, options) => {
210 on('session.start', async ($, e, next) => {
211 await update($, prefsA, () => prefsOf(options, undefined))
212 const stored = await $.store.get('prefs')
213 await update($, prefsA, () => prefsOf(options, stored))
214 const history = ((await $.store.get('history')) ?? {}) as History
215 await update($, historyA, () => history)
216 const usage = await $.session.usage()
217 // A new session has no response yet: start from what the others heard.
218 const start = mergeLimits(keep(usage.rateLimits), await shared($), await $.clock.now())
219 await update($, limitsA, () => start)
220
221 await $.command.register({
222 name: 'usage-bars',
223 description: 'Usage bars: stats, demo, mode, style, language (/usage-bars help)',
224 argumentHint: '[stats|demo|full|compact|off|style <name>|lang en|de|anim|sound|toasts|face on|off]',
225 immediate: true,
226 })
227
228 // Countdown, local reset detection, idle wiggle.
229 tickTimer?.cancel()
230 tickTimer = $.clock.every(1000, () => {
231 ticks += 1
232 void (async () => {
233 const now = await $.clock.now()
234 const list = await read($, limitsA)
235 const prefs = await read($, prefsA)
236 // Another session heard newer figures, or a window ended while nothing
237 // asked the API: either way the bars move without a response of our own.
238 const others = ticks % SYNC_TICKS === 0 ? await shared($) : []
239 const merged = mergeLimits(list, others, now)
240 if (!sameLimits(merged, list)) {
241 await apply($, merged, now)
242 return
243 }
244 if (ticks % 60 === 0) await beat($, list, now)
245 const soon = list.some(l => l.resetsAt && Date.parse(l.resetsAt) - now < 3600_000)
246 const wiggle = prefs.animation && (prefs.style === 'hourglass' || prefs.style === 'battery')
247 if (soon || wiggle || ticks % 30 === 0) $.ui.invalidate('ui.render')
248 })()
249 })
250 return next(e)
251 })
252
253 on('session.measure', async ($, e, next) => {
254 if (e.changed.includes('rateLimits')) {
255 const now = await $.clock.now()
256 await apply($, await publish($, keep(e.rateLimits), now), now)
257 }
258 return next(e)
259 })
260
261 on('command.run', { command: 'usage-bars' }, async ($, e) => {
262 const [cmd = '', arg = ''] = e.args.trim().toLowerCase().split(/\s+/)
263 const prefs = await read($, prefsA)
264 const save = async (p: Prefs) => {
265 await update($, prefsA, () => p)
266 await $.store.set('prefs', p)
267 $.ui.invalidate('ui.render')
268 }
269 const now = await $.clock.now()
270
271 const de = prefs.lang === 'de'
272 const tr = (en: string, ger: string) => (de ? ger : en)
273 const onOff = (v: boolean) => (v ? tr('on', 'an') : tr('off', 'aus'))
274
275 if (MODES.includes(cmd as Mode)) {
276 await save({ ...prefs, mode: cmd as Mode })
277 return { text: `${tr('mode', 'Modus')} ${cmd}` }
278 }
279 const style = (cmd === 'style' ? arg : cmd) as Style
280 if (STYLES.includes(style)) {
281 await save({ ...prefs, style, mode: 'full' })
282 return { text: `${tr('style', 'Stil')} ${style}` }
283 }
284 if (cmd === 'style') return { text: `${tr('styles', 'Stile')}: ${STYLES.join(', ')} (${tr('current', 'aktuell')} ${prefs.style})` }
285
286 if (cmd === 'lang' || cmd === 'language' || cmd === 'sprache') {
287 const lang = LANGS.includes(arg as Lang) ? (arg as Lang) : prefs.lang === 'en' ? 'de' : 'en'
288 await save({ ...prefs, lang })
289 return { text: lang === 'de' ? 'Sprache: Deutsch' : 'Language: English' }
290 }
291
292 const flag = ({ anim: 'animation', animation: 'animation', sound: 'sound', ton: 'sound', toasts: 'toasts', face: 'face', gesicht: 'face' } as Record<string, Flag>)[cmd]
293 if (flag) {
294 const value = arg === 'on' || arg === 'an' ? true : arg === 'off' || arg === 'aus' ? false : !prefs[flag]
295 await save({ ...prefs, [flag]: value })
296 return { text: `${flag} ${onOff(value)}` }
297 }
298
299 if (cmd === 'demo') {
300 const list = await read($, limitsA)
301 const five = pick(list, 'five_hour')?.percentUsed ?? 42
302 await update($, motionA, m => ({ ...m, five_hour: { from: 0, to: five, at: now }, seven_day: { from: 0, to: pick(list, 'seven_day')?.percentUsed ?? 0, at: now + PARTY_MS } }))
303 await update($, deltasA, d => ({ ...d, five_hour: { value: 7, at: now } }))
304 await update($, partyA, p => ({ ...p, seven_day: now }))
305 if (prefs.sound) void $.audio.play({ asset: 'sounds/chime.wav' }).catch(() => undefined)
306 if (list.length === 0) $.ui.toast(T[prefs.lang].noData)
307 animate($, now, PARTY_MS + SLIDE_MS + DELTA_MS)
308 return { text: tr('🎬 Demo running (under the prompt)', '🎬 Demo läuft (unter dem Prompt)') }
309 }
310
311 if (cmd === 'stats') {
312 const list = await read($, limitsA)
313 const history = await read($, historyA)
314 const record = ((await $.store.get('record')) ?? null) as { p: number; t: number } | null
315 const L = T[prefs.lang]
316 const lines = [tr('Stats', 'Statistik')]
317 for (const w of WINDOWS) {
318 const l = pick(list, w.kind)
319 if (!l) {
320 lines.push(` ${w.label} ${tr('no reading', 'keine Messung')}`)
321 continue
322 }
323 const r = rate(history[w.kind], w.kind, l.resetsAt, l.percentUsed, now)
324 const dry = runsDryAt(r, l.resetsAt)
325 const at = l.resetsAt ? Date.parse(l.resetsAt) : NaN
326 lines.push(
327 ` ${w.label} ${Math.round(l.percentUsed)} % ${tr('used', 'verbraucht')}` +
328 (Number.isNaN(at) ? '' : ` · ${L.resetAt(clockTime(at, now, prefs.lang))} (${tr('in', 'in')} ${untilReset(l.resetsAt, now, prefs.lang)})`) +
329 (r ? ` · ${num1(r.perHour, prefs.lang)} %/h` : '') +
330 (dry ? ` · ${L.dry(clockTime(dry, now, prefs.lang))}` : r ? ` · ${tr('lasts until the reset', 'reicht bis zum Reset')}` : ''),
331 )
332 }
333 lines.push(` ${tr('5h peaks, 24h', '5h-Spitzen 24h')} ${spark(peaks(history.five_hour, now, 3600_000, 24))}`)
334 lines.push(` ${tr('5h peaks, 7d ', '5h-Spitzen 7T ')} ${spark(peaks(history.five_hour, now, 86400_000, 7))}`)
335 if (record) {
336 const d = new Date(record.t)
337 lines.push(` ${tr('Record 5h', 'Rekord 5h')}: ${Math.round(record.p)} % · ${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
338 }
339 lines.push(` ${tr('style', 'Stil')} ${prefs.style} · ${tr('mode', 'Modus')} ${prefs.mode} · animation ${onOff(prefs.animation)} · sound ${onOff(prefs.sound)}`)
340 return { text: lines.join('\n') }
341 }
342
343 return {
344 text: [
345 tr('Commands:', 'Befehle:'),
346 ` /usage-bars stats ${tr('rate, projection, history, record', 'Rate, Hochrechnung, Verlauf, Rekord')}`,
347 ` /usage-bars demo ${tr('play the animation', 'Animation vorführen')}`,
348 ` /usage-bars full|compact|off ${tr('display', 'Darstellung')}`,
349 ` /usage-bars style <name> ${STYLES.join(' · ')}`,
350 ` /usage-bars lang en|de ${tr('language', 'Sprache')}`,
351 ' /usage-bars anim|sound|toasts|face [on|off]',
352 `${tr('Now', 'Aktuell')}: ${prefs.mode}, ${prefs.style}, ${prefs.lang}, animation ${onOff(prefs.animation)}, sound ${onOff(prefs.sound)}, toasts ${onOff(prefs.toasts)}, face ${onOff(prefs.face)}`,
353 ].join('\n'),
354 }
355 })
356
357 // Claude Code completes the command's name, not its arguments, so the line
358 // under the prompt lists what may follow while the draft is `/usage-bars …`.
359 // Only such drafts redraw: typing a normal prompt costs nothing.
360 on('prompt.edit', async ($, e, next) => {
361 const r = await next(e)
362 redrawIfOurs($, r.text)
363 return r
364 })
365 on('prompt.submit', async ($, e, next) => {
366 const r = await next(e)
367 redrawIfOurs($, '')
368 return r
369 })
370
371 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
372 const prefs = await read($, prefsA)
373 // A surface without a prompt box (or a read that fails) just shows the bars.
374 const draft = (await $.prompt.read().catch(() => undefined))?.text ?? ''
375 const options = complete(draft, prefs.lang)
376 if (options) {
377 const { Box, Text } = $.ui.resolve(e)
378 const one = options.length === 1 ? options[0] : undefined
379 return (
380 <Box flexDirection="column">
381 {await next(e)}
382 <Box key="usage-bars-complete" flexDirection="row">
383 <Text dimColor>{'⌨ '}</Text>
384 {options.map((o, i) => (
385 <Text key={o.word} wrap="truncate-end">
386 {i > 0 ? <Text dimColor>{' · '}</Text> : null}
387 <Text bold color="#79c0ff">{o.word.slice(0, o.typed)}</Text>
388 <Text color="#e6edf3">{o.word.slice(o.typed)}</Text>
389 </Text>
390 ))}
391 {one ? <Text dimColor wrap="truncate-end">{` — ${one.hint}`}</Text> : null}
392 </Box>
393 </Box>
394 )
395 }
396 if (prefs.mode === 'off') return next(e)
397 const list = await read($, limitsA)
398 const shown = WINDOWS.filter(w => pick(list, w.kind))
399 if (shown.length === 0) return next(e)
400
401 const { Box, Text } = $.ui.resolve(e)
402 const now = await $.clock.now()
403 const columns = e.viewport?.columns ?? 100
404 const compact = prefs.mode === 'compact' || columns < COMPACT_BELOW
405 const width = barWidth(columns)
406 const motion = await read($, motionA)
407 const deltas = await read($, deltasA)
408 const party = await read($, partyA)
409 const history = await read($, historyA)
410 const segsOf = (kind: string): Seg[] =>
411 rowSegs({
412 limit: pick(list, kind)!,
413 prefs,
414 now,
415 width,
416 compact,
417 motion: motion[kind],
418 delta: deltas[kind],
419 partyAt: party[kind],
420 history,
421 })
422 const detail = detailText(shown.map(w => pick(list, w.kind)!), history, now, prefs.lang)
423
424 const text = (s: Seg, k: string) => (
425 <Text key={k} color={s.color} backgroundColor={s.bg} dimColor={s.dim} bold={s.bold} wrap="truncate-end">
426 {s.text}
427 </Text>
428 )
429
430 return (
431 <Box flexDirection="column">
432 {await next(e)}
433 <Box key="usage-bars" flexDirection="row">
434 {shown.map((w, i) => (
435 <Box key={w.kind} flexDirection="row" marginRight={i < shown.length - 1 ? 3 : 0}>
436 {segsOf(w.kind).map((s, j) => text(s, `${w.kind}-${j}`))}
437 </Box>
438 ))}
439 {compact ? null : (
440 <Box position="absolute" top={0} left={0} right={0} display="none" hover={{ display: 'flex' }} backgroundColor="#30363d">
441 <Text color="#e6edf3" wrap="truncate-end">
442 {detail}
443 </Text>
444 </Box>
445 )}
446 </Box>
447 </Box>
448 )
449 })
450}
451hooks/format.ts 187 lines1import type { History, Lang, Limit, Motion, Sample } from '../types'
2import { T } from './i18n'
3
4export const WINDOWS = [
5 { kind: 'five_hour', label: '5h', lengthMs: 5 * 3600_000 },
6 { kind: 'seven_day', label: '7d', lengthMs: 7 * 86400_000 },
7] as const
8
9export const SLIDE_MS = 700
10export const DELTA_MS = 4000
11export const PARTY_MS = 2600
12export const THRESHOLDS = [50, 80, 90, 100] as const
13
14export const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
15
16/** Filled cells of a bar `width` wide for `percent` (0–100, clamped). */
17export function filled(percent: number, width: number): number {
18 return Math.round((clamp(percent, 0, 100) / 100) * width)
19}
20
21/** green below 50 %, yellow below 80 %, red from there. */
22export function tone(percent: number): string {
23 if (percent >= 80) return 'red'
24 if (percent >= 50) return 'yellow'
25 return 'green'
26}
27
28/** easeOutCubic */
29export const ease = (t: number) => 1 - (1 - clamp(t, 0, 1)) ** 3
30
31/** Where a gliding bar stands at `now`; `to` once the glide is over. */
32export function glide(m: Motion | undefined, now: number): number | undefined {
33 if (!m) return undefined
34 const t = (now - m.at) / SLIDE_MS
35 if (t >= 1) return m.to
36 return m.from + (m.to - m.from) * ease(t)
37}
38
39const pad2 = (n: number) => String(n).padStart(2, '0')
40
41/** Time until reset: `12m41s` under an hour, `2h13m`, `3d 4h`. */
42export function untilReset(resetsAt: string | undefined, nowMs: number, lang: Lang = 'en'): string {
43 if (!resetsAt) return ''
44 const at = Date.parse(resetsAt)
45 if (Number.isNaN(at)) return ''
46 const sec = Math.max(0, Math.ceil((at - nowMs) / 1000))
47 if (sec < 3600) return `${Math.floor(sec / 60)}m${pad2(sec % 60)}s`
48 const min = Math.round(sec / 60)
49 const h = Math.floor(min / 60)
50 if (h < 24) return `${h}h${pad2(min % 60)}m`
51 return `${Math.floor(h / 24)}${T[lang].day} ${h % 24}h`
52}
53
54/** A wall-clock time: `17:30` today, `Mon 09:00` further out. */
55export function clockTime(ms: number, nowMs: number, lang: Lang = 'en'): string {
56 const d = new Date(ms)
57 const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
58 if (ms - nowMs < 20 * 3600_000 && new Date(nowMs).getDate() === d.getDate()) return hm
59 const wd = T[lang].weekdays[d.getDay()]
60 return `${wd} ${hm}`
61}
62
63export const percentText = (p: number) => `${Math.round(p)}%`.padStart(4)
64
65/** Bar width so both segments fit the line: 6 to 24 cells. */
66export function barWidth(columns: number): number {
67 // per segment: label 3 + bar + "100%" 5 + " ↻ 12m41s" 9 + face 3 + gap 3
68 const fixed = 3 + 5 + 9 + 3 + 3
69 return clamp(Math.floor(columns / 2) - fixed, 6, 24)
70}
71
72export const COMPACT_BELOW = 72
73
74export function pick(limits: readonly Limit[], kind: string): Limit | undefined {
75 return limits.find(l => l.kind === kind)
76}
77
78export function windowLength(kind: string): number | undefined {
79 return WINDOWS.find(w => w.kind === kind)?.lengthMs
80}
81
82/** How far into its window we are, 0..1; undefined without a reset time. */
83export function elapsed(kind: string, resetsAt: string | undefined, now: number): number | undefined {
84 const len = windowLength(kind)
85 const at = resetsAt ? Date.parse(resetsAt) : NaN
86 if (!len || Number.isNaN(at)) return undefined
87 return clamp(1 - (at - now) / len, 0, 1)
88}
89
90/** Used share against the even pace: 1 = right on, >1 = too fast. */
91export function paceRatio(percent: number, frac: number | undefined): number | undefined {
92 if (frac === undefined || frac < 0.03) return undefined
93 return percent / (frac * 100)
94}
95
96export function face(percent: number, ratio: number | undefined): string {
97 if (percent >= 100) return '💀'
98 if (percent >= 90) return '🥵'
99 if (ratio === undefined) return '🙂'
100 if (ratio < 0.8) return '😎'
101 if (ratio < 1.15) return '🙂'
102 return '😬'
103}
104
105export type Rate = { perHour: number; emptyAt?: number }
106
107/**
108 * Burn rate from this window's samples, and when it runs dry at that rate.
109 * Needs 10 minutes of samples inside the current window and a rising value.
110 */
111export function rate(samples: readonly Sample[] | undefined, kind: string, resetsAt: string | undefined, percent: number, now: number): Rate | undefined {
112 const len = windowLength(kind)
113 const at = resetsAt ? Date.parse(resetsAt) : NaN
114 if (!samples?.length || !len || Number.isNaN(at)) return undefined
115 const start = at - len
116 const first = samples.find(([t]) => t >= start)
117 if (!first) return undefined
118 const span = now - first[0]
119 const rise = percent - first[1]
120 if (span < 10 * 60_000 || rise <= 0) return undefined
121 const perMs = rise / span
122 return { perHour: perMs * 3600_000, emptyAt: percent >= 100 ? now : now + (100 - percent) / perMs }
123}
124
125/** Runs dry before the window resets? Then when. */
126export function runsDryAt(r: Rate | undefined, resetsAt: string | undefined): number | undefined {
127 const at = resetsAt ? Date.parse(resetsAt) : NaN
128 if (!r?.emptyAt || Number.isNaN(at)) return undefined
129 return r.emptyAt < at ? r.emptyAt : undefined
130}
131
132export function appendSample(h: History, kind: string, s: Sample, now: number): History {
133 const keepFrom = now - 8 * 86400_000
134 const list = (h[kind] ?? []).filter(([t]) => t >= keepFrom)
135 const last = list[list.length - 1]
136 if (last && last[1] === s[1] && s[0] - last[0] < 60_000) return h
137 return { ...h, [kind]: [...list, s].slice(-3000) }
138}
139
140/** How long a reading may stand without a sample before the ticker records it again. */
141export const HEARTBEAT_MS = 10 * 60_000
142
143/**
144 * A sample for every window whose last one is `every` old or more.
145 *
146 * Readings only arrive when a window moves, so an hour at a steady value
147 * left no sample and its sparkline bucket read `·`, as if nothing was known.
148 * Returns `h` itself when nothing was due.
149 */
150export function heartbeat(h: History, limits: readonly { kind: string; percentUsed: number }[], now: number, every = HEARTBEAT_MS): History {
151 let out = h
152 for (const l of limits) {
153 const last = out[l.kind]?.at(-1)
154 if (!last || now - last[0] >= every) out = appendSample(out, l.kind, [now, l.percentUsed], now)
155 }
156 return out
157}
158
159const SPARK = '▁▂▃▄▅▆▇█'
160export function spark(values: readonly (number | undefined)[]): string {
161 return values
162 .map(v => (v === undefined ? '·' : SPARK[clamp(Math.round((v / 100) * 7), 0, 7)]))
163 .join('')
164}
165
166/** Peak per bucket over the last `count` buckets of `bucketMs`, oldest first. */
167export function peaks(samples: readonly Sample[] | undefined, now: number, bucketMs: number, count: number): (number | undefined)[] {
168 const out: (number | undefined)[] = Array.from({ length: count }, () => undefined)
169 for (const [t, p] of samples ?? []) {
170 const i = count - 1 - Math.floor((now - t) / bucketMs)
171 if (i >= 0 && i < count) out[i] = Math.max(out[i] ?? 0, p)
172 }
173 return out
174}
175
176/** The highest threshold `percent` has reached, or 0. */
177export function reached(percent: number): number {
178 return [...THRESHOLDS].reverse().find(t => percent >= t) ?? 0
179}
180
181export function quip(seed: string | undefined, lang: Lang = 'en'): string {
182 let h = 0
183 for (const c of seed ?? '') h = (h * 31 + c.charCodeAt(0)) >>> 0
184 const q = T[lang].quips
185 return q[h % q.length]!
186}
187hooks/styles.ts 171 lines1import type { Style } from '../types'
2import { clamp } from './format'
3
4export type Seg = { text: string; color?: string; bg?: string; dim?: boolean; bold?: boolean }
5
6export const STYLES: readonly Style[] = ['bars', 'pacman', 'beer', 'tank', 'battery', 'hourglass']
7
8export const GREEN = '#3fb950'
9export const YELLOW = '#d29922'
10export const RED = '#f85149'
11const MARK = '#8b949e'
12const TICK = '#ffffff'
13const TRACK = '#30363d'
14/** The pace mark drawn through a filled cell: a thin line on the fill colour. */
15const tick = (fill: string): Seg => ({ text: '│', color: TICK, bg: fill })
16
17const hex = (n: number) => Math.round(n).toString(16).padStart(2, '0')
18function mix(a: string, b: string, t: number): string {
19 const pa = [1, 3, 5].map(i => parseInt(a.slice(i, i + 2), 16))
20 const pb = [1, 3, 5].map(i => parseInt(b.slice(i, i + 2), 16))
21 return '#' + pa.map((v, i) => hex(v + (pb[i]! - v) * t)).join('')
22}
23
24/** green → yellow → red along 0..1 */
25export function gradient(pos: number): string {
26 const t = clamp(pos, 0, 1)
27 return t < 0.6 ? mix(GREEN, YELLOW, t / 0.6) : mix(YELLOW, RED, (t - 0.6) / 0.4)
28}
29
30/** Joins neighbours that look alike, so a bar is a handful of Texts. */
31export function merge(segs: readonly Seg[]): Seg[] {
32 const out: Seg[] = []
33 for (const s of segs) {
34 const last = out[out.length - 1]
35 if (last && last.color === s.color && last.bg === s.bg && last.dim === s.dim && last.bold === s.bold) {
36 out[out.length - 1] = { ...last, text: last.text + s.text }
37 } else out.push({ ...s })
38 }
39 return out
40}
41
42export type StyleCtx = {
43 /** Even-pace position, 0..1 of the window gone; undefined hides the mark. */
44 pace?: number
45 /** True on alternating frames while the bar moves (Pac-Man's mouth). */
46 chomp: boolean
47 /** Changes every second: idle wiggle. */
48 second: number
49}
50
51const EIGHTHS = ' ▏▎▍▌▋▊▉'
52
53/** Cells (fractional) a value covers. */
54const cover = (p: number, width: number) => (clamp(p, 0, 100) / 100) * width
55const markCell = (pace: number | undefined, width: number) =>
56 pace === undefined ? -1 : clamp(Math.round(pace * width - 0.5), 0, width - 1)
57
58function bars(p: number, width: number, ctx: StyleCtx): Seg[] {
59 const c = cover(p, width)
60 const full = Math.floor(c)
61 const part = Math.floor((c - full) * 8)
62 const mark = markCell(ctx.pace, width)
63 const segs: Seg[] = []
64 for (let i = 0; i < width; i++) {
65 const col = gradient(width > 1 ? i / (width - 1) : 0)
66 if (i < full) segs.push(i === mark ? tick(col) : { text: '█', color: col })
67 else if (i === full && part > 0) segs.push({ text: EIGHTHS[part]!, color: col })
68 else if (i === mark) segs.push({ text: '│', color: MARK })
69 else segs.push({ text: '░', dim: true })
70 }
71 return merge(segs)
72}
73
74function pacman(p: number, width: number, ctx: StyleCtx): Seg[] {
75 const pos = clamp(Math.floor(cover(p, width)), 0, width - 1)
76 const mark = markCell(ctx.pace, width)
77 const segs: Seg[] = []
78 for (let i = 0; i < width; i++) {
79 // from 90 % a ghost chases Pac-Man two cells behind
80 if (p >= 90 && i === pos - 2) segs.push({ text: 'ᗣ', color: RED, bold: true })
81 else if (i < pos) segs.push({ text: '─', color: TRACK })
82 else if (i === pos) segs.push({ text: ctx.chomp ? '●' : 'ᗧ', color: '#f2cc60', bold: true })
83 else if (i === mark) segs.push({ text: '┊', color: MARK })
84 else segs.push({ text: (i - pos) % 4 === 0 ? '•' : '·', color: (i - pos) % 4 === 0 ? '#f0b6a8' : undefined, dim: (i - pos) % 4 !== 0 })
85 }
86 return merge(segs)
87}
88
89/** Beer, battery, tank and hourglass show what is LEFT. */
90function beer(p: number, width: number, ctx: StyleCtx): Seg[] {
91 const left = Math.round(cover(100 - p, width))
92 const mark = markCell(ctx.pace === undefined ? undefined : 1 - ctx.pace, width)
93 const segs: Seg[] = [{ text: '🍺' }, { text: '▕', dim: true }]
94 for (let i = 0; i < width; i++) {
95 if (i < left - 1) segs.push(i === mark ? tick('#e3a008') : { text: '█', color: '#e3a008' })
96 else if (i === left - 1) segs.push({ text: '▓', color: '#f5f0e1' })
97 else if (i === mark) segs.push({ text: '│', color: MARK })
98 else segs.push({ text: '░', dim: true })
99 }
100 segs.push({ text: '▏', dim: true })
101 return merge(segs)
102}
103
104function battery(p: number, width: number, ctx: StyleCtx): Seg[] {
105 const left = 100 - clamp(p, 0, 100)
106 const n = Math.round(cover(left, width))
107 const col = left <= 20 ? RED : left <= 50 ? YELLOW : GREEN
108 const mark = markCell(ctx.pace === undefined ? undefined : 1 - ctx.pace, width)
109 const segs: Seg[] = [{ text: '▕', dim: true }]
110 for (let i = 0; i < width; i++) {
111 if (i < n) segs.push(i === mark ? tick(col) : { text: '█', color: col })
112 else if (i === mark) segs.push({ text: '│', color: MARK })
113 else segs.push({ text: '░', dim: true })
114 }
115 segs.push({ text: '▏', dim: true }, { text: '▌', dim: true })
116 if (left <= 10 && ctx.second % 2 === 0) segs.push({ text: '⚡', color: YELLOW })
117 return merge(segs)
118}
119
120function tank(p: number, width: number, ctx: StyleCtx): Seg[] {
121 const cells = Math.max(4, Math.floor(width / 2))
122 const left = 100 - clamp(p, 0, 100)
123 const n = Math.round((left / 100) * cells)
124 const col = left <= 20 ? RED : left <= 50 ? YELLOW : GREEN
125 const mark = markCell(ctx.pace === undefined ? undefined : 1 - ctx.pace, cells)
126 const segs: Seg[] = [{ text: '⛽E ', dim: true }]
127 for (let i = 0; i < cells; i++) {
128 const glyph = i < n ? '▮' : '▯'
129 segs.push(i === mark ? { text: glyph, color: TICK, bold: true } : i < n ? { text: glyph, color: col } : { text: glyph, dim: true })
130 }
131 segs.push({ text: ' F', dim: true })
132 return merge(segs)
133}
134
135function hourglass(p: number, width: number, ctx: StyleCtx): Seg[] {
136 const left = Math.round(cover(100 - p, width))
137 const mark = markCell(ctx.pace === undefined ? undefined : 1 - ctx.pace, width)
138 const segs: Seg[] = [{ text: ctx.second % 2 === 0 ? '⏳' : '⌛' }]
139 for (let i = 0; i < width; i++) {
140 if (i === mark) segs.push({ text: '│', color: MARK })
141 else if (i < left) segs.push({ text: '⣿', color: '#d4a373' })
142 else segs.push({ text: '⣀', dim: true })
143 }
144 return merge(segs)
145}
146
147const DRAW: Record<Style, (p: number, w: number, c: StyleCtx) => Seg[]> = {
148 bars,
149 pacman,
150 beer,
151 tank,
152 battery,
153 hourglass,
154}
155
156export function drawStyle(style: Style, p: number, width: number, ctx: StyleCtx): Seg[] {
157 return (DRAW[style] ?? bars)(p, width, ctx)
158}
159
160/** A short sparkle line for the reset party, moving with `frame`. */
161export function sparkle(width: number, frame: number): Seg[] {
162 const glyphs = ['✦', '✧', '·', '⋆', '✶', ' ']
163 const colors = ['#f2cc60', '#79c0ff', '#d2a8ff', '#7ee787', '#ff7b72']
164 const segs: Seg[] = []
165 for (let i = 0; i < width; i++) {
166 const k = (i * 7 + frame * 3) % 11
167 segs.push(k < glyphs.length ? { text: glyphs[k]!, color: colors[(i + frame) % colors.length] } : { text: ' ' })
168 }
169 return merge(segs)
170}
171hooks/row.ts 113 lines1import type { Delta, History, Lang, Limit, Motion, Prefs, Sample } from '../types'
2import {
3 DELTA_MS,
4 PARTY_MS,
5 SLIDE_MS,
6 WINDOWS,
7 clockTime,
8 elapsed,
9 face,
10 glide,
11 paceRatio,
12 peaks,
13 percentText,
14 quip,
15 rate,
16 runsDryAt,
17 spark,
18 tone,
19 untilReset,
20} from './format'
21import { T, num1 } from './i18n'
22import { GREEN, RED, YELLOW, drawStyle, sparkle } from './styles'
23import type { Seg } from './styles'
24
25export type RowInput = {
26 limit: Limit
27 prefs: Prefs
28 now: number
29 width: number
30 compact: boolean
31 motion?: Motion
32 delta?: Delta
33 partyAt?: number
34 history?: History
35}
36
37export const label = (kind: string) => WINDOWS.find(w => w.kind === kind)?.label ?? kind
38
39/**
40 * One window's segment of the line under the prompt, as plain styled text:
41 * `5h ██████▌░░│░░ 42% +3% 😎 ↻ 2h13m ⚠ empty ~16:40`.
42 */
43export function rowSegs(i: RowInput): Seg[] {
44 const { limit: l, prefs, now, width, compact } = i
45 const lang = prefs.lang
46 const kind = l.kind
47 const real = l.percentUsed
48 const m = i.motion
49 const isMoving = prefs.animation && !!m && now - m.at < SLIDE_MS && now >= m.at
50 const p = prefs.animation ? (m && now < m.at ? m.from : glide(m, now) ?? real) : real
51 const frac = elapsed(kind, l.resetsAt, now)
52 const until = untilReset(l.resetsAt, now, lang)
53 const dry = runsDryAt(rate(i.history?.[kind], kind, l.resetsAt, real, now), l.resetsAt)
54 const partyAge = now - (i.partyAt ?? -Infinity)
55 const isParty = prefs.animation && partyAge >= 0 && partyAge < PARTY_MS
56 const d = i.delta
57 const dAge = d ? now - d.at : Infinity
58 const frame = Math.floor(now / 90)
59
60 const out: Seg[] = [{ text: `${label(kind)} `, dim: true }]
61 if (isParty) {
62 out.push(...sparkle(compact ? 6 : width, frame), { text: ` ${T[lang].refuelled}`, color: GREEN, bold: true })
63 return out
64 }
65 if (real >= 100 && !compact) {
66 out.push({ text: quip(l.resetsAt, lang), color: YELLOW, bold: true }, { text: until ? T[lang].backIn(until) : '', dim: true })
67 return out
68 }
69 if (!compact) {
70 out.push(...drawStyle(prefs.style, p, width, { pace: frac, chomp: isMoving && frame % 2 === 0, second: Math.floor(now / 1000) }))
71 out.push({ text: ' ' })
72 }
73 const t = tone(real)
74 out.push({ text: compact ? percentText(p).trimStart() : percentText(p), color: t === 'red' ? RED : t === 'yellow' ? YELLOW : GREEN })
75 if (d && dAge < DELTA_MS) {
76 const delta = ` +${Math.round(d.value)}%`
77 out.push(
78 !prefs.animation || dAge < 1200
79 ? { text: delta, color: YELLOW, bold: true }
80 : dAge < 2500
81 ? { text: delta, color: YELLOW }
82 : { text: delta, dim: true },
83 )
84 }
85 if (prefs.face) out.push({ text: ` ${face(real, paceRatio(real, frac))}` })
86 if (until) out.push({ text: compact ? ` ↻${until}` : ` ↻ ${until}`, dim: true })
87 if (dry) out.push(compact ? { text: ' ⚠', color: RED } : { text: ` ${T[lang].dry(clockTime(dry, now, lang))}`, color: RED, bold: true })
88 return out
89}
90
91/**
92 * The hover card: reset time, burn rate and a sparkline per window.
93 *
94 * Each window's sparkline shows that window's own level (the 5h one per hour
95 * over 24 hours, the 7d one per day over 7 days), with the current reading as
96 * the newest sample: it is known even when no sample was taken this hour.
97 */
98export function detailText(limits: readonly Limit[], history: History, now: number, lang: Lang = 'en'): string {
99 return limits
100 .map(l => {
101 const r = rate(history[l.kind], l.kind, l.resetsAt, l.percentUsed, now)
102 const at = l.resetsAt ? Date.parse(l.resetsAt) : NaN
103 const own = [...(history[l.kind] ?? []), [now, l.percentUsed] as Sample]
104 const sp =
105 l.kind === 'five_hour'
106 ? `24h ${spark(peaks(own, now, 3600_000, 24))}`
107 : `7${T[lang].day} ${spark(peaks(own, now, 86400_000, 7))}`
108 const reset = Number.isNaN(at) ? T[lang].resetOpen : T[lang].resetAt(clockTime(at, now, lang))
109 return `${label(l.kind)}: ${reset}${r ? ` · ${num1(r.perHour, lang)} %/h` : ''} · ${sp}`
110 })
111 .join(' ')
112}
113hooks/i18n.ts 68 lines1import type { Lang } from '../types'
2
3export const LANGS: readonly Lang[] = ['en', 'de']
4
5type Strings = {
6 day: string
7 weekdays: readonly string[]
8 dec: string
9 quips: readonly string[]
10 dry: (t: string) => string
11 refuelled: string
12 backIn: (u: string) => string
13 resetAt: (t: string) => string
14 resetOpen: string
15 toastAt: (label: string, p: number, until: string) => string
16 toastFull: (quip: string, label: string, until: string) => string
17 noData: string
18}
19
20export const T: Record<Lang, Strings> = {
21 en: {
22 day: 'd',
23 weekdays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
24 dec: '.',
25 quips: [
26 '☕ Coffee break',
27 '🧘 Breathe',
28 '🚶 Go touch grass',
29 '📖 Read the docs instead',
30 '🍕 Pizza time',
31 '😴 Power nap?',
32 '🦆 Ask the rubber duck',
33 ],
34 dry: t => `⚠ empty ~${t}`,
35 refuelled: 'refuelled ✨',
36 backIn: u => ` – back in ${u}`,
37 resetAt: t => `resets ${t}`,
38 resetOpen: 'no reset yet',
39 toastAt: (l, p, u) => `${l} limit at ${p} %${u ? ` · resets in ${u}` : ''}`,
40 toastFull: (q, l, u) => `${q} – ${l} limit used up${u ? `, back in ${u}` : ''}`,
41 noData: 'No readings yet – the bars appear after the first answer.',
42 },
43 de: {
44 day: 'T',
45 weekdays: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'],
46 dec: ',',
47 quips: [
48 '☕ Kaffeepause',
49 '🧘 Durchatmen',
50 '🚶 Kurz an die frische Luft',
51 '📖 Doku lesen statt prompten',
52 '🍕 Pizza-Zeit',
53 '😴 Powernap?',
54 '🦆 Zeit für die Quietscheente',
55 ],
56 dry: t => `⚠ leer ~${t}`,
57 refuelled: 'frisch aufgetankt ✨',
58 backIn: u => ` – weiter in ${u}`,
59 resetAt: t => `Reset ${t}`,
60 resetOpen: 'Reset offen',
61 toastAt: (l, p, u) => `${l}-Kontingent bei ${p} %${u ? ` · Reset in ${u}` : ''}`,
62 toastFull: (q, l, u) => `${q} – ${l}-Kontingent aufgebraucht${u ? `, weiter in ${u}` : ''}`,
63 noData: 'Noch keine Messwerte – die Balken erscheinen nach der ersten Antwort.',
64 },
65}
66
67export const num1 = (n: number, lang: Lang) => n.toFixed(1).replace('.', T[lang].dec)
68hooks/complete.ts 76 lines1/**
2 * What `/usage-bars …` can continue with, for the line under the prompt.
3 *
4 * Claude Code completes a slash command's name but not its arguments, so the
5 * mod shows the candidates itself while the person types. Pure: the draft in,
6 * the candidates out.
7 */
8import type { Lang } from '../types'
9import { STYLES } from './styles'
10import { LANGS } from './i18n'
11
12export const COMMAND = '/usage-bars'
13
14/** One candidate: the word, how much of it is already typed, what it does. */
15export type Candidate = { word: string; typed: number; hint: string }
16
17type Entry = { word: string; en: string; de: string }
18
19const SUBS: readonly Entry[] = [
20 { word: 'stats', en: 'rate, projection, history, record', de: 'Rate, Hochrechnung, Verlauf, Rekord' },
21 { word: 'demo', en: 'play the animation', de: 'Animation vorführen' },
22 { word: 'full', en: 'show the bars', de: 'Balken anzeigen' },
23 { word: 'compact', en: 'numbers only', de: 'nur Zahlen' },
24 { word: 'off', en: 'hide the line', de: 'Zeile ausblenden' },
25 { word: 'style', en: STYLES.join(' · '), de: STYLES.join(' · ') },
26 { word: 'lang', en: 'en · de', de: 'en · de' },
27 { word: 'anim', en: 'animation on/off', de: 'Animation an/aus' },
28 { word: 'sound', en: 'chime on/off', de: 'Ton an/aus' },
29 { word: 'toasts', en: 'toasts on/off', de: 'Hinweise an/aus' },
30 { word: 'face', en: 'emoji on/off', de: 'Emoji an/aus' },
31]
32
33const ON_OFF: readonly Entry[] = [
34 { word: 'on', en: 'switch on', de: 'einschalten' },
35 { word: 'off', en: 'switch off', de: 'ausschalten' },
36]
37
38const SECOND: Record<string, readonly Entry[]> = {
39 style: STYLES.map(s => ({ word: s, en: 'style', de: 'Stil' })),
40 lang: LANGS.map(l => ({ word: l, en: l === 'en' ? 'English' : 'German', de: l === 'en' ? 'Englisch' : 'Deutsch' })),
41 anim: ON_OFF,
42 sound: ON_OFF,
43 toasts: ON_OFF,
44 face: ON_OFF,
45}
46
47/** Whether a draft is about this command at all (cheap; checked on every key). */
48export const isOurs = (draft: string): boolean => draft.trimStart().startsWith(COMMAND)
49
50/**
51 * The candidates for `draft`, or `null` when there is nothing to offer: the
52 * draft is not `/usage-bars ` plus arguments, the argument has no follow-up,
53 * or nothing matches what is typed.
54 */
55export function complete(draft: string, lang: Lang): Candidate[] | null {
56 const text = draft.trimStart()
57 if (!text.startsWith(`${COMMAND} `)) return null
58 const rest = text.slice(COMMAND.length + 1).toLowerCase()
59 const words = rest.split(/\s+/).filter(Boolean)
60 const done = rest === '' || /\s$/.test(rest) ? words : words.slice(0, -1)
61 const current = rest === '' || /\s$/.test(rest) ? '' : words[words.length - 1]!
62
63 let pool: readonly Entry[] | undefined
64 if (done.length === 0) {
65 // Styles also work bare (`/usage-bars pacman`); offer them once something is typed.
66 pool = current ? [...SUBS, ...STYLES.filter(s => !SUBS.some(e => e.word === s)).map(s => ({ word: s, en: 'style', de: 'Stil' }))] : SUBS
67 } else if (done.length === 1) {
68 pool = SECOND[done[0]!]
69 }
70 if (!pool) return null
71
72 const hits = pool.filter(e => e.word.startsWith(current))
73 if (hits.length === 0) return null
74 return hits.map(e => ({ word: e.word, typed: current.length, hint: lang === 'de' ? e.de : e.en }))
75}
76hooks/share.ts 91 lines1import type { History, Limit, Sample } from '../types'
2
3/**
4 * Readings shared between the sessions of one account.
5 *
6 * Each Claude Code session only learns about the limits from its own API
7 * responses. A session that sits idle keeps showing what it last heard, and
8 * when its window's reset time passes it zeroes the bar, while another
9 * session has long started the next window. So every session publishes what
10 * it hears to the store (one file per plugin, read from disk on every get),
11 * and every session folds in what the others published.
12 *
13 * The merge has to tolerate stale input: an idle session can come back with
14 * an old reading. It never needs to know which reading is newer, because a
15 * window only fills up until it resets:
16 * - a reading whose reset time has passed is that window ended: 0, no reset
17 * - a later reset time is a later window, and wins
18 * - the same window (reset times within an hour) keeps the higher percent
19 */
20
21/** Reset times this close name the same window (the API rounds them). */
22export const SAME_WINDOW_MS = 3600_000
23
24/** A reading as it stands at `now`: an ended window reads 0. */
25export function settle(l: Limit, now: number): Limit {
26 const at = l.resetsAt ? Date.parse(l.resetsAt) : NaN
27 if (!Number.isNaN(at) && at <= now) return { kind: l.kind, percentUsed: 0 }
28 return l
29}
30
31/** The truer of two readings of one window kind. On a tie `b` wins. */
32export function mergeOne(a: Limit | undefined, b: Limit | undefined, now: number): Limit | undefined {
33 if (!a) return b && settle(b, now)
34 if (!b) return settle(a, now)
35 const x = settle(a, now)
36 const y = settle(b, now)
37 const tx = x.resetsAt ? Date.parse(x.resetsAt) : NaN
38 const ty = y.resetsAt ? Date.parse(y.resetsAt) : NaN
39 if (Number.isNaN(tx) && Number.isNaN(ty)) return y
40 // A live window beats an ended (or unknown) one.
41 if (Number.isNaN(tx)) return y
42 if (Number.isNaN(ty)) return x
43 if (Math.abs(tx - ty) >= SAME_WINDOW_MS) return tx > ty ? x : y
44 const resetsAt = tx > ty ? x.resetsAt : y.resetsAt
45 return { kind: y.kind, percentUsed: Math.max(x.percentUsed, y.percentUsed), resetsAt }
46}
47
48/** Both lists merged per kind, in the order the kinds first appear. */
49export function mergeLimits(a: readonly Limit[], b: readonly Limit[], now: number): Limit[] {
50 const kinds = [...new Set([...a, ...b].map(l => l.kind))]
51 const find = (list: readonly Limit[], kind: string) => list.find(l => l.kind === kind)
52 return kinds.map(k => mergeOne(find(a, k), find(b, k), now)!).map(({ kind, percentUsed, resetsAt }) =>
53 resetsAt === undefined ? { kind, percentUsed } : { kind, percentUsed, resetsAt },
54 )
55}
56
57export function sameLimits(a: readonly Limit[], b: readonly Limit[]): boolean {
58 return (
59 a.length === b.length &&
60 a.every(x => {
61 const y = b.find(l => l.kind === x.kind)
62 return !!y && y.percentUsed === x.percentUsed && (y.resetsAt ?? '') === (x.resetsAt ?? '')
63 })
64 )
65}
66
67/** Samples of both histories, by time, a sample per moment, the last 8 days. */
68export function mergeHistory(a: History, b: History, now: number): History {
69 const keepFrom = now - 8 * 86400_000
70 const out: History = {}
71 for (const kind of new Set([...Object.keys(a), ...Object.keys(b)])) {
72 const byTime = new Map<number, Sample>()
73 for (const s of [...(a[kind] ?? []), ...(b[kind] ?? [])]) if (s[0] >= keepFrom) byTime.set(s[0], s)
74 out[kind] = [...byTime.values()].sort((x, y) => x[0] - y[0]).slice(-3000)
75 }
76 return out
77}
78
79/** What the store holds under `latest`, as a list of readings or nothing. */
80export function asLimits(stored: unknown): Limit[] {
81 if (!Array.isArray(stored)) return []
82 return stored.filter(
83 (l): l is Limit =>
84 !!l &&
85 typeof l === 'object' &&
86 typeof (l as Limit).kind === 'string' &&
87 typeof (l as Limit).percentUsed === 'number' &&
88 ((l as Limit).resetsAt === undefined || typeof (l as Limit).resetsAt === 'string'),
89 )
90}
91types/index.d.ts 37 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Style = 'bars' | 'pacman' | 'beer' | 'tank' | 'battery' | 'hourglass'
4export type Mode = 'full' | 'compact' | 'off'
5export type Lang = 'en' | 'de'
6
7export type Prefs = {
8 mode: Mode
9 style: Style
10 animation: boolean
11 sound: boolean
12 toasts: boolean
13 face: boolean
14 lang: Lang
15}
16
17/** A bar gliding from one value to the next. */
18export type Motion = { from: number; to: number; at: number }
19/** The `+3%` shown after a response, fading out. */
20export type Delta = { value: number; at: number }
21/** One sample of a window: [time ms, percent]. */
22export type Sample = [number, number]
23export type History = Record<string, Sample[]>
24
25declare module 'claude-code' {
26 interface PluginState {
27 'usage-bars': {
28 limits: Limit[]
29 motion: Record<string, Motion>
30 deltas: Record<string, Delta>
31 party: Record<string, number>
32 prefs: Prefs
33 history: History
34 }
35 }
36}
37