SLOPSHOPPER

usage-ring

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

newbandguardtimer
v1.0.1MITupdated 2026-10-04Oualid0/claude-mods/plugins/usage-ring
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-ring
› 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 ⟨Claude Code's own drawing⟩ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ limits Se ▣ ◔ 31% │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ │ limits Se ▣ ◔ 31% │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯
README

claude-mods

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.

Quick install

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.

Manual install

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.

Update

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.

Mods

PluginWhat it does
usage-ringTwo 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-boardA 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.

What the labels mean

LabelChipMeaning
WklimitsWeekly limit used, in percent.
SelimitsSession 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.
CxchatContext window used, in percent.
TdchatTodos done out of all, e.g. 3/5. Only while the chat has a todo list.
TkchatTokens this chat used since the session started (input, output, cache reads and writes, subagents included). Grows after every model request.
CochatWhat the session cost so far, in US dollars.
CachatTime 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.

Limits

  • Needs a Claude Code version with mods (function-hook plugins); tested with Claude Code 2.1.288. The mod API is early access and may change between versions.
  • Rings are pixel images in kitty and Ghostty; other terminals show a glyph instead.
  • Rings for the week, session and context turn red from 95%, and the cache time 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).
  • The board knows other sessions only as busy or idle; "needs input" is not available. Sessions without a name are hidden.
  • The board reads the session list from the 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.

Options

usage-ring has one option, off by default:

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

Development

  • Each plugin lives in 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/.
  • Check: claude plugin validate ., then claude plugin validate plugins/<name> and claude plugin test plugins/<name>.
  • Installed from a local clone, Claude Code reads the files in place: changes apply with /reload-plugins or the next session.
  • Both mods draw into the same band above the prompt; each render hook calls next(e) and keeps what is beneath (session-board on top, usage-ring next to the prompt).

License

MIT, see LICENSE.

Source 6 files
hooks/register.tsx 360 lines
1import { 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}
360
hooks/cache.ts 72 lines
1// 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}
72
hooks/claude.ts 155 lines
1// 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}
155
hooks/model.ts 353 lines
1// 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}
353
hooks/ring.ts 105 lines
1// 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}
105
types/index.d.ts 54 lines
1export 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