Rings above the prompt for limits, context, todos, tokens, cost and the prompt cache

Mods for the Claude Code terminal: a band of rings above the prompt that shows your limits, this chat's usage and the prompt cache, plus a board of your running Claude sessions.
Copy this prompt into Claude Code:
Install the Claude Code plugins from https://github.com/Oualid0/claude-mods:
add the repo as a plugin marketplace, then install every plugin listed in its
.claude-plugin/marketplace.json, and tell me to run /reload-plugins when done.
claude plugin marketplace add Oualid0/claude-mods
claude plugin install usage-ring@claude-mods
claude plugin install session-board@claude-mods
Then run /reload-plugins in Claude Code, or start a new session. From a local clone, ./install.sh does the same (it needs python3) and is safe to run again.
Auto-update is off by default. Update by hand with /plugin marketplace update claude-mods in a session, or claude plugin update usage-ring@claude-mods and claude plugin update session-board@claude-mods in the shell. You can also turn on Enable auto-update for the marketplace under Marketplaces in /plugin.
| Plugin | What it does |
|---|---|
usage-ring | Two chips right above the prompt: limits and chat, with a pixel Claude beside them that hammers while a turn runs and sleeps otherwise, and the model in grey next to it, e.g. Opus 5.5 (mid) (effort low, mid, high, xhigh or max, known from the first request on). |
session-board | A sessions chip above that while other Claude sessions on this machine are running: one row each (● running, ✓ done for 60 s after it finished), plus your own row (○ ready or ● running) marked ←. With no other session running, the board is hidden. |
| Label | Chip | Meaning |
|---|---|---|
Wk | limits | Weekly limit used, in percent. |
Se | limits | Session limit (the 5-hour window) used, and the time until it resets: 4:50h, or 33m under an hour. Once the window is over it shows 0% 5:00h until the next reading. |
Cx | chat | Context window used, in percent. |
Td | chat | Todos done out of all, e.g. 3/5. Only while the chat has a todo list. |
Tk | chat | Tokens this chat used since the session started (input, output, cache reads and writes, subagents included). Grows after every model request. |
Co | chat | What the session cost so far, in US dollars. |
Ca | chat | Time left on the prompt cache (33m), expired once it lapsed. |
When the terminal is narrow, the least important goes first: the model label, the words limits and chat, Ca, Co, Tk, Td, Wk, the pixel Claude, then Cx. Se stays longest; if not even it fits, the band is hidden. Nothing is squeezed or wrapped.
Ca turns red in its last 3 minutes.Ca is an estimate. Claude Code does not tell plugins how long the cache lives (5 minutes or 1 hour), so the band assumes 1 hour and learns from what each request read from the cache: a hit after a pause of more than 5 minutes means 1 hour, a miss means 5 minutes. A miss can also come from a changed system prompt, which the band cannot see.Tk starts at 0 when a session starts or resumes; earlier tokens of a resumed session are not available. Co starts with the session's cost.Td counts TaskCreate/TaskUpdate/TaskList and TodoWrite. Newer models only have these tools with CLAUDE_CODE_ENABLE_TODO_TOOLS=1 (docs).ListAgents tool every 5 s. Its output is text for the model, not a fixed format: if a Claude Code update changes it, the board stays empty instead of showing an error.usage-ring has one option, off by default:
| Option | Meaning |
|---|---|
limitsFile | Write the session and weekly limits to $CLAUDE_CONFIG_DIR/usage-limits.json (default ~/.claude) for other tools to read. |
Set it with /plugin configure usage-ring@claude-mods in Claude Code, or:
echo '{"limitsFile":"true"}' | claude plugin configure usage-ring@claude-mods --values-stdin
plugins/<name>/: .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.tsx with the hooks, pure logic in files beside it, types/index.d.ts (the state contract) and tests/.claude plugin validate ., then claude plugin validate plugins/<name> and claude plugin test plugins/<name>./reload-plugins or the next session.next(e) and keeps what is beneath (session-board on top, usage-ring next to the prompt).MIT, see LICENSE.
hooks/register.tsx 360 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Cache, Model, Todos, Usage } from '../types'
5import { FRESH_CACHE, afterRequest, cacheLeft, isCacheLow } from './cache'
6import { CLAUDE, SCALE, SPRITE, SPRITE_W, claudePixels, hammerPixels } from './claude'
7import {
8 RING_COLUMNS,
9 RING_ROWS,
10 SPRITE_COLUMNS,
11 ALERT,
12 GAP,
13 LABELS,
14 entries,
15 fit,
16 fromList,
17 fromTodoWrite,
18 groups,
19 modelLabel,
20 MODEL_GAP,
21 segments,
22 snapshot,
23 usageFrom,
24 withCreated,
25 withUpdated,
26} from './model'
27import { RING_SIZE, hexToRgb, hot, ringGlyph, ringPixels, toBase64 } from './ring'
28
29const usage = atom({ plugin: 'usage-ring', key: 'usage' } as const, {} as Usage)
30const todos = atom({ plugin: 'usage-ring', key: 'todos' } as const, {} as Todos)
31const now = atom({ plugin: 'usage-ring', key: 'now' } as const, 0)
32
33// Tokens this chat used since the session started: all four counts of every
34// request, subagents included. In $.state, so a reload keeps them.
35const tokens = atom({ plugin: 'usage-ring', key: 'tokens' } as const, 0)
36// What the whole session cost so far, in US dollars, as /cost totals it.
37const costUsd = atom({ plugin: 'usage-ring', key: 'costUsd' } as const, 0)
38// Animation frame, and whether a turn of this session is running.
39const frame = atom({ plugin: 'usage-ring', key: 'frame' } as const, 0)
40const isBusy = atom({ plugin: 'usage-ring', key: 'isBusy' } as const, false)
41const cache = atom({ plugin: 'usage-ring', key: 'cache' } as const, FRESH_CACHE as Cache)
42const model = atom({ plugin: 'usage-ring', key: 'model' } as const, {} as Model)
43
44/** The chat chip's frame on a hammer hit: a brighter orange. */
45const HIT_FLASH = '#f59a6c'
46const FRAME_MS = 150
47/** The hammer's swing: raised, raised, swinging, hit, hit with sparks, swinging back. */
48const SWING = [0, 0, 1, 2, 3, 1]
49/** The pose in which the hammer hits and sparks. */
50const HIT_POSE = 3
51/** The z's change every second frame. */
52const Z_PHASES = 12
53
54const LIMITS_FILE = 'usage-limits.json'
55
56/** Claude Code's config directory; undefined when neither CLAUDE_CONFIG_DIR nor HOME is set. */
57async function configDir($: EngineInterface): Promise<string | undefined> {
58 const dir = await $.env.get('CLAUDE_CONFIG_DIR')
59 if (dir) return dir
60 const home = await $.env.get('HOME')
61 return home ? `${home}/.claude` : undefined
62}
63
64/** Seeds the session and weekly figures from the last snapshot on disk. */
65async function seedFromSnapshot($: EngineInterface): Promise<void> {
66 try {
67 const dir = await configDir($)
68 if (!dir) return
69 const raw = JSON.parse(await $.fs.read(`${dir}/${LIMITS_FILE}`)) as {
70 session?: { usedPercent?: number; resetsAt?: string | null } | null
71 week?: { usedPercent?: number } | null
72 }
73 await update($, usage, u => ({
74 ...u,
75 sessionUsed: u.sessionUsed ?? raw.session?.usedPercent,
76 sessionResetsAt: u.sessionResetsAt ?? raw.session?.resetsAt ?? undefined,
77 weekUsed: u.weekUsed ?? raw.week?.usedPercent,
78 }))
79 } catch {
80 // No snapshot yet: the rings appear with the first reading.
81 }
82}
83
84/** Reads the task list that already exists (a resumed session, a reload). */
85async function syncTodos($: EngineInterface): Promise<void> {
86 const names = (await $.tool.list()).map(t => t.name)
87 if (!names.includes('TaskList')) return
88 try {
89 const r = await $.tool.call({ tool: 'TaskList' })
90 const list = (r.result as { tasks?: { id: string; status: string }[] } | undefined)?.tasks
91 if (list && !r.isError) await update($, todos, () => fromList(list))
92 } catch {
93 // No list to read: the ring appears with the first TaskCreate.
94 }
95}
96
97/**
98 * The context fill: the last response's figure, or before the first response
99 * (a new chat, after /clear) the local estimate of what is already loaded
100 * (system prompt, tools, memory files). Undefined when neither is known.
101 */
102async function contextPercent(
103 $: EngineInterface,
104 context: { percent?: number },
105): Promise<number | undefined> {
106 if (context.percent !== undefined) return context.percent
107 try {
108 const { context: c } = await $.session.usage({ breakdown: 'summary' })
109 return c.breakdown?.percentage
110 } catch {
111 return undefined
112 }
113}
114
115const pictures = new Map<string, string>()
116
117/** The base64 picture for `key`, drawn once. */
118function cached(key: string, draw: () => Uint8Array): string {
119 const hit = pictures.get(key)
120 if (hit !== undefined) return hit
121 const base64 = toBase64(draw())
122 pictures.set(key, base64)
123 return base64
124}
125
126function picture(percent: number, color: string): string {
127 return cached(`r${Math.round(percent)}${color}`, () => ringPixels(Math.round(percent), hexToRgb(color)))
128}
129
130export const register: Register = (on, options) => {
131 const writesLimits = options.limitsFile === true
132
133 on('session.start', async ($, e, next) => {
134 const result = await next(e)
135 const startedAt = await $.clock.now()
136 await update($, now, () => startedAt)
137 const sessionUsage = await $.session.usage()
138 const { rateLimits, context } = sessionUsage
139 const percent = await contextPercent($, context)
140 await update($, usage, u => usageFrom(rateLimits, percent, u))
141 if (writesLimits && rateLimits.length === 0) await seedFromSnapshot($)
142 await syncTodos($)
143 try {
144 const id = await $.session.model()
145 await update($, model, m => ({ ...m, id }))
146 } catch {
147 // No model yet: the label appears with the first request.
148 }
149 const usd = sessionUsage.cost?.usd
150 if (usd !== undefined) await update($, costUsd, () => usd)
151 // Only a redraw: the frame number picks the hammer pose or the z's.
152 $.clock.every(FRAME_MS, () => {
153 void update($, frame, f => (f + 1) % 10_000)
154 })
155 $.clock.every(60_000, () => {
156 void $.clock.now().then(t => update($, now, () => t))
157 })
158 return result
159 })
160
161 on('session.measure', async ($, e, next) => {
162 if (e.cost) {
163 const usd = e.cost.usd
164 await update($, costUsd, () => usd)
165 }
166 const percent = await contextPercent($, e.context)
167 await update($, usage, u => usageFrom(e.rateLimits, percent, u))
168 if (writesLimits) {
169 try {
170 const written = snapshot(e.rateLimits, await $.clock.now(), await $.session.model())
171 const dir = await configDir($)
172 if (written && dir) await $.fs.write(`${dir}/${LIMITS_FILE}`, JSON.stringify(written))
173 } catch {
174 // No model or no file: readers keep the last snapshot; the band must not break.
175 }
176 }
177 return next(e)
178 })
179
180 on('turn.start', async ($, e, next) => {
181 await update($, isBusy, () => true)
182 return next(e)
183 })
184
185 // Each request's tokens as soon as it is answered, so the count moves while a
186 // turn runs; turn.complete would bring the same sum only at the turn's end.
187 on('turn.step', async function* ($, e, next) {
188 const sentAt = await $.clock.now()
189 // The model and effort this request is really sent with (a fallback, /model, /effort).
190 if (e.agentId === undefined) await update($, model, () => ({ id: e.model, effort: e.effort }))
191 const r = yield* next(e)
192 const u = r.usage
193 if (u) {
194 const used = u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
195 await update($, tokens, t => t + used)
196 // Subagents keep caches of their own; the band times the main thread's.
197 if (e.agentId === undefined) await update($, cache, c => afterRequest(c, sentAt, u.model, u))
198 }
199 return r
200 })
201
202 on('turn.complete', async ($, e, next) => {
203 if (e.agentId === undefined) await update($, isBusy, () => false)
204 return next(e)
205 })
206
207 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
208 const r = await next(e)
209 const id = (r.result as { task?: { id?: string } } | undefined)?.task?.id
210 if (id !== undefined && !r.isError) await update($, todos, t => withCreated(t, id))
211 return r
212 })
213
214 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
215 const r = await next(e)
216 const input = e as unknown as { taskId?: string; status?: string }
217 if (input.taskId !== undefined && !r.isError) {
218 const id = input.taskId
219 await update($, todos, t => withUpdated(t, id, input.status))
220 }
221 return r
222 })
223
224 on('tool.call', { tool: 'TaskList' }, async ($, e, next) => {
225 const r = await next(e)
226 const list = (r.result as { tasks?: { id: string; status: string }[] } | undefined)?.tasks
227 if (list && !r.isError) await update($, todos, () => fromList(list))
228 return r
229 })
230
231 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
232 const r = await next(e)
233 const list = (e as unknown as { todos?: { status: string }[] }).todos
234 if (list && !r.isError) await update($, todos, () => fromTodoWrite(list))
235 return r
236 })
237
238 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
239 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
240
241 // The minute tick only triggers the redraw; the time itself is read here, as
242 // the tick holds 0 until session.start has run (and /clear fires none).
243 await read($, now)
244 const usedTokens = await read($, tokens)
245 const usd = await read($, costUsd)
246 const all = segments(await read($, usage), await read($, todos), await $.clock.now())
247 if (all.length === 0) return next(e)
248 const c = await read($, cache)
249 const m = await read($, model)
250 const extras = {
251 tokens: usedTokens,
252 costUsd: usd,
253 cache: cacheLeft(c, await $.clock.now()),
254 model: modelLabel(m.id, m.effort),
255 }
256 const shown = fit(all, extras, e.props.bodyColumns)
257 // Not even the session ring fits: show nothing rather than squeeze it.
258 if (!shown) return next(e)
259 const chatEntries = entries(extras, shown)
260
261 // Other mods' drawings go above the rings, so the rings stay next to the prompt.
262 const below = await next(e)
263 const { Box, Text, Image } = $.ui.resolve(e)
264 const f = await read($, frame)
265 const busy = await read($, isBusy)
266 const pose = SWING[f % SWING.length]!
267 const isHit = busy && pose === HIT_POSE
268
269 const cacheIsLow = isCacheLow(c, await $.clock.now())
270 const entry = (label: string, value: string) => (
271 <Box key={label} flexDirection="row" gap={1}>
272 <Text dimColor>{label}</Text>
273 <Text color={hot(label === 'Ca' && cacheIsLow ? ALERT : CLAUDE)} bold>
274 {value}
275 </Text>
276 </Box>
277 )
278 // One chip per group: a rounded frame and label in terracotta.
279 const chip = (label: string, list: typeof shown.segments, withEntries: boolean) => (
280 <Box
281 key={label}
282 borderStyle="round"
283 borderColor={label === LABELS.chat && isHit ? HIT_FLASH : CLAUDE}
284 paddingX={1}
285 >
286 <Box flexDirection="row" gap={1} alignItems="center">
287 {shown.hasLabels ? <Text color={CLAUDE}>{label}</Text> : null}
288 <Box flexDirection="row" gap={GAP} alignItems="center">
289 {list.map(s => (
290 <Box key={s.id} flexDirection="row" gap={1} alignItems="center">
291 <Text dimColor>{s.short}</Text>
292 <Image
293 key={`ring-${s.id}`}
294 source={{ rgba: picture(s.percent, s.color), width: RING_SIZE, height: RING_SIZE }}
295 columns={RING_COLUMNS}
296 rows={RING_ROWS}
297 alt={ringGlyph(s.percent)}
298 />
299 <Text color={hot(s.color)} bold>
300 {s.time ? s.text.slice(0, -(s.time.length + 1)) : s.text}
301 </Text>
302 {s.time ? (
303 <Text color={hot(CLAUDE)} bold>
304 {s.time}
305 </Text>
306 ) : null}
307 </Box>
308 ))}
309 {withEntries && chatEntries.length > 0 ? (
310 <Box flexDirection="row" gap={1}>
311 {chatEntries.flatMap(([label, value], i) =>
312 i === 0 ? [entry(label, value)] : [<Text key={`gap-${label}`}> </Text>, entry(label, value)],
313 )}
314 </Box>
315 ) : null}
316 </Box>
317 </Box>
318 </Box>
319 )
320
321 // Hammering while a turn runs, asleep otherwise; 24 x 24 sprite pixels, SCALE image pixels each.
322 const zPhase = Math.floor(f / 2) % Z_PHASES
323 const spriteKey = busy ? `h${pose}` : `s${zPhase}`
324 const sprite = (
325 <Image
326 key="claude"
327 source={{
328 rgba: cached(spriteKey, () => (busy ? hammerPixels(pose) : claudePixels(zPhase))),
329 width: SPRITE_W * SCALE,
330 height: SPRITE * SCALE,
331 }}
332 columns={SPRITE_COLUMNS}
333 rows={3}
334 alt={busy ? '⚒' : 'z'}
335 />
336 )
337 const { limits, chat } = groups(shown.segments)
338 const hasChat = chat.length > 0 || chatEntries.length > 0
339 return (
340 <Box flexDirection="column">
341 {below}
342 <Box flexDirection="row" alignItems="center">
343 {limits.length > 0 ? (
344 <Box marginRight={hasChat ? 1 : 0}>
345 {chip(LABELS.limits, limits, false)}
346 </Box>
347 ) : null}
348 {hasChat ? chip(LABELS.chat, chat, true) : null}
349 {shown.hasSprite ? sprite : null}
350 {shown.hasModel && extras.model !== undefined ? (
351 <Box marginLeft={MODEL_GAP}>
352 <Text dimColor>{extras.model}</Text>
353 </Box>
354 ) : null}
355 </Box>
356 </Box>
357 )
358 })
359}
360hooks/cache.ts 72 lines1// The prompt cache's countdown. The engine shows neither the cache's lifetime
2// (5 minutes or 1 hour) nor when an entry lapses; both are inferred from what
3// each request read from the cache and how long after the one before it came.
4
5import type { Cache } from '../types'
6import { duration } from './model'
7
8/** The cache's time left is shown red from this point on. */
9export const WARN_MS = 3 * 60_000
10
11export const SHORT_TTL = 5 * 60_000
12export const LONG_TTL = 60 * 60_000
13
14/** Shown once the entry's lifetime is over. */
15export const EXPIRED = 'expired'
16
17/** At least this share of the input read from the cache counts as a hit. */
18export const HIT_SHARE = 0.5
19/** Below this share it counts as a miss. */
20export const MISS_SHARE = 0.1
21
22export const FRESH_CACHE: Cache = { ttl: LONG_TTL, source: 'assumed' }
23
24type Counts = {
25 input_tokens: number
26 cache_read_input_tokens: number
27 cache_creation_input_tokens: number
28}
29
30export function readShare(u: Counts): number | undefined {
31 const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
32 return total > 0 ? u.cache_read_input_tokens / total : undefined
33}
34
35/**
36 * The cache after a main-thread request sent at `sentAt` answered with `u`.
37 *
38 * Only a gap between both lifetimes says which one holds: a hit after more
39 * than 5 minutes means 1 hour, a miss means 5 minutes (or an invalidation the
40 * band cannot see, such as a changed system prompt). The latest such request
41 * wins, so a drop to 5 minutes (usage overage) shows too.
42 */
43export function afterRequest(cache: Cache, sentAt: number, model: string, u: Counts): Cache {
44 const share = readShare(u)
45 let { ttl, source } = cache
46 const gap = cache.lastAt === undefined ? undefined : sentAt - cache.lastAt
47 const isTelling = gap !== undefined && gap > SHORT_TTL && gap < LONG_TTL && cache.model === model
48 if (isTelling && share !== undefined) {
49 if (share >= HIT_SHARE) {
50 ttl = LONG_TTL
51 source = 'hit'
52 } else if (share < MISS_SHARE) {
53 ttl = SHORT_TTL
54 source = 'miss'
55 }
56 }
57 return { lastAt: sentAt, model, ttl, source, readShare: share }
58}
59
60/** Time left on the entry (`33m`), `expired` once over; undefined before the first request. */
61export function cacheLeft(cache: Cache, now: number): string | undefined {
62 if (cache.lastAt === undefined) return undefined
63 const ms = cache.lastAt + cache.ttl - now
64 return ms > 0 ? duration(ms) : EXPIRED
65}
66
67/** True when the entry has less than 3 minutes left or has lapsed; false before the first request. */
68export function isCacheLow(cache: Cache, now: number): boolean {
69 if (cache.lastAt === undefined) return false
70 return cache.lastAt + cache.ttl - now <= WARN_MS
71}
72hooks/claude.ts 155 lines1// Pure pixel logic of the pixel Claude beside the chat chip: a hammering and a
2// sleeping frame as RGBA. No `$` in here, so the tests can drive it directly.
3import { hexToRgb, hot } from './ring'
4import type { Rgb } from './ring'
5
6export const CLAUDE = '#d97757'
7
8export const SPRITE = 24
9/** 24 x 24 pixels on 6 x 3 cells: the body from x 4, both arms inside the picture. */
10export const SPRITE_W = 24
11/** Each sprite pixel becomes SCALE x SCALE image pixels, so the terminal scales crisp blocks. */
12export const SCALE = 4
13const BODY = hexToRgb(CLAUDE)
14const EYE: Rgb = [38, 24, 20]
15const Z_RGB = hexToRgb(hot(CLAUDE))
16
17const Z = ['###', '.#.', '###']
18
19/**
20 * The sleeping Claude: eyes shut, both arms. `zPhase` 0..11 lets three z's
21 * appear one by one, rising diagonally from the top right of the head, then
22 * clears them.
23 */
24export function claudePixels(zPhase: number): Uint8Array {
25 const grid: (Rgb | null)[][] = Array.from({ length: SPRITE }, () => Array<Rgb | null>(SPRITE_W).fill(null))
26 const alpha: number[][] = Array.from({ length: SPRITE }, () => Array<number>(SPRITE_W).fill(0))
27 const put = (x: number, y: number, rgb: Rgb, a = 1) => {
28 if (x < 0 || y < 0 || x >= SPRITE_W || y >= SPRITE) return
29 grid[y]![x] = rgb
30 alpha[y]![x] = a
31 }
32 const rect = (x0: number, y0: number, w: number, h: number, rgb: Rgb) => {
33 for (let y = y0; y < y0 + h; y++) for (let x = x0; x < x0 + w; x++) put(x, y, rgb)
34 }
35
36 // Asleep where the hammering Claude stands: same height, body from x 4.
37 const top = 5
38 rect(4, top, 16, 11, BODY)
39 rect(1, top + 4, 3, 3, BODY)
40 rect(20, top + 4, 3, 3, BODY)
41 rect(7, top + 5, 4, 1, EYE)
42 rect(13, top + 5, 4, 1, EYE)
43 for (const x of [5, 9, 13, 17]) rect(x, top + 11, 2, 4, BODY)
44
45 // Three z's one by one: beside the head on the right, then up and over it.
46 const glyph = (x0: number, y0: number) =>
47 Z.forEach((line, dy) => {
48 for (let dx = 0; dx < line.length; dx++) if (line[dx] === '#') put(x0 + dx, y0 + dy, Z_RGB)
49 })
50 const shown = zPhase < 9 ? Math.floor(zPhase / 3) + 1 : 0
51 const spots = [
52 [21, top],
53 [17, top - 4],
54 [13, top - 5],
55 ] as const
56 spots.slice(0, shown).forEach(([x, y]) => glyph(x, y))
57
58 const width = SPRITE_W * SCALE
59 const height = SPRITE * SCALE
60 const px = new Uint8Array(width * height * 4)
61 for (let y = 0; y < height; y++)
62 for (let x = 0; x < width; x++) {
63 const sy = Math.floor(y / SCALE)
64 const sx = Math.floor(x / SCALE)
65 const rgb = grid[sy]![sx]
66 if (!rgb) continue
67 const i = (y * width + x) * 4
68 px[i] = rgb[0]
69 px[i + 1] = rgb[1]
70 px[i + 2] = rgb[2]
71 px[i + 3] = Math.round(alpha[sy]![sx]! * 255)
72 }
73 return px
74}
75
76// --- working poses -------------------------------------------------------------
77
78const HANDLE: Rgb = [139, 90, 60]
79const IRON: Rgb = [150, 150, 158]
80const SPARK: Rgb = [255, 214, 120]
81
82type Canvas = { put: (x: number, y: number, rgb: Rgb) => void; rect: (x: number, y: number, w: number, h: number, rgb: Rgb) => void; pixels: () => Uint8Array }
83
84function canvas(): Canvas {
85 const grid: (Rgb | null)[][] = Array.from({ length: SPRITE }, () => Array<Rgb | null>(SPRITE_W).fill(null))
86 const put = (x: number, y: number, rgb: Rgb) => {
87 if (x >= 0 && y >= 0 && x < SPRITE_W && y < SPRITE) grid[y]![x] = rgb
88 }
89 const rect = (x0: number, y0: number, w: number, h: number, rgb: Rgb) => {
90 for (let y = y0; y < y0 + h; y++) for (let x = x0; x < x0 + w; x++) put(x, y, rgb)
91 }
92 const pixels = () => {
93 const width = SPRITE_W * SCALE
94 const height = SPRITE * SCALE
95 const px = new Uint8Array(width * height * 4)
96 for (let y = 0; y < height; y++)
97 for (let x = 0; x < width; x++) {
98 const rgb = grid[Math.floor(y / SCALE)]![Math.floor(x / SCALE)]
99 if (!rgb) continue
100 const i = (y * width + x) * 4
101 px[i] = rgb[0]
102 px[i + 1] = rgb[1]
103 px[i + 2] = rgb[2]
104 px[i + 3] = 255
105 }
106 return px
107 }
108 return { put, rect, pixels }
109}
110
111/** Body, legs and open eyes at `top`; `eyeDx` shifts the pupils, `x0` the whole figure. */
112function figure(c: Canvas, top: number, x0: number, eyeDx: number, eyeDy: number, arms: 'both' | 'right' | 'none', legs = SPRITE - top - 11) {
113 c.rect(x0 + 4, top, 16, 11, BODY)
114 if (arms === 'both' || arms === 'right') c.rect(x0 + 20, top + 4, 3, 3, BODY)
115 if (arms === 'both') c.rect(x0 + 1, top + 4, 3, 3, BODY)
116 c.rect(x0 + 8 + eyeDx, top + 3 + eyeDy, 2, 3, EYE)
117 c.rect(x0 + 14 + eyeDx, top + 3 + eyeDy, 2, 3, EYE)
118 for (const x of [5, 9, 13, 17]) c.rect(x0 + x, top + 11, 2, legs, BODY)
119}
120
121/** Faces left and hammers the chip on its left: 0 raised, 1 swinging, 2 hit, 3 hit with sparks. */
122export function hammerPixels(pose: number): Uint8Array {
123 const c = canvas()
124 // High in the picture, flush left, so the hammer reaches the chip beside it.
125 const top = 5
126 figure(c, top, 0, -2, 0, 'right', 4)
127 // Left arm: from the body out to a hand at x 2..3, raised, halfway or level.
128 const hy = [top + 1, top + 3, top + 5, top + 5][pose]!
129 c.rect(2, hy, 2, 2, BODY)
130 if (pose === 0) {
131 // Hammer up: handle straight up from the hand, head on top.
132 c.rect(2, hy - 3, 1, 3, HANDLE)
133 c.rect(0, hy - 6, 4, 3, IRON)
134 } else if (pose === 1) {
135 // Halfway: handle up and to the left, head above it.
136 c.put(1, hy - 1, HANDLE)
137 c.put(1, hy - 2, HANDLE)
138 c.rect(0, hy - 6, 3, 4, IRON)
139 } else {
140 // Hit: the head against the left edge, which is the chip's frame.
141 c.rect(0, hy - 2, 2, 4, IRON)
142 if (pose === 3) {
143 for (const [x, y] of [
144 [0, hy - 4],
145 [1, hy - 5],
146 [3, hy - 4],
147 [0, hy + 3],
148 [2, hy + 4],
149 ] as const)
150 c.put(x, y, SPARK)
151 }
152 }
153 return c.pixels()
154}
155hooks/model.ts 353 lines1// Pure logic of the band: what each ring shows, the todo bookkeeping and the
2// optional usage-limits.json snapshot. No `$` in here, so the tests
3// can drive it directly.
4
5import type { TodoStatus, Todos, Usage } from '../types'
6
7/** Claude's terracotta: every ring, frame and label. */
8export const TERRACOTTA = '#d97757'
9
10/** A red that sits next to the terracotta: rings from 95% on and a cache about to lapse. */
11export const ALERT = '#e5484d'
12/** From this fill on a limit or context ring turns red. */
13export const ALERT_PERCENT = 95
14
15/** The color of a ring that shows a fill: red from 95%, otherwise `base`. */
16export const ringColor = (percent: number, base: string): string => (percent >= ALERT_PERCENT ? ALERT : base)
17
18export const COLORS = {
19 session: TERRACOTTA,
20 context: TERRACOTTA,
21 week: TERRACOTTA,
22 todos: TERRACOTTA,
23} as const
24
25/** Two-letter label dimmed in front of each ring. */
26export const SHORT = { week: 'Wk', session: 'Se', context: 'Cx', todos: 'Td' } as const
27
28export type SegmentId = keyof typeof COLORS
29
30export type Segment = {
31 id: SegmentId
32 short: string
33 /** How full the ring is drawn, 0..100. */
34 percent: number
35 text: string
36 /** The time part of `text` (`4:54h`); it keeps the base color when the ring turns red. */
37 time?: string
38 color: string
39}
40
41type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
42
43/** Folds the engine's rate-limit windows into the band's usage. */
44export function usageFrom(
45 rateLimits: readonly RateLimit[],
46 contextPercent: number | undefined,
47 previous: Usage,
48): Usage {
49 const session = rateLimits.find(w => w.kind === 'five_hour')
50 const week = rateLimits.find(w => w.kind === 'seven_day')
51 return {
52 sessionUsed: session?.percentUsed ?? previous.sessionUsed,
53 sessionResetsAt: session ? session.resetsAt : previous.sessionResetsAt,
54 weekUsed: week?.percentUsed ?? previous.weekUsed,
55 contextUsed: contextPercent,
56 }
57}
58
59/** A span as the band shows it: `4:50h` from an hour up, `33m` below. */
60export function duration(ms: number): string {
61 const minutes = Math.max(0, Math.floor(ms / 60_000))
62 if (minutes < 60) return `${minutes}m`
63 return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}h`
64}
65
66/** Time left until `resetsAt` (`4:50h`, `33m`); undefined when unknown or past. */
67export function remaining(resetsAt: string | undefined, now: number): string | undefined {
68 if (!resetsAt) return undefined
69 const ms = Date.parse(resetsAt) - now
70 if (!Number.isFinite(ms) || ms <= 0) return undefined
71 return duration(ms)
72}
73
74const round = (n: number) => Math.round(n)
75
76/** The session window's length, shown once a window has run out and the next has not begun. */
77export const FRESH_WINDOW = '5:00h'
78
79/** Whether the session window ending at `resetsAt` is over. */
80export function hasReset(resetsAt: string | undefined, now: number): boolean {
81 if (!resetsAt) return false
82 const at = Date.parse(resetsAt)
83 return Number.isFinite(at) && at <= now
84}
85
86export function todoCounts(todos: Todos): { done: number; total: number } {
87 const statuses = Object.values(todos)
88 return {
89 done: statuses.filter(s => s === 'completed').length,
90 total: statuses.length,
91 }
92}
93
94/** The rings in band order (week, session, then context, todos); a ring without a figure is left out. */
95export function segments(usage: Usage, todos: Todos, now: number): Segment[] {
96 const out: Segment[] = []
97
98 if (usage.weekUsed !== undefined) {
99 const used = round(usage.weekUsed)
100 out.push({ id: 'week', short: SHORT.week, percent: used, text: `${used}%`, color: ringColor(used, COLORS.week) })
101 }
102 if (usage.sessionUsed !== undefined) {
103 // A window that ran out is empty and whole again until the next reading
104 // brings the new one: 0% and its full five hours.
105 const isOver = hasReset(usage.sessionResetsAt, now)
106 const used = isOver ? 0 : round(usage.sessionUsed)
107 const time = isOver ? FRESH_WINDOW : remaining(usage.sessionResetsAt, now)
108 out.push({
109 id: 'session',
110 short: SHORT.session,
111 percent: used,
112 text: time ? `${used}% ${time}` : `${used}%`,
113 time,
114 color: ringColor(used, COLORS.session),
115 })
116 }
117 if (usage.contextUsed !== undefined) {
118 const used = round(usage.contextUsed)
119 out.push({ id: 'context', short: SHORT.context, percent: used, text: `${used}%`, color: ringColor(used, COLORS.context) })
120 }
121 const { done, total } = todoCounts(todos)
122 if (total > 0) {
123 out.push({
124 id: 'todos',
125 short: SHORT.todos,
126 percent: (done / total) * 100,
127 text: `${done}/${total}`,
128 color: COLORS.todos,
129 })
130 }
131 return out
132}
133
134export const RING_COLUMNS = 2
135export const RING_ROWS = 1
136/** Columns between two rings inside a chip. */
137export const GAP = 2
138/** Columns between the two chips. */
139export const CHIP_GAP = 1
140/** Columns a chip adds around its content: the frame and one column of padding, both sides. */
141export const CHIP_FRAME = 4
142
143/** Label in front of each chip's rings: the account's limits, then this chat's own. */
144export const LABELS = { limits: 'limits', chat: 'chat' } as const
145
146export type Groups = { limits: Segment[]; chat: Segment[] }
147
148/** Splits the band into the limit rings (week, session) and the chat's own (context, todos). */
149export function groups(list: readonly Segment[]): Groups {
150 const isLimit = (s: Segment) => s.id === 'week' || s.id === 'session'
151 return { limits: list.filter(isLimit), chat: list.filter(s => !isLimit(s)) }
152}
153
154/** The pixel Claude beside the chat chip, in terminal columns. */
155export const SPRITE_COLUMNS = 6
156
157/** Tokens as `812`, `4.6k`, `46k`, `4.6M`. */
158export function compact(n: number): string {
159 if (n < 1000) return String(n)
160 if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
161 return `${(n / 1_000_000).toFixed(1)}M`
162}
163
164/** Dollars as `$6.79`. */
165export const money = (usd: number): string => `$${usd.toFixed(2)}`
166
167/** Effort levels as the band shows them. */
168export const EFFORT_SHORT: Record<string, string> = {
169 low: 'low',
170 medium: 'mid',
171 high: 'high',
172 xhigh: 'xhigh',
173 max: 'max',
174}
175
176/** `claude-opus-5-5` → `Opus 5.5`, `claude-haiku-4-5-20251001` → `Haiku 4.5`; any other name as given. */
177export function modelName(id: string): string {
178 const bare = id.replace(/\[.*\]$/, '').trim()
179 const parts = bare.replace(/^claude-/, '').split('-').filter(p => !/^\d{8}$/.test(p))
180 const [family, ...version] = parts
181 if (!family || !/^[a-z]+$/.test(family) || !version.every(v => /^\d+$/.test(v))) return bare
182 const title = family[0]!.toUpperCase() + family.slice(1)
183 return version.length > 0 ? `${title} ${version.join('.')}` : title
184}
185
186/** The label beside the pixel Claude: `Opus 5.5 (mid)`, or the name alone while the effort is unknown. */
187export function modelLabel(id: string | undefined, effort: string | number | undefined): string | undefined {
188 if (!id) return undefined
189 const name = modelName(id)
190 if (effort === undefined) return name
191 return `${name} (${typeof effort === 'number' ? effort : (EFFORT_SHORT[effort] ?? effort)})`
192}
193
194/** Shown for the cache before the first request: nothing is cached yet. */
195export const CACHE_NONE = '–'
196
197/** What the chat chip shows besides its rings: tokens, cost and the cache's time left. */
198export type Extras = { tokens: number; costUsd: number; cache?: string; model?: string }
199
200/** What of the band fits: the rings kept and which of the chat's entries stay. */
201export type Shown = {
202 segments: Segment[]
203 hasTokens: boolean
204 hasCost: boolean
205 hasCache: boolean
206 hasModel: boolean
207 /** The chips' words `limits` and `chat`. */
208 hasLabels: boolean
209 /** The pixel Claude. */
210 hasSprite: boolean
211}
212
213/** Columns of a `Tk 4.6M` or `Co $6.79` entry: label, space, value. */
214const entryWidth = (value: string) => 2 + 1 + value.length
215
216/** Columns between two entries: their own gap around one blank. */
217const EXTRAS_GAP = 3
218
219/** The chat's entries that are shown, as `[label, value]`, in order. */
220export function entries(extras: Extras, shown: Shown): [string, string][] {
221 const out: [string, string][] = []
222 if (shown.hasTokens) out.push(['Tk', compact(extras.tokens)])
223 if (shown.hasCost) out.push(['Co', money(extras.costUsd)])
224 if (shown.hasCache) out.push(['Ca', extras.cache ?? CACHE_NONE])
225 return out
226}
227
228function extrasWidth(extras: Extras, shown: Shown): number {
229 const list = entries(extras, shown)
230 if (list.length === 0) return 0
231 return list.reduce((sum, [, value]) => sum + entryWidth(value), 0) + (list.length - 1) * EXTRAS_GAP
232}
233
234/** A chip: frame, label, then the items, each `GAP` apart; 0 without items. `extra` is the width of a trailing non-ring item. */
235function chipWidth(label: string | undefined, list: readonly Segment[], extra = 0): number {
236 const items = list.map(s => s.short.length + 1 + RING_COLUMNS + 1 + s.text.length)
237 if (extra > 0) items.push(extra)
238 if (items.length === 0) return 0
239 const labelWidth = label === undefined ? 0 : label.length + 1
240 return CHIP_FRAME + labelWidth + items.reduce((a, b) => a + b, 0) + (items.length - 1) * GAP
241}
242
243export function widthOf(extras: Extras, shown: Shown): number {
244 const { limits, chat } = groups(shown.segments)
245 const limitsChip = chipWidth(shown.hasLabels ? LABELS.limits : undefined, limits)
246 const chatChip = chipWidth(shown.hasLabels ? LABELS.chat : undefined, chat, extrasWidth(extras, shown))
247 const chips = limitsChip + chatChip + (limitsChip > 0 && chatChip > 0 ? CHIP_GAP : 0)
248 const sprite = shown.hasSprite ? SPRITE_COLUMNS : 0
249 const model = shown.hasModel && extras.model !== undefined ? MODEL_GAP + extras.model.length : 0
250 return chips + sprite + model
251}
252
253/** Columns between the pixel Claude and the model label. */
254export const MODEL_GAP = 1
255
256/**
257 * What of the band fits `columns`, dropping the least important first: the
258 * model label, the chips' words, the cache, the cost, the tokens, todos, the
259 * weekly ring, the pixel Claude, the context ring. The session ring goes
260 * last: null when not even it fits, and the band shows nothing.
261 */
262export function fit(list: readonly Segment[], extras: Extras, columns: number): Shown | null {
263 const shown: Shown = {
264 segments: [...list],
265 hasTokens: true,
266 hasCost: true,
267 hasCache: true,
268 hasModel: true,
269 hasLabels: true,
270 hasSprite: true,
271 }
272 const without = (id: SegmentId) => () => (shown.segments = shown.segments.filter(s => s.id !== id))
273 const drops: (() => void)[] = [
274 () => (shown.hasModel = false),
275 () => (shown.hasLabels = false),
276 () => (shown.hasCache = false),
277 () => (shown.hasCost = false),
278 () => (shown.hasTokens = false),
279 without('todos'),
280 without('week'),
281 () => (shown.hasSprite = false),
282 without('context'),
283 ]
284 for (const drop of drops) {
285 if (widthOf(extras, shown) <= columns) return shown
286 drop()
287 }
288 return widthOf(extras, shown) <= columns ? shown : null
289}
290
291// --- todos -------------------------------------------------------------------
292
293export function withCreated(todos: Todos, id: string): Todos {
294 return { ...todos, [id]: 'pending' }
295}
296
297export function withUpdated(todos: Todos, id: string, status: string | undefined): Todos {
298 if (status === 'deleted') {
299 const { [id]: _gone, ...rest } = todos
300 return rest
301 }
302 if (status === 'pending' || status === 'in_progress' || status === 'completed') {
303 return { ...todos, [id]: status }
304 }
305 return todos
306}
307
308export function fromList(list: readonly { id: string; status: string }[]): Todos {
309 const out: Todos = {}
310 for (const t of list) out[t.id] = t.status as TodoStatus
311 return out
312}
313
314/** TodoWrite replaces the whole list; its entries have no ids, so index them. */
315export function fromTodoWrite(list: readonly { status: string }[]): Todos {
316 const out: Todos = {}
317 list.forEach((t, i) => {
318 out[`todo-${i}`] = t.status as TodoStatus
319 })
320 return out
321}
322
323// --- usage-limits.json -------------------------------------------------------
324
325type Window = { usedPercent: number; resetsAt: string | null; status: null }
326
327export type Snapshot = {
328 session: Window | null
329 week: Window | null
330 updatedAt: string
331 model: string
332}
333
334/**
335 * The limits as `usage-limits.json` holds them for other tools; null when the
336 * engine has no limit reading, so a good snapshot is never overwritten by an
337 * empty one.
338 */
339export function snapshot(
340 rateLimits: readonly RateLimit[],
341 now: number,
342 model: string,
343): Snapshot | null {
344 const window = (kind: string): Window | null => {
345 const w = rateLimits.find(r => r.kind === kind)
346 return w ? { usedPercent: w.percentUsed, resetsAt: w.resetsAt ?? null, status: null } : null
347 }
348 const session = window('five_hour')
349 const week = window('seven_day')
350 if (!session && !week) return null
351 return { session, week, updatedAt: new Date(now).toISOString(), model }
352}
353hooks/ring.ts 105 lines1// Draws a donut as RGBA pixels: the filled arc starts at 12 o'clock and runs
2// clockwise in a whitened "hot" tone, the rest of the ring is the same color, dimmed.
3
4export const RING_SIZE = 32
5
6const OUTER = 15.5
7const INNER = 10
8const SAMPLES = 4
9const TRACK_ALPHA = 0.22
10/** How far the arc and the ring's text move toward white. */
11const CORE = 0.35
12
13export type Rgb = readonly [number, number, number]
14
15export function hexToRgb(hex: string): Rgb {
16 const n = Number.parseInt(hex.replace('#', ''), 16)
17 return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
18}
19
20function toWhite(rgb: Rgb, t: number): Rgb {
21 return [0, 1, 2].map(i => Math.round(rgb[i]! + (255 - rgb[i]!) * t)) as unknown as Rgb
22}
23
24/** The hot tone of `hex`, as the arc draws it; the ring's text uses it too. */
25export function hot(hex: string): string {
26 return `#${toWhite(hexToRgb(hex), CORE)
27 .map(c => c.toString(16).padStart(2, '0'))
28 .join('')}`
29}
30
31/** Fraction of the circle (0..1) at which the point lies, clockwise from 12. */
32function turnOf(x: number, y: number): number {
33 const angle = Math.atan2(x, -y)
34 return (angle < 0 ? angle + 2 * Math.PI : angle) / (2 * Math.PI)
35}
36
37/**
38 * The ring for `percent` (clamped to 0..100) as `RING_SIZE * RING_SIZE` RGBA
39 * pixels, edges antialiased by supersampling. A `scale` below 1 draws a smaller
40 * ring with empty room around it.
41 */
42export function ringPixels(percent: number, rgb: Rgb, scale = 1): Uint8Array {
43 const fill = Math.min(100, Math.max(0, percent)) / 100
44 const n = RING_SIZE
45 const lit = new Float32Array(n * n)
46 const track = new Float32Array(n * n)
47 const center = n / 2
48 const step = 1 / SAMPLES
49
50 for (let py = 0; py < n; py++) {
51 for (let px = 0; px < n; px++) {
52 let arc = 0
53 let rest = 0
54 for (let sy = 0; sy < SAMPLES; sy++) {
55 for (let sx = 0; sx < SAMPLES; sx++) {
56 const x = px + (sx + 0.5) * step - center
57 const y = py + (sy + 0.5) * step - center
58 const r = Math.hypot(x, y)
59 if (r > OUTER * scale || r < INNER * scale) continue
60 if (turnOf(x, y) < fill) arc++
61 else rest++
62 }
63 }
64 lit[py * n + px] = arc / (SAMPLES * SAMPLES)
65 track[py * n + px] = rest / (SAMPLES * SAMPLES)
66 }
67 }
68
69 const core = toWhite(rgb, CORE)
70 const pixels = new Uint8Array(n * n * 4)
71 for (let i = 0; i < n * n; i++) {
72 const ring = lit[i]! + track[i]! * TRACK_ALPHA
73 const alpha = Math.min(1, ring)
74 // The arc in its hot tone, the track in the plain color.
75 const w = alpha === 0 ? 0 : lit[i]! / alpha
76 for (let c = 0; c < 3; c++) pixels[i * 4 + c] = Math.round(core[c]! * w + rgb[c]! * (1 - w))
77 pixels[i * 4 + 3] = Math.round(alpha * 255)
78 }
79 return pixels
80}
81
82/** The same ring as a glyph, for terminals that cannot draw the picture. */
83export function ringGlyph(percent: number): string {
84 const glyphs = ['○', '◔', '◑', '◕', '●']
85 const p = Math.min(100, Math.max(0, percent))
86 return glyphs[Math.round(p / 25)] ?? '○'
87}
88
89const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
90
91/** Standard padded base64, as `Image` sources take it. */
92export function toBase64(bytes: Uint8Array): string {
93 let out = ''
94 for (let i = 0; i < bytes.length; i += 3) {
95 const a = bytes[i] ?? 0
96 const b = bytes[i + 1] ?? 0
97 const c = bytes[i + 2] ?? 0
98 const n = (a << 16) | (b << 8) | c
99 out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]!
100 out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63]! : '='
101 out += i + 2 < bytes.length ? ALPHABET[n & 63]! : '='
102 }
103 return out
104}
105types/index.d.ts 54 lines1export type Usage = {
2 /** five_hour window, percent used */
3 sessionUsed?: number
4 sessionResetsAt?: string
5 /** seven_day window, percent used */
6 weekUsed?: number
7 /** context window fill, percent */
8 contextUsed?: number
9}
10
11export type TodoStatus = 'pending' | 'in_progress' | 'completed'
12
13/** Task or todo id → its status. */
14export type Todos = Record<string, TodoStatus>
15
16/** Where the cache lifetime in use comes from: the default, a hit, or a miss. */
17export type TtlSource = 'assumed' | 'hit' | 'miss'
18
19export type Cache = {
20 /** When the last main-thread request was sent: the entry's lifetime starts there. */
21 lastAt?: number
22 /** The model that answered it; another model has a cache of its own. */
23 model?: string
24 /** Lifetime in milliseconds. */
25 ttl: number
26 source: TtlSource
27 /** Share of the last request's input that the cache served, 0..1. */
28 readShare?: number
29}
30
31/** The main loop's model and the effort its last request asked for. */
32export type Model = { id?: string; effort?: string | number }
33
34declare module 'claude-code' {
35 interface PluginState {
36 'usage-ring': {
37 usage: Usage
38 todos: Todos
39 now: number
40 /** Animation frame counter. */
41 frame: number
42 /** A turn of this session is running. */
43 isBusy: boolean
44 /** Tokens used since the session started, all four counts of every request, subagents included. */
45 tokens: number
46 /** Session cost so far in US dollars. */
47 costUsd: number
48 /** The prompt cache's countdown. */
49 cache: Cache
50 model: Model
51 }
52 }
53}
54