SLOPSHOPPER

usage-bars

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…

newspinnercommandtoastprompttimer
★ 1v0.4.2MITupdated 2026-10-07pepperonas/usage-bars
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-bars
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /usage-bars ⎿ usage-bars: Commands: ⎿ usage-bars: /usage-bars stats rate, projection, history, record ⎿ usage-bars: /usage-bars demo play the animation ⎿ usage-bars: /usage-bars full|compact|off display ⎿ usage-bars: /usage-bars style <name> bars · pacman · beer · tank · battery · hourglass ⎿ usage-bars: /usage-bars lang en|de language ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ 5h ███████▍░░░░░░░░░░░░░░░░ 31% 🙂

Draws

Prompt hint
⟨Claude Code's own drawing⟩ 5h ███████▍░░░░░░░░░░░░░░░░ 31% 🙂
README

<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>

📊 usage-bars

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> &nbsp; <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>

version node tests engine tests lines of code

CI Claude Code mod tested with TypeScript Node runtime deps surfaces styles languages network calls mutation tested Keep a Changelog SemVer last commit open issues repo size stars forks PRs welcome License: MIT

Donate with PayPal Rate celox.io on Google


[!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.

🎬 Demo

<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>

📸 Screenshots

<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>

What the line tells you

<img src="docs/states.png" alt="Delta flash, pace mark, projection warning, seconds countdown, quip at 100 %, reset sparkle, compact mode" width="100%">

🎨 Styles

<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 &lt;name&gt;</code> — or just <code>/usage-bars pacman</code>.</sub>

Hover card

<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>

✨ Features

  • Two bars, one line — the 5-hour and the 7-day window side by side, with percent and time until reset. Colour runs green → yellow → red along the bar; eighth blocks (▏▎▍▌▋▊▉█) give eight steps per cell, so even a 1 % move is visible.
  • Delta flash — after every answer, +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.
  • Gliding bars — new values ease in over 0.7 s instead of jumping. Animation runs at ~30 fps only while something moves; at rest the mod redraws once a minute.
  • Pace mark │ — 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.
  • A face that reads your pace — 😎 ahead of plan · 🙂 on plan · 😬 too fast · 🥵 from 90 % · 💀 at 100 %. It follows how you work, not just how full the bar is.
  • Projection — from the samples of the current window the mod computes your burn rate; if you'd run dry before the reset you get ⚠ empty ~16:40. Silent when you'll make it.
  • Seconds countdown — ↻ 2h13m, and in the last hour ↻ 12m41s.
  • Reset, even when idle — the numbers only change when Claude Code gets an answer. If the window resets while you're away, the mod notices from the reset time, drains the bar to 0 and sparkles: refuelled ✨.
  • Every session shows the same figures — a session only hears about the limits from its own answers, so an idle one would keep showing old numbers, or zero a window another session already uses. Each session hands what it hears to the others through the mod's store, and every session looks there every 5 seconds.
  • 100 % gets a quip — ☕ Coffee break – back in 1h12m, one of seven, stable per window.
  • Toasts at 50 / 80 / 90 / 100 %, once per window (remembered across sessions), with an optional chime at 90 % and on reset.
  • Six styles — bars, pacman (a ghost chases Pac-Man from 90 %), beer, tank, battery (⚡ flashes when nearly empty), hourglass.
  • Compact mode — 5h 42% 😎 ↻2h13m 7d 67% 😬 ↻3d 4h; automatic below 72 columns.
  • Stats — /usage-bars stats: burn rate, projection, sparklines of your 5-hour peaks over 24 h and 7 days, your record.
  • English and German — /usage-bars lang de.
  • Shows what may follow — type /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.
  • Respects your settings — everything animated can be switched off (/usage-bars anim off), sound is off by default.

📥 Install

Requirements

  • Claude Code with mods — mods are on by default in current releases; usage-bars is tested with 2.1.289.
  • A Claude subscription login (Pro / Max / Team). The 5-hour and 7-day windows exist only there; with an API key Claude Code reports no rate-limit windows, and the line stays hidden.

Option 1 — marketplace (recommended)

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).

Option 2 — skills folder

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.

Option 3 — one session

git clone https://github.com/pepperonas/usage-bars
claude --plugin-dir ./usage-bars

Option 4 — desktop app and SDK hosts

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" } }

🕹️ Usage

CommandWhat it does
/usage-barsHelp and the current settings
/usage-bars statsBurn rate, projection, sparklines (24 h / 7 d), record
/usage-bars demoPlays the animation once
/usage-bars full · compact · offDisplay mode
/usage-bars style <name>bars · pacman · beer · tank · battery · hourglass (also /usage-bars pacman)
/usage-bars lang en · deLanguage 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.

⚙️ Configuration

The defaults live in /config under usage-bars:

FieldValuesDefault
stylebars pacman beer tank battery hourglassbars
modefull compact offfull
languageen deen
animationon / offon
soundon / offoff
toastson / offon
faceon / offon

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.

🧠 How it works

 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
  • Source of truth. 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.
  • Shared between sessions. Each fresh reading is merged into 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.
  • Drawing. A ui.render hook on the PromptHint component returns the engine's own hint line plus one row — the shortcuts and pills keep working.
  • Pace. elapsed = 1 − (resetsAt − now) / window; the mark sits at that share of the bar, and used / elapsed picks the face.
  • Projection. Burn rate = rise since the first sample inside the current window ÷ time; it needs 10 minutes of samples and a rising value. Samples from the previous window never count.
  • History. Up to 3000 samples, 8 days, in $.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.

🔒 Privacy

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.

🏛️ Architecture

FileRole
hooks/register.tsxThe mod: hooks, state, ticker, animation loop, /usage-bars
hooks/row.tsPure: one window's segment of the line, and the hover card
hooks/styles.tsPure: the six styles, gradient, sparkle
hooks/share.tsPure: merging the readings and history of concurrent sessions
hooks/format.tsPure: countdown, clock, pace, face, burn rate, projection, history
hooks/i18n.tsEnglish and German strings
types/index.d.tsThe state contract (PluginState['usage-bars'])
sounds/Two short chimes, generated, no third-party audio
tools/screenshots.tsRenders 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.

🧪 Testing

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)

❓ FAQ

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.

📝 Changelog

The full history is in CHANGELOG.md (Keep a Changelog).

  • 0.4.2 — the 7-day sparkline shows the 7-day level, and steady hours no longer show as gaps.
  • 0.4.1 — every session shows the same, current figures; an idle one no longer shows old numbers or a 0 % window.
  • 0.4.0 — typing /usage-bars lists what may follow, under the prompt.
  • 0.3.3 — ready for the Claude plugin directory: a listing icon, and /config fields the directory accepts.
  • 0.3.2 — installs no dev tools for users (no lockfile in the plugin root).
  • 0.3.1 — installable from a plugin marketplace: /plugin install usage-bars@pepperonas.
  • 0.3.0 — English and German, a Node test suite with drift guards, rendered screenshots; the pace mark is a thin line on the fill.
  • 0.2.0 — animation, delta, pace, face, projection, reset party, toasts, six styles, /usage-bars.
  • 0.1.0 — two bars under the prompt.

🤝 Contributing

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.

💛 Support

usage-bars is free and stays that way. If it saves you a surprise at 100 %:

📄 License

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.

Source 8 files
hooks/register.tsx 451 lines
1import { 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}
451
hooks/format.ts 187 lines
1import 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}
187
hooks/styles.ts 171 lines
1import 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}
171
hooks/row.ts 113 lines
1import 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}
113
hooks/i18n.ts 68 lines
1import 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)
68
hooks/complete.ts 76 lines
1/**
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}
76
hooks/share.ts 91 lines
1import 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}
91
types/index.d.ts 37 lines
1export 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