SLOPSHOPPER

dashband

Prompt cache, context and plan limits at a glance, in the prompt footer and above the prompt

newbandspinnertoaststatusprocess
v0.1.0MITupdated 2026-10-09EgonLeitner/dashband
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dashband
› 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 ● prompt cache: waiting for the next response context 49% ■■■■■■■■■■■■■■■■■■■■■■■■ 97k tokens 5-hour limit 31%/80% ■■■■■■■■■■■■■■■■■■■│■■■■ resets in 1h 0m ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits · ● … · ctx 49% · 5h 31%/80%

Draws

Band
● prompt cache: waiting for the next response context 49% ■■■■■■■■■■■■■■■■■■■■■■■■ 97k tokens 5-hour limit 31%/80% ■■■■■■■■■■■■■■■■■■■│■■■■ resets in 1h 0m
README

dashband

A Claude Code mod that shows the prompt cache, the context window and your plan limits at a glance: in the prompt footer, and as a band with bars above the prompt.

In the terminal:

dashband in the terminal: a band with bars above the prompt and one colored item in the footer

In the Code tab of the Claude Desktop app:

dashband in the Claude Desktop app: the band above the prompt, cache and context in the status line, the limits in the footer

As text, the footer:

● 42m · ctx 31% · 5h 12%/40% · WL 70%/96%

and the band:

● prompt cache  42m       ■■■■■■■■■■■■■■■■■■■■■■■■ 100% hit · 1h cache
  context       31%       ■■■■■■■■■■■■■■■■■■■■■■■■ 310k tokens
  5-hour limit  12%/40%   ■■■■■■■■■■■■■■■■■■■■■■■■ resets in 3h 0m
  weekly limit  70%/96%   ■■■■■■■■■■■■■■■■■■■■■■■│ resets in 7h 0m

Why

Claude Code caches the beginning of each request. Reading from the cache is much cheaper than processing the context again. Once the cache expires, the next request has to write the whole context to the cache again, which is most expensive in long sessions. dashband tells you how much time is left before that happens, how full the context is, and whether your plan limits last until they reset.

What it shows

ItemMeaning
● 42mTime left until the prompt cache expires: green while more than 2 minutes are left, yellow in the last 2 minutes, gray cold once expired, hot while Claude is working
100% hit (band)Share of the last response's input tokens read from the cache
ctx 31%How full the context window is: green, orange from 75 %, red from 90 %
5h 12%/40%Five-hour limit: used, and where usage would be by now if spread evenly over the window
WL 70%/96%Weekly limit, the same way

The limits are colored by pace, used minus expected:

ColorPace
green10 points or more below
grayabout on pace
orangeup to 15 points above
redmore than 15 points above

In the terminal, the footer shows everything as one colored item. The Desktop app cuts a footer item short, so there cache and context appear in the status line, which the app labels with the mod's name and draws without colors, and the limits follow as a colored item. The band adds a bar for each item. On the limit bars, │ marks where usage would be by now. Two minutes before the cache expires, the cache turns yellow and a notice stays for a minute, saying how many tokens the next request would write again.

The cache lifetime, five minutes or one hour, is read from the session transcript. The plan limits appear only on a Claude subscription.

Requirements

  • Claude Code with mod support (2.1.287 or later); tested with Claude Code 2.1.289 in the terminal and the Claude Desktop app 2.31226
  • The terminal (CLI) or the Code tab of the Claude Desktop app. Mods do not draw in the VS Code extension, on mobile, or in claude -p.
  • macOS or Linux for the cache lifetime: dashband reads the transcript with sh and tail. Elsewhere it assumes one hour.

Installation

dashband is listed in the egonleitner marketplace:

claude plugin marketplace add EgonLeitner/claude-code-mods
claude plugin install dashband@egonleitner

Run /reload-plugins in an open session, or start a new one.

Without a marketplace

Load the folder directly for one session:

claude --plugin-dir /path/to/dashband

To load it in every session, including the Desktop app, add the folder to env in ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/dashband"
  }
}

Limitations

  • The countdown estimates expiry from the time of the last response. It cannot see the cache on the server.
  • Desktop app: the band shows in one chat at a time. With several chats open, the app asks only the active one for the band, and chats that were open when the app started may never show it. This is a known issue of the app (anthropics/claude-code#99265). The footer item shows in every chat; the terminal is not affected.
  • Desktop app: after an app start, a chat shows dashband once you have sent a message in it. The app starts Claude Code for a chat only then.

Development

claude plugin validate .
claude plugin test .

tsconfig.json extends the type declarations Claude Code writes to .claude-plugin/types/ when it loads the mod. That folder is not part of the repository; load the mod once to create it.

To trace what dashband receives and draws, create /tmp/dashband-trace. Each session then writes a log there; remove the folder to stop.

Built with the help of Claude Code.

License

MIT

Source 3 files
hooks/register.tsx 427 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
3
4import type { CacheReading, LimitReading, UsageReading } from '../types'
5import {
6  HOUR,
7  LIMIT_LABEL,
8  LIMIT_NAME,
9  MINUTE,
10  PACE_COLOR,
11  WARN_BEFORE_MS,
12  WINDOW_MS,
13  bar,
14  cacheColor,
15  cacheLeftMs,
16  contextColor,
17  currentLimit,
18  duration,
19  expectedPercent,
20  hitRatio,
21  pace,
22  parseResetsAt,
23  readingFromTranscript,
24} from './model'
25
26const TICK_MS = 15_000
27const BAR_WIDTH = 24
28const TAIL_BYTES = 262_144
29
30const cache = atom({ plugin: 'dashband', key: 'cache' } as const, null)
31const usage = atom({ plugin: 'dashband', key: 'usage' } as const, null)
32const working = atom({ plugin: 'dashband', key: 'working' } as const, false)
33const now = atom({ plugin: 'dashband', key: 'now' } as const, 0)
34
35// Optional trace for troubleshooting: on while this directory exists
36// (`mkdir /tmp/dashband-trace`). Each session writes its own log there. Checked on every
37// entry, because writing a log would otherwise recreate the directory after it was removed.
38const TRACE_DIR = '/tmp/dashband-trace'
39const TRACE_MAX_LINES = 500
40const traceLines: string[] = []
41
42const trace = async ($: EngineInterface, message: string) => {
43  if (!(await $.fs.exists(TRACE_DIR))) return
44
45  traceLines.push(`${new Date(await $.clock.now()).toISOString()} ${message}`)
46  if (traceLines.length > TRACE_MAX_LINES) traceLines.shift()
47  await $.fs.write(`${TRACE_DIR}/${await $.session.id()}.log`, traceLines.join('\n') + '\n')
48}
49
50let transcriptPath: string | null = null
51// The transcript may not hold the last response yet when its turn completes: look again on the
52// next ticks until the entry shows up.
53const TTL_LOOKUPS = 10
54// The entry usually lands within seconds: look again soon before falling back to the ticks.
55const TTL_QUICK_LOOKUPS_MS = [1_500, 4_000]
56let ttlPending: { after: number; lookups: number } | null = null
57// Time of the newest transcript entry already read, to tell a new one apart.
58let transcriptSeenAt = 0
59let statusShown: string | undefined
60const warned = new Set<number>()
61// Turns that started and have not completed. turn.start does not say whether a turn is the main
62// conversation's, so working means any turn is running; every start is matched by its completion.
63const activeTurns = new Set<string>()
64const renders: Record<string, number> = {}
65
66const toUsage = (context: SessionContextUsage, limits: readonly SessionRateLimit[]): UsageReading => ({
67  contextPercent: context.percent ?? null,
68  contextTokens: context.tokens ?? null,
69  limits: limits.map(l => ({
70    kind: l.kind,
71    used: l.percentUsed,
72    resetsAt: parseResetsAt(l.resetsAt),
73  })),
74})
75
76// Finds the transcript by session id when no classic SessionStart told us its path,
77// as after a hot reload.
78const findTranscript = async ($: EngineInterface): Promise<string | null> => {
79  const script = 'ls "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/projects/*/"$1".jsonl 2>/dev/null | head -1'
80  const result = await $.process.run(['sh', '-c', script, 'sh', await $.session.id()])
81  return result.stdout.trim() || null
82}
83
84// Reads the end of the transcript; the whole file can exceed what $.fs.read takes.
85const transcriptReading = async ($: EngineInterface): Promise<CacheReading | null> => {
86  if (!transcriptPath) {
87    try {
88      transcriptPath = await findTranscript($)
89    } catch {
90      return null
91    }
92  }
93  if (!transcriptPath) return null
94  try {
95    const result = await $.process.run(['tail', '-c', String(TAIL_BYTES), transcriptPath])
96    return result.exitCode === 0 ? readingFromTranscript(result.stdout) : null
97  } catch {
98    return null
99  }
100}
101
102// Plan limits are per account, and a session only learns them with its first response.
103// Every session shares its latest reading through the store and fills in from it.
104const LIMITS_KEY = 'limits'
105type StoredLimits = { at: number; limits: LimitReading[] }
106let limitsAt = 0
107
108const withSharedLimits = async ($: EngineInterface, reading: UsageReading): Promise<UsageReading> => {
109  const t = await $.clock.now()
110  const isComplete = reading.limits.length > 0 && reading.limits.every(l => l.resetsAt !== null)
111  if (isComplete) {
112    limitsAt = t
113    await $.store.set(LIMITS_KEY, { at: t, limits: reading.limits })
114    return reading
115  }
116  // No limits yet, or some without a readable reset time: a stored reading is the better one.
117  const stored = (await $.store.get(LIMITS_KEY)) as StoredLimits | undefined
118  const valid = stored?.limits.filter(l => l.resetsAt !== null && l.resetsAt > t) ?? []
119  return valid.length > 0 ? { ...reading, limits: valid } : reading
120}
121
122// Takes a fresher reading another session stored.
123const pullSharedLimits = async ($: EngineInterface) => {
124  const stored = (await $.store.get(LIMITS_KEY)) as StoredLimits | undefined
125  if (!stored || stored.at <= limitsAt) return
126  limitsAt = stored.at
127  await update($, usage, prev => ({
128    contextPercent: prev?.contextPercent ?? null,
129    contextTokens: prev?.contextTokens ?? null,
130    limits: stored.limits,
131  }))
132}
133
134// Reads the cache lifetime of the last response, once its entry is in the transcript.
135const lookUpTtl = async ($: EngineInterface) => {
136  if (ttlPending === null) return
137  ttlPending.lookups++
138  const reading = await transcriptReading($)
139  const isCurrent = reading !== null && reading.at > ttlPending.after
140  if (reading && isCurrent) {
141    transcriptSeenAt = reading.at
142    await update($, cache, prev => (prev ? { ...prev, ttlMs: reading.ttlMs } : reading))
143  }
144  await trace($, `ttl lookup #${ttlPending.lookups} found=${isCurrent} ttl=${reading ? reading.ttlMs / MINUTE : '?'}m`)
145  if (isCurrent || ttlPending.lookups >= TTL_LOOKUPS) ttlPending = null
146}
147
148const refreshUsage = async ($: EngineInterface) => {
149  const u = await $.session.usage()
150  const reading = await withSharedLimits($, toUsage(u.context, u.rateLimits))
151  await update($, usage, () => reading)
152}
153
154const warnBeforeExpiry = async ($: EngineInterface, t: number) => {
155  const reading = await read($, cache)
156  if (reading === null || (await read($, working))) return
157
158  const left = cacheLeftMs(reading, t)
159  if (left > 0 && left <= WARN_BEFORE_MS && !warned.has(reading.at)) {
160    if (warned.size > 100) warned.clear()
161    warned.add(reading.at)
162    const tokens = (await read($, usage))?.contextTokens
163    const rewrite = tokens ? `; the next request rewrites about ${Math.round(tokens / 1000)}k tokens` : ''
164    $.ui.toast(`Prompt cache expires in ${Math.ceil(left / MINUTE)} min${rewrite}`, { timeoutMs: 60_000 })
165    await trace($, `toast left=${Math.round(left / 1000)}s`)
166  }
167}
168
169// Status line for the desktop app, which cuts a footer item at about 28 characters: cache and
170// context go here. The terminal shows a status line as a warning and has room in the footer,
171// so there everything goes into the footer item.
172const pushStatus = async ($: EngineInterface) => {
173  if (!(await $.session.surfaces()).includes('desktop')) {
174    if (statusShown !== undefined) {
175      statusShown = undefined
176      $.ui.status(undefined)
177    }
178    return
179  }
180  const t = await $.clock.now()
181  const reading = await read($, cache)
182  const u = await read($, usage)
183  let text: string
184  if (await read($, working)) {
185    text = '● hot'
186  } else if (reading === null) {
187    text = '● …'
188  } else {
189    const left = cacheLeftMs(reading, t)
190    text = `● ${left > 0 ? `${Math.ceil(left / MINUTE)}m` : 'cold'}`
191  }
192  if (u?.contextPercent != null) text += ` · ctx ${u.contextPercent}%`
193  // Separates the status line from the footer item that follows it.
194  if (u?.limits.length) text += ' ·'
195  if (text !== statusShown) {
196    statusShown = text
197    $.ui.status(text)
198  }
199}
200
201const countRender = async ($: EngineInterface, site: string, surface: string) => {
202  renders[site] = (renders[site] ?? 0) + 1
203  const n = renders[site]
204  if (n <= 3 || n % 50 === 0) await trace($, `render ${site} #${n} surface=${surface}`)
205}
206
207export const register: Register = on => {
208  on('classic.SessionStart', async ($, e, next) => {
209    // Set the clock first: the band may draw before session.start runs.
210    const t = await $.clock.now()
211    await update($, now, () => t)
212    transcriptPath = e.transcript_path
213    const reading = await transcriptReading($)
214    transcriptSeenAt = reading?.at ?? 0
215    await update($, cache, () => reading)
216    await trace($, `classic.SessionStart source=${e.source} reading=${reading !== null}`)
217    return next(e)
218  })
219
220  on('session.start', async ($, e, next) => {
221    const t0 = await $.clock.now()
222    await update($, now, () => t0)
223    $.clock.every(TICK_MS, async () => {
224      const t = await $.clock.now()
225      await update($, now, () => t)
226      await lookUpTtl($)
227      await warnBeforeExpiry($, t)
228      await pullSharedLimits($)
229      await pushStatus($)
230    })
231    await refreshUsage($)
232    if ((await read($, cache)) === null) {
233      const reading = await transcriptReading($)
234      transcriptSeenAt = reading?.at ?? 0
235      await update($, cache, prev => prev ?? reading)
236    }
237    await pushStatus($)
238    await trace($, `session.start surfaces=${(await $.session.surfaces()).join(',')} transcript=${transcriptPath !== null}`)
239    return next(e)
240  })
241
242  on('session.attach', async ($, e, next) => {
243    await trace($, `attach surface=${e.surface} client=${e.clientId}`)
244    return next(e)
245  })
246
247  on('session.detach', async ($, e, next) => {
248    await trace($, `detach client=${e.clientId}`)
249    return next(e)
250  })
251
252  on('session.measure', async ($, e, next) => {
253    const reading = await withSharedLimits($, toUsage(e.context, e.rateLimits))
254    await update($, usage, () => reading)
255    await pushStatus($)
256    return next(e)
257  })
258
259  on('turn.start', async ($, e, next) => {
260    activeTurns.add(e.turnId)
261    await update($, working, () => true)
262    await pushStatus($)
263    return next(e)
264  })
265
266  on('turn.step', async function* ($, e, next) {
267    const result = yield* next(e)
268    if (!e.agentId && result.usage) {
269      const u = result.usage
270      const t = await $.clock.now()
271      await update($, cache, prev => ({ at: t, hit: hitRatio(u), ttlMs: prev?.ttlMs ?? HOUR }))
272      await update($, now, () => t)
273    }
274    return result
275  })
276
277  on('turn.complete', async ($, e, next) => {
278    activeTurns.delete(e.turnId)
279    await update($, working, () => activeTurns.size > 0)
280    if (!e.agentId) {
281      ttlPending = { after: transcriptSeenAt, lookups: 0 }
282      await lookUpTtl($)
283      for (const ms of TTL_QUICK_LOOKUPS_MS) {
284        $.clock.after(ms, () => void lookUpTtl($))
285      }
286      await refreshUsage($)
287    }
288    await pushStatus($)
289    return next(e)
290  })
291
292  // Footer item, drawn in every chat: the plan limits, colored by pace; in the terminal also
293  // cache and context, which the desktop app shows in the status line instead.
294  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
295    const { Box, Text } = $.ui.resolve(e)
296    await countRender($, 'SessionMode', e.surface)
297    const t = await read($, now)
298    const u = await read($, usage)
299    const modes = e.props.modes.join(' & ')
300
301    const parts = []
302    if (e.surface === 'terminal') {
303      const reading = await read($, cache)
304      if (await read($, working)) {
305        parts.push(<Text color="green">● hot</Text>)
306      } else if (reading === null) {
307        parts.push(<Text dimColor>● …</Text>)
308      } else {
309        const left = cacheLeftMs(reading, t)
310        parts.push(<Text color={cacheColor(left)}>● {left > 0 ? `${Math.ceil(left / MINUTE)}m` : 'cold'}</Text>)
311      }
312      if (u?.contextPercent != null) {
313        parts.push(<Text dimColor> · </Text>)
314        parts.push(<Text color={contextColor(u.contextPercent)}>ctx {u.contextPercent}%</Text>)
315      }
316    }
317    for (const limit of (u?.limits ?? []).map(l => currentLimit(l, t))) {
318      const name = LIMIT_LABEL[limit.kind]
319      const windowMs = WINDOW_MS[limit.kind]
320      if (!name || !windowMs) continue
321      if (parts.length) parts.push(<Text dimColor> · </Text>)
322      if (limit.resetsAt === null) {
323        parts.push(<Text dimColor>{name} {Math.round(limit.used)}%</Text>)
324        continue
325      }
326      const expected = expectedPercent(windowMs, limit.resetsAt, t)
327      parts.push(
328        <Text color={PACE_COLOR[pace(limit.used, expected)]}>
329          {name} {Math.round(limit.used)}%/{Math.round(expected)}%
330        </Text>,
331      )
332    }
333    if (parts.length === 0) return next(e)
334
335    return (
336      <Box>
337        {modes ? <Text dimColor>{modes} · </Text> : null}
338        {parts}
339      </Box>
340    )
341  })
342
343  // Band above the prompt: bars and details. The desktop app draws it in one chat at a time.
344  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
345    if (e.props.hasSurvey) return next(e)
346    const { Box, Text } = $.ui.resolve(e)
347    await countRender($, 'AbovePrompt', e.surface)
348    const t = await read($, now)
349    const reading = await read($, cache)
350    const isWorking = await read($, working)
351    const u = await read($, usage)
352
353    const cells = (used: number, color: string, expected?: number) => {
354      const { filled, marker } = bar(BAR_WIDTH, used, expected)
355      const fill = color === 'gray' ? 'green' : color
356      return Array.from({ length: BAR_WIDTH }, (_, i) =>
357        i === marker ? <Text>│</Text> : i < filled ? <Text color={fill}>■</Text> : <Text dimColor>■</Text>,
358      )
359    }
360    // Fixed columns: the desktop app draws proportional text, so padding with spaces does not align.
361    const label = (text: string, color?: string) => (
362      <Box width={15}>{color ? <Text color={color}>{text}</Text> : <Text dimColor>{text}</Text>}</Box>
363    )
364    const value = (text: string, color: string) => (
365      <Box width={10}>
366        <Text color={color}>{text}</Text>
367      </Box>
368    )
369
370    const rows = []
371    if (reading === null) {
372      rows.push(<Text dimColor>● prompt cache: waiting for the next response</Text>)
373    } else {
374      const left = isWorking ? reading.ttlMs : cacheLeftMs(reading, t)
375      const color = isWorking ? 'green' : cacheColor(left)
376      const status = isWorking ? 'hot' : left > 0 ? `${Math.ceil(left / MINUTE)}m` : 'cold'
377      rows.push(
378        <Box>
379          {label('● prompt cache', color)}
380          {value(status, color)}
381          {cells((left / reading.ttlMs) * 100, color)}
382          <Text dimColor> {Math.round(reading.hit * 100)}% hit · {reading.ttlMs >= HOUR ? '1h' : '5m'} cache</Text>
383        </Box>,
384      )
385    }
386    if (u?.contextPercent != null) {
387      const color = contextColor(u.contextPercent)
388      rows.push(
389        <Box>
390          {label('  context')}
391          {value(`${u.contextPercent}%`, color)}
392          {cells(u.contextPercent, color)}
393          {u.contextTokens ? <Text dimColor> {Math.round(u.contextTokens / 1000)}k tokens</Text> : null}
394        </Box>,
395      )
396    }
397    for (const limit of (u?.limits ?? []).map(l => currentLimit(l, t))) {
398      const name = LIMIT_NAME[limit.kind]
399      const windowMs = WINDOW_MS[limit.kind]
400      if (!name || !windowMs) continue
401      if (limit.resetsAt === null) {
402        rows.push(
403          <Box>
404            {label(`  ${name}`)}
405            {value(`${Math.round(limit.used)}%`, 'gray')}
406            {cells(limit.used, 'gray')}
407            <Text dimColor> starts with the next request</Text>
408          </Box>,
409        )
410        continue
411      }
412      const expected = expectedPercent(windowMs, limit.resetsAt, t)
413      const color = PACE_COLOR[pace(limit.used, expected)]
414      rows.push(
415        <Box>
416          {label(`  ${name}`)}
417          {value(`${Math.round(limit.used)}%/${Math.round(expected)}%`, color)}
418          {cells(limit.used, color, expected)}
419          <Text dimColor> resets in {duration(limit.resetsAt - t)}</Text>
420        </Box>,
421      )
422    }
423
424    return <Box flexDirection="column">{rows}</Box>
425  })
426}
427
hooks/model.ts 147 lines
1import type { CacheReading, LimitReading } from '../types'
2
3export const MINUTE = 60_000
4export const HOUR = 60 * MINUTE
5
6// Length of each rate-limit window the band knows how to pace.
7export const WINDOW_MS: Record<string, number> = {
8  five_hour: 5 * HOUR,
9  seven_day: 7 * 24 * HOUR,
10}
11
12export const LIMIT_LABEL: Record<string, string> = {
13  five_hour: '5h',
14  seven_day: 'WL',
15}
16
17// Spelled-out names for the band, where there is room.
18export const LIMIT_NAME: Record<string, string> = {
19  five_hour: '5-hour limit',
20  seven_day: 'weekly limit',
21}
22
23// Pace thresholds in percentage points: used minus where usage should be by now.
24export const PACE_UNDER = -10
25export const PACE_OVER = 5
26export const PACE_FAR_OVER = 15
27
28export type Pace = 'under' | 'on' | 'over' | 'far-over'
29
30export const PACE_COLOR: Record<Pace, string> = {
31  under: 'green',
32  on: 'gray',
33  over: '#e8912d',
34  'far-over': 'red',
35}
36
37// A reset time as milliseconds since the epoch, from an ISO timestamp or epoch seconds or
38// milliseconds; null when it cannot be read.
39export const parseResetsAt = (value: unknown): number | null => {
40  if (value === null || value === undefined || value === '') return null
41  const text = String(value).trim()
42  const n = typeof value === 'number' || /^\d+(\.\d+)?$/.test(text) ? Number(text) : Date.parse(text)
43  if (!Number.isFinite(n) || n <= 0) return null
44  return n < 1e12 ? n * 1000 : n
45}
46
47// A limit as it stands now. A reading taken before its window reset is stale: the weekly window
48// restarts at the same time a week later, a five-hour window only with the next request.
49export const currentLimit = (limit: LimitReading, now: number): LimitReading => {
50  if (limit.resetsAt === null || limit.resetsAt > now) return limit
51  const windowMs = WINDOW_MS[limit.kind]
52  if (limit.kind !== 'seven_day' || !windowMs) return { ...limit, used: 0, resetsAt: null }
53  let resetsAt = limit.resetsAt
54  while (resetsAt <= now) resetsAt += windowMs
55  return { ...limit, used: 0, resetsAt }
56}
57
58// Where usage of a window should be by now if it were spread evenly, 0 to 100.
59export const expectedPercent = (windowMs: number, resetsAt: number, now: number): number => {
60  const elapsed = windowMs - (resetsAt - now)
61  return Math.min(100, Math.max(0, (elapsed / windowMs) * 100))
62}
63
64export const pace = (used: number, expected: number): Pace => {
65  const ahead = used - expected
66  if (ahead <= PACE_UNDER) return 'under'
67  if (ahead <= PACE_OVER) return 'on'
68  if (ahead <= PACE_FAR_OVER) return 'over'
69  return 'far-over'
70}
71
72export const cacheLeftMs = (reading: CacheReading, now: number): number =>
73  Math.max(0, reading.ttlMs - (now - reading.at))
74
75// How long before the cache expires the band turns yellow and a notice shows.
76export const WARN_BEFORE_MS = 2 * MINUTE
77
78export const cacheColor = (leftMs: number): string =>
79  leftMs <= 0 ? 'gray' : leftMs <= WARN_BEFORE_MS ? 'yellow' : 'green'
80
81export const contextColor = (percent: number): string =>
82  percent >= 90 ? 'red' : percent >= 75 ? '#e8912d' : 'green'
83
84type Usage = {
85  input_tokens?: number
86  cache_read_input_tokens?: number
87  cache_creation_input_tokens?: number
88  cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number } | null
89}
90
91export const hitRatio = (u: Usage): number => {
92  const read = u.cache_read_input_tokens ?? 0
93  const total = (u.input_tokens ?? 0) + read + (u.cache_creation_input_tokens ?? 0)
94  return total ? read / total : 0
95}
96
97// The cache lifetime a response wrote with, or null when it wrote nothing.
98export const ttlFromUsage = (u: Usage): number | null => {
99  const c = u.cache_creation
100  if (!c) return null
101  if ((c.ephemeral_1h_input_tokens ?? 0) > 0) return HOUR
102  if ((c.ephemeral_5m_input_tokens ?? 0) > 0) return 5 * MINUTE
103  return null
104}
105
106// The last main-thread response in the tail of a transcript (JSON lines), with the
107// cache lifetime of the latest response that wrote to the cache.
108export const readingFromTranscript = (tail: string): CacheReading | null => {
109  let reading: CacheReading | null = null
110  const lines = tail.split('\n')
111  for (let i = lines.length - 1; i >= 0; i--) {
112    let entry: { type?: string; isSidechain?: boolean; timestamp?: string; message?: { usage?: Usage } }
113    try {
114      entry = JSON.parse(lines[i] ?? '')
115    } catch {
116      continue
117    }
118    const usage = entry.message?.usage
119    if (entry.type !== 'assistant' || entry.isSidechain || !usage) continue
120    if (reading === null) {
121      const at = Date.parse(entry.timestamp ?? '')
122      if (Number.isNaN(at)) continue
123      reading = { at, hit: hitRatio(usage), ttlMs: HOUR }
124    }
125    const ttl = ttlFromUsage(usage)
126    if (ttl !== null) return { ...reading, ttlMs: ttl }
127  }
128  return reading
129}
130
131// A short, locale-free duration: `3d 4h`, `2h 15m`, `9m`.
132export const duration = (ms: number): string => {
133  const minutes = Math.max(0, Math.round(ms / MINUTE))
134  const days = Math.floor(minutes / 1440)
135  const hours = Math.floor((minutes % 1440) / 60)
136  if (days > 0) return `${days}d ${hours}h`
137  if (hours > 0) return `${hours}h ${minutes % 60}m`
138  return `${minutes}m`
139}
140
141// A bar of `width` cells: `filled` cells used, and the cell where usage should be, if any.
142export const bar = (width: number, used: number, expected?: number) => {
143  const filled = Math.round((Math.min(100, Math.max(0, used)) / 100) * width)
144  const marker = expected === undefined ? -1 : Math.min(width - 1, Math.round((expected / 100) * width))
145  return { filled, marker }
146}
147
types/index.d.ts 35 lines
1export type CacheReading = {
2  // When the last main-thread response arrived, in milliseconds since the epoch.
3  at: number
4  // Share of that response's input tokens read from the prompt cache, 0 to 1.
5  hit: number
6  // Lifetime of the prompt cache the session writes: five minutes or one hour.
7  ttlMs: number
8}
9
10export type LimitReading = {
11  // `five_hour`, `seven_day` or a gateway's `spend_limit`.
12  kind: string
13  // Percent of the window used, 0 to 100.
14  used: number
15  // When the window resets, in milliseconds since the epoch.
16  resetsAt: number | null
17}
18
19export type UsageReading = {
20  contextPercent: number | null
21  contextTokens: number | null
22  limits: LimitReading[]
23}
24
25declare module 'claude-code' {
26  interface PluginState {
27    dashband: {
28      cache: CacheReading | null
29      usage: UsageReading | null
30      working: boolean
31      now: number
32    }
33  }
34}
35