SLOPSHOPPER

usage-panel

Band above the prompt with context, plan limits and prompt-cache expiry

newbandguardstatustimer
v0.4.0no licenseupdated 2026-10-14alexmagnum2d0/usage-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-panel
› 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 Context 49% · Session 31% · Cache 59m left (until 09:53) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Context 49% · Session 31% · Cache 59m left (until 09:53)
README

usage-panel

A Claude Code mod that keeps one line above the prompt with your context fill, plan limits and prompt-cache expiry:

Context 58% · Session 36% (resets at 23:30) · Weekly 81% (resets Oct 19 at 01:00) · Cache 13m left (until 22:09)
  • Context: tokens in use against the auto-compact window, the same figure as the Context card in the desktop app.
  • Session / Weekly: the 5-hour and weekly plan limits with their reset time (subscriptions only; a spend limit shows as Spend).
  • Cache: when the prompt cache goes cold, counted from the last main-thread model response (each tool call mid-turn counts as one).

In the desktop app the plan limits and context refresh every 30 seconds from the app's own usage data, so work in other sessions shows up too. Elsewhere they refresh with each model response of the session.

Percentages turn yellow from 70% and red from 90%. The line works in the terminal and in the Code tab of the Claude desktop app.

Install

In a terminal, start claude and type:

/plugin install usage-panel --marketplace alexmagnum2d0/usage-panel

Answer y to add the marketplace, then pick the user scope. The mod then loads in every new session, including the desktop app's Code tab. The command itself is only available in a terminal session.

Settings

OptionValuesDefaultWhat it does
Prompt cache TTL (cacheTtl)1h, 5m1hHow long the prompt cache lives after the last response. Subscriptions get 1 hour; API keys and usage past the plan get 5 minutes.

Limitations

  • The cache time is an estimate: the engine does not report when the cache actually expires.
  • Times are shown in 24-hour format, in your local time zone.
  • Built on the Claude Code mods API (early access, Claude Code 2.1.295); it may change between releases.

Development

claude plugin validate .
claude plugin test .
Source 2 files
hooks/register.tsx 238 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMeasureInput } from 'claude-code'
3
4import type { UsageLimit, UsageSnapshot } from '../types'
5
6const usage = atom({ plugin: 'usage-panel', key: 'usage' } as const, null)
7const lastResponseAt = atom({ plugin: 'usage-panel', key: 'lastResponseAt' } as const, null)
8
9const TTL_MS: Record<string, number> = { '1h': 60 * 60_000, '5m': 5 * 60_000 }
10const TICK_MS = 30_000
11
12const LIMIT_NAMES: Record<string, string> = {
13  five_hour: 'Session',
14  seven_day: 'Weekly',
15  spend_limit: 'Spend',
16}
17const LIMIT_ORDER = ['five_hour', 'seven_day', 'spend_limit']
18
19const DAY = new Intl.DateTimeFormat('en-US', { day: 'numeric', month: 'short' })
20const TIME = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit' })
21
22type Item = { key: string; label: string; percent?: number; note?: string }
23
24// What the desktop app's get_usage tool answers: the account's limits as its
25// usage card shows them, and this session's context as the side panel does.
26type AppUsage = {
27  plan?: { status: string; windows?: { label: string; percentUsed: number; resetsAt?: string }[] }
28  context?: { status: string; tokensUsed?: number; contextWindow?: number; percentUsed?: number }
29}
30
31const APP_SERVER = 'ccd_session_mgmt'
32const APP_TOOL = 'get_usage'
33
34function snapshot(u: Omit<SessionMeasureInput, 'changed'>, compactWindow?: number): UsageSnapshot {
35  const window = compactWindow ?? u.context.window
36  const tokens = u.context.tokens
37  return {
38    contextTokens: tokens,
39    contextWindow: window,
40    contextPercent: tokens === undefined ? undefined : Math.round((tokens / window) * 100),
41    limits: u.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
42    costUsd: u.cost?.usd,
43  }
44}
45
46// The side panel measures against the auto-compact window, not the model's limit.
47async function compactWindow($: EngineInterface) {
48  const { context } = await $.session.usage({ breakdown: 'summary' })
49  return context.breakdown?.rawMaxTokens
50}
51
52async function refresh($: EngineInterface) {
53  const next = snapshot(await $.session.usage(), await compactWindow($))
54  await update($, usage, () => next)
55}
56
57function limitKind(label: string) {
58  if (label.startsWith('5-hour')) return 'five_hour'
59  if (label === 'Weekly · all models') return 'seven_day'
60  return undefined
61}
62
63function fromApp(prev: UsageSnapshot | null, app: AppUsage): UsageSnapshot | null {
64  const fresh: UsageLimit[] = []
65  if (app.plan?.status === 'ok') {
66    for (const w of app.plan.windows ?? []) {
67      const kind = limitKind(w.label)
68      if (kind) fresh.push({ kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt })
69    }
70  }
71  const ctx = app.context?.status === 'ok' ? app.context : undefined
72  if (fresh.length === 0 && !ctx) return prev
73
74  const kept = (prev?.limits ?? []).filter(l => !fresh.some(f => f.kind === l.kind))
75  return {
76    contextTokens: ctx?.tokensUsed ?? prev?.contextTokens,
77    contextWindow: ctx?.contextWindow ?? prev?.contextWindow ?? 0,
78    contextPercent: ctx?.percentUsed ?? prev?.contextPercent,
79    limits: [...kept, ...fresh],
80    costUsd: prev?.costUsd,
81  }
82}
83
84// Plan limits are the account's, so other sessions move them too; the engine
85// only hears of that with this session's next response. The desktop app reads
86// them live. Resolves false where the app's server is not connected.
87async function pollApp($: EngineInterface) {
88  try {
89    const { content, isError } = await $.mcp.call(APP_SERVER, APP_TOOL, {})
90    const text = content.find(block => block.type === 'text')?.text
91    if (isError || text === undefined) return false
92    const app = JSON.parse(text) as AppUsage
93    await update($, usage, prev => fromApp(prev, app))
94    return true
95  } catch {
96    return false
97  }
98}
99
100function colorFor(percent: number) {
101  if (percent >= 90) return 'error'
102  if (percent >= 70) return 'warning'
103  return 'success'
104}
105
106function isSameDay(a: number, b: number) {
107  return DAY.format(a) === DAY.format(b)
108}
109
110function resetNote(resetsAt: string | undefined, now: number) {
111  if (!resetsAt) return undefined
112  const at = Date.parse(resetsAt)
113  if (!Number.isFinite(at)) return undefined
114  if (at <= now) return '(resets now)'
115  const day = isSameDay(at, now) ? '' : `${DAY.format(at)} `
116  return `(resets ${day}at ${TIME.format(at)})`
117}
118
119function cacheItem(warmUntil: number | undefined, now: number): Item | undefined {
120  if (warmUntil === undefined) return undefined
121  const at = TIME.format(warmUntil)
122  const ms = warmUntil - now
123  if (ms <= 0) return { key: 'cache', label: 'Cache', note: `expired at ${at}` }
124  const minutes = Math.floor(ms / 60_000)
125  const left = minutes < 1 ? '<1m' : `${minutes}m`
126  return { key: 'cache', label: 'Cache', note: `${left} left (until ${at})` }
127}
128
129async function warmUntil($: EngineInterface, ttlMs: number) {
130  const at = await read($, lastResponseAt)
131  return at === null ? undefined : at + ttlMs
132}
133
134function redraw($: EngineInterface) {
135  $.ui.invalidate('ui.render')
136}
137
138export const register: Register = (on, options) => {
139  const ttlMs = TTL_MS[String(options.cacheTtl)] ?? TTL_MS['1h']!
140
141  let hasApp = true
142
143  on('session.start', async ($, e, next) => {
144    await refresh($)
145    hasApp = await pollApp($)
146    $.ui.status(undefined)
147    $.clock.every(TICK_MS, () => {
148      void (async () => {
149        if (hasApp) hasApp = await pollApp($)
150        redraw($)
151      })()
152    })
153
154    return next(e)
155  })
156
157  // Each model response refreshes the cache: one that asks for a tool here,
158  // the turn's last one at turn.complete. Subagents keep caches of their own.
159  on('tool.call', async ($, e, next) => {
160    if (e.agentId === undefined) {
161      const at = await $.clock.now()
162      await update($, lastResponseAt, () => at).catch(() => undefined)
163    }
164
165    return next(e)
166  })
167
168  on('turn.complete', async ($, e, next) => {
169    const done = await next(e)
170    if (e.agentId === undefined && e.usage !== undefined) {
171      const at = await $.clock.now()
172      await update($, lastResponseAt, () => at).catch(() => undefined)
173    }
174
175    return done
176  })
177
178  on('session.measure', async ($, e, next) => {
179    const next_ = snapshot(e, await compactWindow($).catch(() => undefined))
180    await update($, usage, () => next_)
181
182    return next(e)
183  })
184
185  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
186    if (e.props.hasSurvey) {
187      return next(e)
188    }
189
190    const u = await read($, usage)
191    const now = await $.clock.now()
192    const limits = [...(u?.limits ?? [])].sort(
193      (a, b) => LIMIT_ORDER.indexOf(a.kind) - LIMIT_ORDER.indexOf(b.kind),
194    )
195    const cache = cacheItem(await warmUntil($, ttlMs), now)
196    const items: Item[] = [
197      ...(u?.contextPercent !== undefined
198        ? [{ key: 'context', label: 'Context', percent: u.contextPercent }]
199        : []),
200      ...limits.map(l => ({
201        key: `limit-${l.kind}`,
202        label: LIMIT_NAMES[l.kind] ?? l.kind,
203        percent: l.percentUsed,
204        note: resetNote(l.resetsAt, now),
205      })),
206      ...(cache ? [cache] : []),
207    ]
208
209    if (items.length === 0) {
210      return next(e)
211    }
212
213    const { Box, Text } = $.ui.resolve(e)
214
215    return (
216      <Box key="usage-band" flexWrap="wrap">
217        {items.map((item, i) => (
218          <Box key={item.key}>
219            <Text dimColor>
220              {i > 0 ? ' · ' : ''}
221              {item.label}{' '}
222            </Text>
223            {item.percent !== undefined && (
224              <Text color={colorFor(item.percent)}>{item.percent}%</Text>
225            )}
226            {item.note !== undefined && (
227              <Text dimColor>
228                {item.percent !== undefined ? ' ' : ''}
229                {item.note}
230              </Text>
231            )}
232          </Box>
233        ))}
234      </Box>
235    )
236  })
237}
238
types/index.d.ts 19 lines
1export type UsageLimit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type UsageSnapshot = {
4  contextTokens?: number
5  contextWindow: number
6  contextPercent?: number
7  limits: UsageLimit[]
8  costUsd?: number
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'usage-panel': {
14      usage: UsageSnapshot | null
15      lastResponseAt: number | null
16    }
17  }
18}
19