SLOPSHOPPER

usage

Side pane with the session's tokens, context, tools and time

newpaneguardcommandtimer
v0.4.0MITupdated 2026-10-08zrdqns/claude-code-usage
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage
│ ┃ Usage ✕ › fix the failing auth test and add an audit log call │ ┃ 30 min 0 s │ ┃ this session · $0.42 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ 5-hour limit 31% ⏺ Update(src/auth.ts) │ ┃ ━━━━━━━━━━━━━━━━━─────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ resets in NaN min ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Context 97.4k of 200k │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Last turn │ ┃ output 1.5k ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Tokens 7.9k › /usage-pane │ ┃ output 1.5k ⎿ usage: Usage pane opened. │ ┃ input 6.4k │ ┃ served from cache 93% │ ┃ │ ┃ Tools 9 │ ┃ Bash 4 │ ┃ Write 2 │ ┃ Read 1 │ ┃ Grep 1 │ ┃ Edit 1 │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Usage
30 min 0 s this session · $0.42 5-hour limit 31% ━━━━━━━━━━━━━━━━━─────────────────────────────────────── resets in NaN min Context 97.4k of 200k Last turn output 1.5k Tokens 7.9k output 1.5k input 6.4k served from cache 93% Tools 9 Bash 4 Write 2 Read 1 Grep 1 Edit 1
README

usage

A mod for Claude Code: a side pane with what the session has spent so far.

12 min 5 s
this session · $3.51

5-hour limit                    9%
━━━────────────────────────────
resets in                4 h 0 min

7-day limit                    79%
━━━━━━━━━━━━━━━━━━━━━━━━━──────
resets in                  3 d 2 h

Context                 180k of 1M
messages                      142k
tools                          21k
system                         12k

Last turn
output                        1.2k

Tokens                         45k
output                         12k
input                          33k
served from cache              92%

Tools                           18
Read                             7
Edit                             5
Bash                             4

What it shows

  • Session: elapsed time and accumulated cost.
  • Notices: from 80% of context it recommends /compact, and it warns about each limit that passes 80% (the 5-hour one has its own bar, and is on the band too).
  • Limits: each usage window with its bar and when it resets.
  • Context: tokens used out of the window and the three categories that take up the most.
  • Last turn: output tokens.
  • Tokens: output, input and how much of the input the cache served.
  • Tools: total calls and the five most used.

The pane opens by itself when the session starts; /usage-pane opens it again.

Meant to be used with band

This pane does not repeat what the band mod already shows above the prompt: the context percentage and the last turn's length and cost are there. Here is the detail the band does not have.

Installation

At the prompt of a terminal session:

/plugin install usage --marketplace zrdqns/claude-code-usage

Answer y to add the marketplace and choose the scope (the user scope loads it in every session, including the desktop app's).

To try it from a local copy, without installing it:

claude --plugin-dir ./claude-code-usage

Development

claude plugin validate .
claude plugin test .

The module is in hooks/register.tsx, its state contract in types/index.d.ts and the tests in tests/.

License

MIT

Source 2 files
hooks/register.tsx 317 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4const PANE = 'usage'
5const TITLE = 'Usage'
6
7const tokens = atom({ plugin: 'usage', key: 'tokens' } as const, {
8  input: 0,
9  output: 0,
10  cacheRead: 0,
11  cacheWrite: 0,
12})
13const tools = atom({ plugin: 'usage', key: 'tools' } as const, {})
14const parts = atom({ plugin: 'usage', key: 'parts' } as const, [])
15const last = atom({ plugin: 'usage', key: 'last' } as const, null)
16// The converter mod's last notice, when it is loaded.
17const converted = { plugin: 'converter', key: 'notice' } as const
18// From this share of the context window or of a rate limit, the pane says so.
19const ALERT_PERCENT = 80
20// How long another mod's notice stays in the pane.
21const NOTICE_MS = 60000
22
23const LIMITS: Record<string, string> = {
24  five_hour: '5-hour',
25  seven_day: '7-day',
26  spend_limit: 'Spend',
27}
28
29const PARTS: Record<string, string> = {
30  'System prompt': 'system',
31  'System tools': 'tools',
32  'MCP tools': 'MCP tools',
33  'Custom agents': 'agents',
34  'Memory files': 'memory',
35  Messages: 'messages',
36}
37
38// The desktop draws the meter as an image, outside the theme: these read on
39// a dark and on a light pane alike.
40const METER = { calm: '#9c9a92', warning: '#d4a037', error: '#d65f4a' }
41
42const trim = (n: number, digits: number) =>
43  n.toFixed(digits).replace(/\.?0+$/, '')
44
45const compact = (n: number) =>
46  n >= 1e6
47    ? `${trim(n / 1e6, 2)}M`
48    : n >= 1e3
49      ? `${trim(n / 1e3, 1)}k`
50      : `${n}`
51
52const span = (ms: number) => {
53  const s = Math.max(0, Math.floor(ms / 1000))
54  const m = Math.floor(s / 60)
55
56  return m >= 60
57    ? `${Math.floor(m / 60)} h ${m % 60} min`
58    : m > 0
59      ? `${m} min ${s % 60} s`
60      : `${s} s`
61}
62
63const until = (ms: number) => {
64  const m = Math.max(0, Math.round(ms / 60000))
65
66  return m >= 1440
67    ? `${Math.floor(m / 1440)} d ${Math.floor((m % 1440) / 60)} h`
68    : m >= 60
69      ? `${Math.floor(m / 60)} h ${m % 60} min`
70      : `${m} min`
71}
72
73const tone = (percent: number) =>
74  percent >= 85 ? 'error' : percent >= 60 ? 'warning' : undefined
75
76const shortName = (tool: string) => tool.replace(/^mcp__.*__/, '')
77
78const meter = (percent: number, color: string) =>
79  `<svg xmlns="http://www.w3.org/2000/svg" width="2000" height="12" viewBox="0 0 100 12" preserveAspectRatio="none">` +
80  `<rect y="4" width="100" height="4" fill="${METER.calm}" fill-opacity="0.3"/>` +
81  `<rect y="4" width="${Math.min(100, Math.max(0, percent))}" height="4" fill="${color}"/>` +
82  `</svg>`
83
84const refresh = async ($: EngineInterface) => {
85  try {
86    const { context } = await $.session.usage({ breakdown: 'summary' })
87    const rows = (context.breakdown?.categories ?? [])
88      .filter(row => row.kind === 'used' && row.tokens > 0)
89      .sort((a, b) => b.tokens - a.tokens)
90      .slice(0, 3)
91      .map(row => ({ name: row.name, tokens: row.tokens }))
92    await update($, parts, () => rows)
93  } catch {
94    // No breakdown yet (no session bound): the pane draws without it.
95  }
96}
97
98export const register: Register = on => {
99  on('session.start', async ($, e, next) => {
100    await $.command.register({
101      name: 'usage-pane',
102      description: "Opens the session's usage pane",
103    })
104    void $.ui.open({ id: PANE, title: TITLE })
105    $.clock.every(1000, () => $.ui.invalidate('ui.render'))
106
107    await refresh($)
108
109    return next(e)
110  })
111
112  on('command.run', { command: 'usage-pane' }, async $ => {
113    await $.ui.open({ id: PANE, title: TITLE })
114
115    return { text: 'Usage pane opened.' }
116  })
117
118  on('tool.call', async ($, e, next) => {
119    await update($, tools, counts => ({
120      ...counts,
121      [e.tool]: (counts[e.tool] ?? 0) + 1,
122    }))
123
124    return next(e)
125  })
126
127  on('turn.complete', async ($, e, next) => {
128    const { usage } = e
129    if (usage) {
130      await update($, tokens, sum => ({
131        input: sum.input + usage.input_tokens,
132        output: sum.output + usage.output_tokens,
133        cacheRead: sum.cacheRead + usage.cache_read_input_tokens,
134        cacheWrite: sum.cacheWrite + usage.cache_creation_input_tokens,
135      }))
136    }
137    if (e.agentId === undefined) {
138      await update($, last, () => ({ output: usage?.output_tokens ?? 0 }))
139      await refresh($)
140    }
141
142    return next(e)
143  })
144
145  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
146    const { Box, Text } = $.ui.resolve(e)
147    const [spent, counts, rows, turn, notice, usage, now] =
148      await Promise.all([
149        read($, tokens),
150        read($, tools),
151        read($, parts),
152        read($, last),
153        $.state.get(converted),
154        $.session.usage(),
155        $.clock.now(),
156      ])
157
158    const { context, cost, rateLimits } = usage
159    const { percent } = context
160    const fresh = spent.input + spent.cacheWrite
161    const incoming = fresh + spent.cacheRead
162    const ranked = Object.entries(counts).sort((a, b) => b[1] - a[1])
163    const calls = ranked.reduce((sum, [, count]) => sum + count, 0)
164    // The band mod has the context's figure and the five-hour one:
165    // the pane keeps the advice, and warns of the limits the band does not show.
166    const warnings = [
167      ...(percent !== undefined && percent >= ALERT_PERCENT
168        ? [
169            {
170              text: 'Context is high: consider /compact',
171              color: tone(percent) ?? 'warning',
172            },
173          ]
174        : []),
175      ...rateLimits
176        .filter(
177          limit =>
178            limit.kind !== 'five_hour' && limit.percentUsed >= ALERT_PERCENT,
179        )
180        .map(limit => ({
181          text: `${LIMITS[limit.kind] ?? limit.kind} limit at ${Math.round(limit.percentUsed)}%`,
182          color: tone(limit.percentUsed) ?? 'warning',
183        })),
184    ]
185    const news = [notice.value].flatMap(said =>
186      said !== undefined && said !== null && now - said.at < NOTICE_MS
187        ? [said.text]
188        : [],
189    )
190
191    const head = (label: string, value: string) => (
192      <Box flexDirection="row" justifyContent="space-between">
193        <Text>{label}</Text>
194        <Text bold>{value}</Text>
195      </Box>
196    )
197
198    const sub = (label: string, value: string) => (
199      <Box flexDirection="row" justifyContent="space-between">
200        <Text dimColor wrap="truncate-end">
201          {label}
202        </Text>
203        <Text dimColor>{value}</Text>
204      </Box>
205    )
206
207    const gauge = (label: string, value: number) => {
208      const alert = tone(value)
209      if (e.surface === 'terminal') {
210        const columns = Math.max(16, e.props.bodyColumns)
211        const full = Math.round((Math.min(100, value) / 100) * columns)
212
213        return (
214          <Box flexDirection="row">
215            {full > 0 && alert === undefined && <Text>{'━'.repeat(full)}</Text>}
216            {full > 0 && alert !== undefined && (
217              <Text color={alert}>{'━'.repeat(full)}</Text>
218            )}
219            {full < columns && (
220              <Text dimColor>{'─'.repeat(columns - full)}</Text>
221            )}
222          </Box>
223        )
224      }
225      const { Svg } = $.ui.resolve(e)
226
227      return (
228        <Svg
229          source={meter(value, METER[alert ?? 'calm'])}
230          alt={`${label} at ${value}%`}
231          height={12}
232        />
233      )
234    }
235
236    return (
237      <Box flexDirection="column" gap={1}>
238        <Box flexDirection="column">
239          <Text bold>{span(now - usage.startedAt)}</Text>
240          <Text dimColor>
241            {cost === undefined
242              ? 'this session'
243              : `this session · $${cost.usd.toFixed(2)}`}
244          </Text>
245        </Box>
246
247        {(warnings.length > 0 || news.length > 0) && (
248          <Box flexDirection="column">
249            <Text>Notices</Text>
250            {warnings.map(warning => (
251              <Text color={warning.color}>{warning.text}</Text>
252            ))}
253            {news.map(text => (
254              <Text dimColor>{text}</Text>
255            ))}
256          </Box>
257        )}
258
259        {rateLimits.map(limit => {
260          const label = `${LIMITS[limit.kind] ?? limit.kind} limit`
261          const value = Math.round(limit.percentUsed)
262
263          return (
264            <Box flexDirection="column">
265              {head(label, `${value}%`)}
266              {gauge(label, value)}
267              {limit.resetsAt !== undefined &&
268                sub('resets in', until(Date.parse(limit.resetsAt) - now))}
269            </Box>
270          )
271        })}
272
273        {context.tokens !== undefined && (
274          <Box flexDirection="column">
275            {head(
276              'Context',
277              `${compact(context.tokens)} of ${compact(context.window)}`,
278            )}
279            {rows.map(row =>
280              sub(PARTS[row.name] ?? row.name.toLowerCase(), compact(row.tokens)),
281            )}
282          </Box>
283        )}
284
285        {/* Its length and its cost are on the band mod's line. */}
286        {turn !== null && (
287          <Box flexDirection="column">
288            <Text>Last turn</Text>
289            {sub('output', compact(turn.output))}
290          </Box>
291        )}
292
293        <Box flexDirection="column">
294          {head('Tokens', compact(spent.output + fresh))}
295          {sub('output', compact(spent.output))}
296          {sub('input', compact(fresh))}
297          {incoming > 0 &&
298            sub(
299              'served from cache',
300              `${Math.round((spent.cacheRead / incoming) * 100)}%`,
301            )}
302        </Box>
303
304        <Box flexDirection="column">
305          {head('Tools', `${calls}`)}
306          {ranked
307            .slice(0, 5)
308            .map(([tool, count]) => sub(shortName(tool), `${count}`))}
309          {ranked.length > 5 && (
310            <Text dimColor>+{ranked.length - 5} more</Text>
311          )}
312        </Box>
313      </Box>
314    )
315  })
316}
317
types/index.d.ts 24 lines
1export type Tokens = {
2  input: number
3  output: number
4  cacheRead: number
5  cacheWrite: number
6}
7
8export type Tools = Record<string, number>
9
10export type Part = { name: string; tokens: number }
11
12export type LastTurn = { output: number }
13
14declare module 'claude-code' {
15  interface PluginState {
16    usage: {
17      tokens: Tokens
18      tools: Tools
19      parts: Part[]
20      last: LastTurn | null
21    }
22  }
23}
24