SLOPSHOPPER

token-usage

A pane showing context fill, session token totals, cost and rate limits

newpanecommand
★ 1v0.1.2MITupdated 2026-10-11matr1xp/local-mods/token-usage
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-usage
│ ┃ Token usage ✕ › fix the failing auth test and add an audit log call │ ┃ Context │ ┃ ████████████░░░░░░░░░░░░ 49% ⏺ Read(src/auth.ts) │ ┃ 97k / 200k tokens ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Session ⎿ Added 2 lines, removed 1 line │ ┃ in 0 · out 0 ⏺ Bash(bun test) │ ┃ cache read 0 · write 0 ⎿ 3 pass, 1 fail │ ┃ 0 requests │ ┃ cost $0.42 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Rate limits ✻ Worked for 42s · done 4:20 PM │ ┃ 5h ████░░░░░░░░ 31% ↻09:53 │ › /token-usage │ ⎿ token-usage: Token usage pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Token usage
Context ████████████░░░░░░░░░░░░ 49% 97k / 200k tokens Session in 0 · out 0 cache read 0 · write 0 0 requests cost $0.42 Rate limits 5h ████░░░░░░░░ 31% ↻09:53
README

local-mods

A personal Claude Code plugin marketplace of terminal UI mods: a status band above the prompt and two side panes.

PluginWhat it addsCommand
prompt-statusA two-line band above the prompt: model and context window, current directory, git branch, context fill and rate-limit usage—
token-usageA pane with context fill, session token totals, cost and rate limits/token-usage
git-changesA pane listing the files changed in the working tree, with colored git diff stats/changes

Install

/plugin marketplace add matr1xp/local-mods
/plugin install prompt-status@local-mods
/plugin install token-usage@local-mods
/plugin install git-changes@local-mods

Plugins

prompt-status

Renders above the prompt in every session:

The prompt-status band: model and context window, directory and git branch on the first line; context fill and five-hour usage bars with the reset time on the second

  • The model name comes from the latest main-agent request, falling back to the model setting.
  • Usage shows the five-hour rate limit when it is reported, otherwise the fullest limit.
  • The git branch and dirty marker (*) refresh after each turn and once a minute. A detached HEAD shows its short commit hash.
  • The band hides itself while a survey is showing.

token-usage

Opens a pane at session start. Run /token-usage to reopen it. The pane shows:

The token-usage pane: a context fill bar at 11% of a 1M window, session input, output and cache totals with the request count and cost, and the model and token counts of the last request

  • Context: fill bar, percentage and tokens used out of the window.
  • Session: input, output, cache read and cache write tokens, the request count with subagent requests counted separately, and cost.
  • Last request: model and token counts for the most recent request.
  • Rate limits: the 5h, 7d and spend limits with reset times. Each bar turns yellow at 60% and red at 85%.

Running /clear resets the session totals.

git-changes

Opens a pane in interactive sessions. Run /changes to reopen it and print a summary. The pane lists every file that differs from HEAD, whether staged, unstaged or untracked:

The git-changes pane on branch master: 3 files changed, +3 -4; a modified README.md marked as just changed with its diff counts and stat bar, and two untracked PNG files shown as bin

  • A status letter for each file (M, A, D, R, ??, …), +added / -deleted counts and a git --stat-style bar. Binary files show bin.
  • A ● marker on files whose stats changed since the previous scan.
  • The pane rescans after Edit, Write, NotebookEdit and Bash tool calls, and every 4 seconds while it is on screen. Press r to refresh by hand.
  • Git runs with GIT_OPTIONAL_LOCKS=0, so polling never holds index.lock while you run your own git commands.
  • Only the first 40 untracked files get line counts. The rest show as new.

Layout

.claude-plugin/marketplace.json   marketplace manifest listing the plugins
<plugin>/
  .claude-plugin/plugin.json      name, version, description
  hooks/hooks.json                points at the hooks module
  hooks/register.tsx              the mod: event hooks and UI rendering
  types/index.d.ts                shapes of the state each mod stores
  tests/*.test.ts(x)              tests using claude-code/testing

Development

Run a plugin's tests, then validate the marketplace:

claude plugin test git-changes
claude plugin validate .

After changing a plugin, bump version in its .claude-plugin/plugin.json so other machines pick up the update with claude plugin update <plugin>@local-mods.

License

MIT. The software is provided as is, without warranty of any kind, and the author is not liable for any damage arising from its use.

Source 2 files
hooks/register.tsx 196 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, SessionMeasureInput, SessionUsage } from 'claude-code'
3
4import type { LastRequest, Snapshot, Totals } from '../types'
5
6const PANE = 'token-usage'
7const TITLE = 'Token usage'
8// Widest line is the rate-limit row: "5h    ████████████ 23.5% ↻14:00".
9const COLUMNS = 34
10const OPEN = { id: PANE, title: TITLE, columns: COLUMNS } as const
11
12const EMPTY: Totals = {
13  input: 0,
14  output: 0,
15  cacheRead: 0,
16  cacheWrite: 0,
17  requests: 0,
18  subagentRequests: 0,
19}
20
21const totals = atom({ plugin: 'token-usage', key: 'totals' } as const, EMPTY)
22const last = atom({ plugin: 'token-usage', key: 'last' } as const, null)
23const snapshot = atom({ plugin: 'token-usage', key: 'snapshot' } as const, null)
24
25const toSnapshot = (u: SessionUsage | SessionMeasureInput): Snapshot => ({
26  contextTokens: u.context.tokens,
27  contextWindow: u.context.window,
28  contextPercent: u.context.percent,
29  rateLimits: u.rateLimits.map(({ kind, percentUsed, resetsAt }) => ({
30    kind,
31    percentUsed,
32    resetsAt,
33  })),
34  costUsd: u.cost?.usd,
35})
36
37const fmt =(n: number): string => {
38  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`
39  if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}k`
40  return String(n)
41}
42
43const bar = (percent: number, width: number): string => {
44  const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
45  return '█'.repeat(filled) + '░'.repeat(width - filled)
46}
47
48const LIMIT_NAMES: Record<string, string> = {
49  five_hour: '5h',
50  seven_day: '7d',
51  spend_limit: 'spend',
52}
53
54const resetTime = (iso?: string): string => {
55  if (!iso) return ''
56  const d = new Date(iso)
57  if (Number.isNaN(d.getTime())) return ''
58  const hh = String(d.getHours()).padStart(2, '0')
59  const mm = String(d.getMinutes()).padStart(2, '0')
60  return ` ↻${hh}:${mm}`
61}
62
63const levelColor = (percent: number) =>
64  percent >= 85 ? 'error' : percent >= 60 ? 'warning' : 'success'
65
66export const register: Register = on => {
67  on('session.start', async ($, e, next) => {
68    await $.command.register({
69      name: 'token-usage',
70      description: 'Show token usage, cost and rate limits in a pane',
71    })
72    const usage = await $.session.usage()
73    await update($, snapshot, () => toSnapshot(usage))
74    void $.ui.open(OPEN)
75
76    return next(e)
77  })
78
79  on('command.run', { command: 'token-usage' }, async $ => {
80    await $.ui.open(OPEN)
81
82    return { text: 'Token usage pane opened.' }
83  })
84
85  on('turn.step', async function* ($, e, next) {
86    const result = yield* next(e)
87    const usage = result?.usage
88    if (usage) {
89      const isSubagent = e.agentId !== undefined
90      await update($, totals, t => ({
91        input: t.input + usage.input_tokens,
92        output: t.output + usage.output_tokens,
93        cacheRead: t.cacheRead + usage.cache_read_input_tokens,
94        cacheWrite: t.cacheWrite + usage.cache_creation_input_tokens,
95        requests: t.requests + 1,
96        subagentRequests: t.subagentRequests + (isSubagent ? 1 : 0),
97      }))
98      const request: LastRequest = {
99        model: usage.model,
100        isSubagent,
101        input: usage.input_tokens,
102        output: usage.output_tokens,
103        cacheRead: usage.cache_read_input_tokens,
104        cacheWrite: usage.cache_creation_input_tokens,
105      }
106      await update($, last, () => request)
107    }
108
109    return result
110  })
111
112  on('session.measure', async ($, e, next) => {
113    await update($, snapshot, () => toSnapshot(e))
114
115    return next(e)
116  })
117
118  on('session.end', async ($, e, next) => {
119    if (e.reason === 'clear') {
120      await update($, totals, () => EMPTY)
121      await update($, last, () => null)
122    }
123
124    return next(e)
125  })
126
127  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
128    const { Box, Text } = $.ui.resolve(e)
129    const t = await read($, totals)
130    const l = await read($, last)
131    const s = await read($, snapshot)
132    // Context row is "<bar> 100%"; limit rows add a 6-cell label and a reset time.
133    const width = Math.max(8, Math.min(24, e.props.bodyColumns - 6))
134    const limitWidth = Math.max(6, Math.min(12, e.props.bodyColumns - 19))
135
136    const percent = s?.contextPercent
137    const hasContext = s !== null && percent !== undefined
138
139    return (
140      <Box flexDirection="column">
141        <Text bold>Context</Text>
142        {hasContext ? (
143          <Box flexDirection="column">
144            <Text color={levelColor(percent)}>
145              {bar(percent, width)} {percent}%
146            </Text>
147            <Text dimColor>
148              {fmt(s.contextTokens ?? 0)} / {fmt(s.contextWindow)} tokens
149            </Text>
150          </Box>
151        ) : (
152          <Text dimColor>No reading yet</Text>
153        )}
154
155        <Text> </Text>
156        <Text bold>Session</Text>
157        <Text>
158          in {fmt(t.input)} · out {fmt(t.output)}
159        </Text>
160        <Text dimColor>
161          cache read {fmt(t.cacheRead)} · write {fmt(t.cacheWrite)}
162        </Text>
163        <Text dimColor>
164          {t.requests} requests
165          {t.subagentRequests > 0 ? ` (${t.subagentRequests} subagent)` : ''}
166        </Text>
167        {s?.costUsd !== undefined && <Text>cost ${s.costUsd.toFixed(2)}</Text>}
168
169        {l !== null && (
170          <Box flexDirection="column">
171            <Text> </Text>
172            <Text bold>Last request{l.isSubagent ? ' (subagent)' : ''}</Text>
173            <Text dimColor>{l.model}</Text>
174            <Text>
175              in {fmt(l.input)} · out {fmt(l.output)} · cached {fmt(l.cacheRead)}
176            </Text>
177          </Box>
178        )}
179
180        {s !== null && s.rateLimits.length > 0 && (
181          <Box flexDirection="column">
182            <Text> </Text>
183            <Text bold>Rate limits</Text>
184            {s.rateLimits.map(r => (
185              <Text color={levelColor(r.percentUsed)}>
186                {(LIMIT_NAMES[r.kind] ?? r.kind).padEnd(5)} {bar(r.percentUsed, limitWidth)}{' '}
187                {r.percentUsed}%{resetTime(r.resetsAt)}
188              </Text>
189            ))}
190          </Box>
191        )}
192      </Box>
193    )
194  })
195}
196
types/index.d.ts 31 lines
1export type Tokens = {
2  input: number
3  output: number
4  cacheRead: number
5  cacheWrite: number
6}
7
8export type Totals = Tokens & { requests: number; subagentRequests: number }
9
10export type LastRequest = Tokens & { model: string; isSubagent: boolean }
11
12export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
13
14export type Snapshot = {
15  contextTokens?: number
16  contextWindow: number
17  contextPercent?: number
18  rateLimits: Limit[]
19  costUsd?: number
20}
21
22declare module 'claude-code' {
23  interface PluginState {
24    'token-usage': {
25      totals: Totals
26      last: LastRequest | null
27      snapshot: Snapshot | null
28    }
29  }
30}
31