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

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:

In the Code tab of the Claude Desktop app:

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
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.
| Item | Meaning |
|---|---|
● 42m | Time 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:
| Color | Pace |
|---|---|
| green | 10 points or more below |
| gray | about on pace |
| orange | up to 15 points above |
| red | more 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.
claude -p.sh and tail. Elsewhere it assumes one hour.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.
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"
}
}
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.
hooks/register.tsx 427 lines1import { 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}
427hooks/model.ts 147 lines1import 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}
147types/index.d.ts 35 lines1export 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