SLOPSHOPPER

context-card

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

newbandprocessnetworktimer
★ 4v0.1.2MITupdated 2026-10-04Nongfsq/frank-claude-cockpit/context-card
A shopper browsing a rack in a slop shop
README

Claude Code Cockpit

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.

What you get

context-card: one line above the prompt

<img src="docs/card-light.png" alt="The context card with its breakdown open.">

On the lineMeaning
27% 272.3k / 1MHow 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 barThe same percentage, colored by what is using it.
weekly 20% ↻ 4d 21hYour plan's weekly usage and when it resets, read live from your account.
PRsOpens 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:

SectionWhat is in it
Needs youCI failing, conflicts, changes requested, ready to merge, or idle with nothing to show.
WorkingSessions running right now.
DoneMerged or closed. Folded until you click the heading.
Loose worktreesWorktrees 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).

ControlWhat it does
Click a sessionShows its worktree, branch, PR and activity.
Ask hereAsks, 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 workingThe 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 headingFolds or unfolds the section.

Outside any project, the pane shows every project's sessions as one overview.

Install

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.

Requirements

  • A recent Claude Code. These mods use the hooks-module API (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.

What leaves your machine

  • 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.
  • Ask here sends one prompt in your current session, so it costs what a prompt costs there.

Nothing else is sent anywhere.

Development

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

Support

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>

License

MIT

Source 2 files
hooks/register.tsx 510 lines
1import { 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}
510
types/index.d.ts 36 lines
1export 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