Context window breakdown and plan usage, in the band above the prompt.

Two mods for Claude Code for people who run many sessions at once: one line above the prompt that tells you what this session is costing, and one pane beside the chat that tells you what every other session is doing.
<img src="docs/demo-light.gif" alt="A walkthrough: click the bar to see the context breakdown, click PRs to open the sessions pane, open a session, ask about it, ask about everything in flight, unfold the done ones.">
Unofficial. Not affiliated with Anthropic.
context-card: one line above the prompt<img src="docs/card-light.png" alt="The context card with its breakdown open.">
| On the line | Meaning |
|---|---|
27% 272.3k / 1M | How full the context window is. Turns amber at 60%, red at 80%. |
● ◐ ○ | The prompt cache: fresh, past half its life, expired. Sending a message on an expired cache re-reads the whole context at full price. |
| The thin bar | The same percentage, colored by what is using it. |
weekly 20% ↻ 4d 21h | Your plan's weekly usage and when it resets, read live from your account. |
PRs | Opens the sessions pane. A number beside it counts sessions waiting on you. |
Click anywhere on the line to open the breakdown (messages, tools, MCP, skills, system, memory), the cache's minutes left, and where auto-compact will run. Click again to close it.
pr-pane: the sessions pane<img src="docs/sessions-light.png" alt="The sessions pane docked beside the chat.">
Every session in the same sidebar group, sorted by what it needs from you:
| Section | What is in it |
|---|---|
| Needs you | CI failing, conflicts, changes requested, ready to merge, or idle with nothing to show. |
| Working | Sessions running right now. |
| Done | Merged or closed. Folded until you click the heading. |
| Loose worktrees | Worktrees no session holds, and whether they have uncommitted changes. |
Each row has one time, on the right: how long it has been in the state its status names (working for 47m, merged 2h ago).
| Control | What it does |
|---|---|
| Click a session | Shows its worktree, branch, PR and activity. |
| Ask here | Asks, in the session you are in, for that session's status. Claude reads the other session's transcript and checks its PR; the other session is not messaged or interrupted. |
| Ask N working | The same, for every session that is running. |
#45, PR ↗ | Opens the pull request on GitHub. |
↻ | Refreshes now. The pane also refreshes itself every 15 seconds. |
| A section heading | Folds or unfolds the section. |
Outside any project, the pane shows every project's sessions as one overview.
Both mods together, from this repository:
/plugin marketplace add Nongfsq/frank-claude-cockpit
/plugin install context-card@frank-claude-cockpit
/plugin install pr-pane@frank-claude-cockpit
Or copy the two folders into ~/.claude/skills/ and start a new session:
git clone https://github.com/Nongfsq/frank-claude-cockpit
cp -R frank-claude-cockpit/context-card frank-claude-cockpit/pr-pane ~/.claude/skills/
Install both: the PRs button lives in context-card and appears only when pr-pane is present.
ui.render, $.state); they were built and tested on 2.1.286.context-card's weekly usage needs a Claude subscription login. With an API key the card still shows context, without the weekly figure.pr-pane needs the Claude desktop app, which is where the session list comes from; in a plain terminal it says the list is unavailable. PR and CI state come from the gh CLI, logged in.context-card asks api.anthropic.com for your own plan usage with the login Claude Code already holds, at most once every two minutes however many sessions are open: one session asks and the rest read its answer from ~/.claude/cache/context-card-usage.json. That request uses no tokens.pr-pane runs git and gh locally, in the project's folder.Nothing else is sent anywhere.
claude plugin validate context-card
claude plugin test context-card
claude plugin test pr-pane
Types for an editor: run /plugin-types inside a mod's folder.
The demo above is a scripted page, docs/demo/index.html; open it in a browser to watch it loop, or add ?theme=light. To re-record it:
cd docs/demo && npm i puppeteer-core
node record.mjs frames dark 15
ffmpeg -framerate 15 -i frames/f%04d.png -vf "scale=1200:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=128:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" ../demo-dark.gif
If this project is useful to you, you can buy me a coffee.
<a href="https://buymeacoffee.com/frankmenger"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="44"></a>
hooks/register.tsx 510 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Card, Limit, Slice } from '../types'
5
6const card = atom({ plugin: 'context-card', key: 'card' } as const, null)
7// Sessions that need the user, as the sessions pane counts them.
8const attention = atom({ plugin: 'pr-pane', key: 'attention' } as const, 0)
9
10// Presses of the PRs button. The sessions pane hooks this write and toggles itself, which works mid-turn,
11// where running its command would wait for the turn to end.
12const paneAsk = atom({ plugin: 'context-card', key: 'paneAsk' } as const, 0)
13
14const LINE_CELLS = 14
15// Whether the breakdown is open: a click on the line toggles it.
16const isOpen = atom({ plugin: 'context-card', key: 'isOpen' } as const, false)
17
18const LOCAL_MS = 15_000
19const HOUR = 3_600_000
20
21const FIVE_MINUTES = 300_000
22const TAIL_BYTES = 400_000
23
24// The prompt cache: when the last response came back, and how long the cache lives. An hour until the
25// session's own record says otherwise: five minutes with an API key or once usage credits are being drawn.
26const cache = atom({ plugin: 'context-card', key: 'cache' } as const, { lastReplyAt: 0, ttlMs: HOUR })
27
28const markReply = async ($: EngineInterface) => {
29 const now = await $.clock.now()
30 await update($, cache, was => ({ ...was, lastReplyAt: now }))
31}
32
33// Each response is recorded with what it wrote to the cache at each lifetime; the newest one that wrote any
34// tells which lifetime this session's cache has. Null when the end of the record names neither.
35const writtenTtl = (tail: string) => {
36 for (const line of tail.split('\n').reverse()) {
37 try {
38 const wrote = (JSON.parse(line) as { message?: { usage?: { cache_creation?: Record<string, unknown> } } }).message?.usage
39 ?.cache_creation
40
41 if (Number(wrote?.ephemeral_1h_input_tokens) > 0) {
42 return HOUR
43 }
44
45 if (Number(wrote?.ephemeral_5m_input_tokens) > 0) {
46 return FIVE_MINUTES
47 }
48 } catch {
49 // The first line is cut where the tail starts, and not every line is a response.
50 }
51 }
52
53 return null
54}
55
56const measureTtl = async ($: EngineInterface, transcriptPath: string) => {
57 if (transcriptPath === '') {
58 return
59 }
60
61 const tail = await $.process.run(['tail', '-c', String(TAIL_BYTES), transcriptPath]).catch(() => null)
62 const ttlMs = tail === null || tail.exitCode !== 0 ? null : writtenTtl(tail.stdout)
63
64 if (ttlMs !== null) {
65 await update($, cache, was => ({ ...was, ttlMs }))
66 }
67}
68
69// Full while the cache is fresh, half once most of its life is gone, an empty ring once it has expired. Only
70// these three: the quarter pies come from another font on desktop, smaller and off the line's centre.
71const cacheGlyph = (left: number, ttl: number) => (left <= 0 ? '○' : left > ttl * 0.5 ? '●' : '◐')
72
73const minutesLeft = (left: number) => (left >= 60_000 ? `${Math.ceil(left / 60_000)}m` : `${Math.max(1, Math.ceil(left / 1000))}s`)
74
75// Opening the breakdown also refreshes it, so what opens is current; neither call spends tokens.
76const toggleOpen = async ($: EngineInterface) => {
77 const opening = !(await read($, isOpen))
78 await update($, isOpen, () => opening)
79
80 if (opening) {
81 await Promise.all([refresh($), poll($).catch(() => {})])
82 }
83}
84
85// Labels per /context row, matched on a lowercase fragment of its name. Colors come from the row itself:
86// /context's own theme keys, so the bar matches /context and follows the light and dark themes.
87const ROWS: { match: string; label: string }[] = [
88 { match: 'system prompt', label: 'system' },
89 { match: 'mcp', label: 'mcp' },
90 { match: 'tool', label: 'tools' },
91 { match: 'agent', label: 'agents' },
92 { match: 'memory', label: 'memory' },
93 { match: 'skill', label: 'skills' },
94 { match: 'message', label: 'messages' },
95 { match: 'slash', label: 'commands' },
96]
97
98const LIMIT_LABELS: Record<string, string> = { seven_day: 'weekly', five_hour: '5h', spend_limit: 'spend' }
99const LIMIT_ORDER = ['seven_day']
100
101const compact = (tokens: number) => {
102 if (tokens < 1000) {
103 return String(tokens)
104 }
105
106 const [value, unit] = tokens < 999_950 ? [tokens / 1000, 'k'] : [tokens / 1_000_000, 'M']
107
108 return `${value.toFixed(1).replace(/\.0$/, '')}${unit}`
109}
110
111// Each figure has its own theme hue, so the two percentages never read alike; near a cap the hue gives way to
112// warning or error. Theme keys resolve per theme: claude is terracotta and suggestion is blue in light, dark and
113// ANSI themes alike, each legible on its background.
114const fillColor = (percent: number) => (percent >= 80 ? 'error' : percent >= 60 ? 'warning' : 'claude')
115const limitColor = (percent: number) => (percent >= 90 ? 'error' : percent >= 75 ? 'warning' : 'suggestion')
116
117const resetsIn = (resetsAt: string | undefined, now: number) => {
118 if (resetsAt === undefined) {
119 return ''
120 }
121
122 const minutes = Math.max(0, Math.round((Date.parse(resetsAt) - now) / 60_000))
123
124 if (minutes >= 1440) {
125 return `${Math.floor(minutes / 1440)}d ${Math.floor((minutes % 1440) / 60)}h`
126 }
127
128 return minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`
129}
130
131const toLimits = (limits: SessionRateLimit[]): Limit[] =>
132 limits
133 .filter(({ kind }) => LIMIT_ORDER.includes(kind))
134 .map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt }))
135 .sort((a, b) => LIMIT_ORDER.indexOf(a.kind) - LIMIT_ORDER.indexOf(b.kind))
136
137const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
138const POLL_MS = 60_000
139
140// True once the account-wide figures came back; the session's own rate-limit headers then stop overriding them.
141let hasLive = false
142
143// What the last usage request found, kept in one file every session reads, so the account is asked once
144// for all of them and they all show the same figure.
145type Shared = { at: number; triedAt: number; retryAt: number; limits: Limit[] }
146
147const SHARE_MS = 120_000
148const BACKOFF_MS = 300_000
149const CLAIM_MS = 20_000
150
151const sharedPath = async ($: EngineInterface) => {
152 const home = await $.env.get('HOME').catch(() => undefined)
153
154 return typeof home === 'string' && home !== '' ? `${home}/.claude/cache/context-card-usage.json` : null
155}
156
157const readShared = async ($: EngineInterface, path: string | null): Promise<Shared | null> => {
158 if (path === null) {
159 return null
160 }
161
162 try {
163 const found = JSON.parse(await $.fs.read(path)) as Partial<Shared>
164
165 return {
166 at: Number(found.at) || 0,
167 triedAt: Number(found.triedAt) || 0,
168 retryAt: Number(found.retryAt) || 0,
169 limits: Array.isArray(found.limits) ? found.limits : [],
170 }
171 } catch {
172 return null
173 }
174}
175
176const writeShared = async ($: EngineInterface, path: string | null, value: Shared) => {
177 if (path !== null) {
178 await $.fs.write(path, JSON.stringify(value)).catch(() => undefined)
179 }
180}
181
182const show = async ($: EngineInterface, shared: Shared) => {
183 if (shared.limits.length === 0) {
184 return
185 }
186
187 hasLive = true
188 await update($, card, was => (was === null ? was : { ...was, limits: shared.limits }))
189}
190
191// Account-wide plan usage, as the app's usage card reads it; covers every session, not just this one.
192// Each session calls this on its own timer, but only one of them sends the request: the rest read its result.
193const poll = async ($: EngineInterface) => {
194 const path = await sharedPath($)
195 const now = await $.clock.now()
196 const shared = (await readShared($, path)) ?? { at: 0, triedAt: 0, retryAt: 0, limits: [] }
197
198 await show($, shared)
199
200 // Fresh enough, told to wait after a refusal, or another session is asking right now.
201 if (now - shared.at < SHARE_MS || now < shared.retryAt || now - shared.triedAt < CLAIM_MS) {
202 return
203 }
204
205 const auth = await $.session.authorize()
206
207 if (auth === null) {
208 return
209 }
210
211 await writeShared($, path, { ...shared, triedAt: now })
212 const response = await $.http.fetch(USAGE_URL, {
213 headers: { 'anthropic-beta': 'oauth-2025-04-20' },
214 auth: auth.handle,
215 })
216
217 if (!response.ok) {
218 // Refused (rate limited, usually): every session waits before any of them asks again.
219 await writeShared($, path, { ...shared, triedAt: now, retryAt: now + BACKOFF_MS })
220
221 return
222 }
223
224 const body = JSON.parse(response.text) as Record<string, { utilization?: unknown; resets_at?: unknown } | null>
225 const limits: Limit[] = LIMIT_ORDER.flatMap(kind => {
226 const window = body[kind]
227
228 return window && typeof window.utilization === 'number'
229 ? [{ kind, percentUsed: window.utilization, resetsAt: typeof window.resets_at === 'string' ? window.resets_at : undefined }]
230 : []
231 })
232
233 if (limits.length === 0) {
234 return
235 }
236
237 const next: Shared = { at: now, triedAt: now, retryAt: 0, limits }
238
239 await writeShared($, path, next)
240 await show($, next)
241}
242
243const refresh = async ($: EngineInterface) => {
244 const { context, rateLimits } = await $.session.usage({ breakdown: 'summary' })
245 const breakdown = context.breakdown
246
247 if (breakdown === undefined) {
248 return
249 }
250
251 const window = breakdown.rawMaxTokens
252
253 const slices: Slice[] = breakdown.categories
254 .filter(row => row.kind === 'used' || row.kind === 'free')
255 .filter(row => row.tokens > 0)
256 .map(row => {
257 const isFree = row.kind === 'free'
258 const known = ROWS.find(r => row.name.toLowerCase().includes(r.match))
259
260 return {
261 name: isFree ? 'free' : (known?.label ?? row.name.toLowerCase()),
262 tokens: row.tokens,
263 percent: Math.round((row.tokens / window) * 100),
264 color: row.color,
265 isFree,
266 }
267 })
268 // Categories that share a label (loaded and deferred MCP tools) are one row.
269 .reduce<Slice[]>((merged, slice) => {
270 const same = merged.find(one => one.name === slice.name)
271
272 if (same === undefined) {
273 return [...merged, slice]
274 }
275
276 const tokens = same.tokens + slice.tokens
277
278 return merged.map(one => (one === same ? { ...one, tokens, percent: Math.round((tokens / window) * 100) } : one))
279 }, [])
280
281 const tokens = context.tokens ?? breakdown.totalTokens
282
283 await update($, card, was => ({
284 tokens,
285 window: context.window,
286 percent: context.percent ?? breakdown.percentage,
287 compactsAt: breakdown.isAutoCompactEnabled ? (breakdown.autoCompactThreshold ?? null) : null,
288 slices,
289 limits: hasLive && was !== null ? was.limits : toLimits(rateLimits),
290 }))
291}
292
293export const register: Register = on => {
294 let ticker: { cancel: () => void } | null = null
295 let localTicker: { cancel: () => void } | null = null
296
297 on('session.start', async ($, e, next) => {
298 await refresh($)
299 await poll($).catch(() => {})
300 ticker?.cancel()
301 ticker = $.clock.every(POLL_MS, () => poll($).catch(() => {}))
302 // The breakdown is a local estimate: refreshing it sends no request.
303 localTicker?.cancel()
304 localTicker = $.clock.every(LOCAL_MS, () => void refresh($))
305
306 return next(e)
307 })
308
309 on('session.end', ($, e, next) => {
310 ticker?.cancel()
311 localTicker?.cancel()
312 ticker = null
313 localTicker = null
314
315 return next(e)
316 })
317
318 on('turn.complete', async ($, e, next) => {
319 if (e.agentId === undefined) {
320 await markReply($)
321 await refresh($)
322 await poll($).catch(() => {})
323 }
324
325 return next(e)
326 })
327
328 // A resumed session's record already says which lifetime it has; after that, each finished turn's does.
329 on('classic.SessionStart', async ($, e, next) => {
330 await measureTtl($, e.transcript_path)
331
332 return next(e)
333 })
334
335 on('classic.Stop', async ($, e, next) => {
336 await measureTtl($, e.transcript_path)
337
338 return next(e)
339 })
340
341 on('session.compact', async ($, e, next) => {
342 const result = await next(e)
343 await refresh($)
344
345 return result
346 })
347
348 on('session.measure', async ($, e, next) => {
349 // The fill moves mid-turn: follow it at once, keeping the last breakdown until the next refresh.
350 const { tokens, window } = e.context
351
352 if (e.changed.includes('context') && tokens !== undefined) {
353 await markReply($)
354 const percent = e.context.percent ?? Math.round((tokens / window) * 100)
355 await update($, card, was => (was === null ? was : { ...was, tokens, window, percent }))
356 }
357
358 if (!hasLive && e.changed.includes('rateLimits')) {
359 await update($, card, was => (was === null ? was : { ...was, limits: toLimits(e.rateLimits) }))
360 }
361
362 return next(e)
363 })
364
365 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
366 const now = await read($, card)
367
368 if (e.props.hasSurvey || now === null) {
369 return next(e)
370 }
371
372 const { Box, Button, Text } = $.ui.resolve(e)
373 const clock = await $.clock.now()
374 const open = await read($, isOpen)
375 const isDesktop = e.surface === 'desktop'
376 const isNarrow = (e.props.bodyColumns ?? 100) < 64
377 const weekly = now.limits.find(limit => limit.kind === 'seven_day')
378 const used = now.slices.filter(slice => !slice.isFree).sort((a, b) => b.tokens - a.tokens)
379 // Checked while drawing, so the button appears whichever mod finished loading first.
380 const hasSessions = (await $.command.list().catch(() => [])).some(command => command.name === 'sessions')
381 const waiting = hasSessions ? await read($, attention) : 0
382 const pctColor = fillColor(now.percent)
383 const cached = await read($, cache)
384 const cacheLeft = cached.lastReplyAt === 0 ? null : cached.ttlMs - (clock - cached.lastReplyAt)
385
386 // The line's bar in whole cells: each category its share of the filled cells, the rest dim.
387 const filled = Math.min(LINE_CELLS, Math.round((now.percent / 100) * LINE_CELLS))
388 const cells = used.map(slice => ({ slice, n: Math.round((slice.tokens / Math.max(now.tokens, 1)) * filled) }))
389 const drawn = cells.reduce((sum, cell) => sum + cell.n, 0)
390
391 const breakdown = (
392 <Box flexDirection="column" rowGap={1} paddingX={2} paddingY={1} marginBottom={1} borderStyle="round" borderDimColor>
393 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
394 <Text>
395 <Text bold color={pctColor}>{`${now.percent}%`}</Text>
396 <Text dimColor> of the context window</Text>
397 </Text>
398 <Box flexDirection="row" columnGap={2}>
399 {cacheLeft === null ? null : cacheLeft > 0 ? (
400 <Text dimColor>{`${cacheGlyph(cacheLeft, cached.ttlMs)} ${minutesLeft(cacheLeft)}`}</Text>
401 ) : (
402 <Text color="warning">○ cache cold</Text>
403 )}
404 {now.compactsAt === null ? null : <Text dimColor>{`auto-compact at ${compact(now.compactsAt)}`}</Text>}
405 </Box>
406 </Box>
407 <Box flexDirection="row" overflow="hidden">
408 {used.map(slice => (
409 <Box key={`bar-${slice.name}`} width={`${Math.max(slice.percent, 1)}%`} flexShrink={0} overflow="hidden">
410 {/* Wider than any bar, so the parent clips it and no ellipsis is drawn at the cut. */}
411 <Box width={900} flexShrink={0}>
412 <Text color={slice.color}>{'━'.repeat(300)}</Text>
413 </Box>
414 </Box>
415 ))}
416 <Box key="bar-free" flexGrow={1} flexShrink={1} overflow="hidden">
417 <Box width={900} flexShrink={0}>
418 <Text dimColor>{'─'.repeat(300)}</Text>
419 </Box>
420 </Box>
421 </Box>
422 <Box flexDirection="row" flexWrap="wrap">
423 {used.map(slice => (
424 <Box key={`row-${slice.name}`} width={isNarrow ? '50%' : '33%'} paddingRight={3}>
425 <Box flexDirection="row" justifyContent="space-between" flexGrow={1}>
426 <Text wrap="truncate-end">
427 <Text color={slice.color}>● </Text>
428 <Text dimColor>{slice.name}</Text>
429 </Text>
430 <Text>{compact(slice.tokens)}</Text>
431 </Box>
432 </Box>
433 ))}
434 </Box>
435 </Box>
436 )
437
438 return (
439 <Box flexDirection="column">
440 {open ? breakdown : null}
441 <Box flexDirection="row" alignItems="center" columnGap={2}>
442 <Box key="line" flexDirection="row" alignItems="center" columnGap={2} flexGrow={1} position="relative">
443 {isDesktop ? (
444 <Text bold color={pctColor}>{`${now.percent}%`}</Text>
445 ) : (
446 <Button key="toggle" plain label={`${now.percent}%`} onPress={() => void toggleOpen($)} />
447 )}
448 {isNarrow ? null : (
449 <Text dimColor hover={{ dimColor: false }}>
450 {`${compact(now.tokens)} / ${compact(now.window)}`}
451 </Text>
452 )}
453 {cacheLeft === null ? null : cacheLeft > 0 ? (
454 <Text dimColor>{cacheGlyph(cacheLeft, cached.ttlMs)}</Text>
455 ) : (
456 <Text color="warning">○</Text>
457 )}
458 <Text wrap="truncate">
459 {cells.map(cell => (
460 <Text key={`cell-${cell.slice.name}`} color={cell.slice.color}>
461 {'━'.repeat(cell.n)}
462 </Text>
463 ))}
464 <Text dimColor hover={{ dimColor: false }}>
465 {'─'.repeat(Math.max(0, LINE_CELLS - drawn))}
466 </Text>
467 </Text>
468 {now.percent >= 60 && now.compactsAt !== null ? (
469 <Text color={pctColor}>{`compacts at ${compact(now.compactsAt)}`}</Text>
470 ) : null}
471 <Box flexGrow={1} />
472 {weekly === undefined ? null : (
473 <Box flexDirection="row" columnGap={1} flexShrink={0}>
474 {isNarrow ? null : <Text dimColor>weekly</Text>}
475 <Text bold color={limitColor(weekly.percentUsed)}>{`${Math.round(weekly.percentUsed)}%`}</Text>
476 {weekly.resetsAt === undefined ? null : <Text dimColor>{`↻ ${resetsIn(weekly.resetsAt, clock)}`}</Text>}
477 </Box>
478 )}
479 {/* Desktop: a label-less control laid over the whole line, so a click anywhere on it toggles the breakdown. */}
480 {isDesktop ? (
481 <Box position="absolute" top={0} left={0} right={0} bottom={0} overflow="hidden">
482 {/* The control is wider than any line, so the overlay clips it and its label is never cut with an ellipsis. */}
483 <Box width={1200} flexShrink={0}>
484 <Button
485 key="toggle"
486 plain
487 label={'\u00A0'.repeat(600)}
488 hover={{ backgroundColor: '#00000000' }}
489 onPress={() => void toggleOpen($)}
490 />
491 </Box>
492 </Box>
493 ) : null}
494 </Box>
495 {hasSessions ? (
496 <Box flexDirection="row" columnGap={1} flexShrink={0}>
497 <Button key="sessions" label="PRs" onPress={() => void update($, paneAsk, count => count + 1)} />
498 {waiting > 0 ? (
499 <Text bold color="warning">
500 {waiting}
501 </Text>
502 ) : null}
503 </Box>
504 ) : null}
505 </Box>
506 </Box>
507 )
508 })
509}
510types/index.d.ts 36 lines1export type Slice = {
2 /** Row label as /context prints it. */
3 name: string
4 tokens: number
5 /** 0 to 100, against the measured window. */
6 percent: number
7 color: string
8 isFree: boolean
9}
10
11export type Limit = {
12 /** `five_hour`, `seven_day`, or a gateway's `spend_limit`. */
13 kind: string
14 percentUsed: number
15 resetsAt?: string
16}
17
18export type Card = {
19 /** Tokens in use. */
20 tokens: number
21 /** The window measured against. */
22 window: number
23 percent: number
24 /** Token count where auto-compaction runs; null when off. */
25 compactsAt: number | null
26 slices: Slice[]
27 limits: Limit[]
28}
29
30declare module 'claude-code' {
31 interface PluginState {
32 'context-card': { card: Card | null; isOpen: boolean; cache: { lastReplyAt: number; ttlMs: number }; paneAsk: number }
33 'pr-pane': { attention: number }
34 }
35}
36