SLOPSHOPPER

token-panel

A pane listing each prompt's token usage and its cost at standard API (Enterprise usage) rates; /token-panel turns it on and off

newpanebandrowscommandtoast
★ 1v0.1.2MITupdated 2026-10-09flowmux-ai/claude-token-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-panel
│ ┃ Claude Token Panel ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ⏺ Read(src/auth.ts) │ ┃ Claude Token Panel ⎿ Read 6 lines │ ┃ # model in out cacheR cacheW … ⏺ Update(src/auth.ts) │ ┃ No prompts yet. ⎿ Added 2 lines, removed 1 line │ ┃ etc unplaced 0 0 0 0 … ⏺ Bash(bun test) │ ┃ sum 0 0 0 0 … ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /token-panel │ ⎿ token-panel: Token panel on. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Claude Token Panel
Claude Token Panel # model in out cacheR cacheW cost No prompts yet. etc unplaced 0 0 0 0 $0.42 sum 0 0 0 0 $0.42
README

claude-token-panel

A Claude Code mod that shows, for every prompt of the session, how many tokens it used and what it cost.

The pane docked beside the transcript

Install

Requires Claude Code 2.1.287 or newer.

/plugin install token-panel --marketplace flowmux-ai/claude-token-panel

Use

The pane starts hidden. The TokenPanel button above the prompt or /token-panel opens it, and /token-panel hides it again.

What is counted

  • Every prompt of the session, read from its transcript file.
  • Subagent and advisor calls count toward the prompt that made them, priced at their own model.
  • Internal calls with no transcript row (web search, title generation) add to cost only.
  • The sum row matches /cost. Cost not tied to any prompt shows as etc unplaced.

Cost

Tokens times the standard API rates. The rate table lives in hooks/tally.ts and is updated by hand. A model missing from it shows ? instead of $.

Develop

claude --plugin-dir .        # run the mod from this folder, reloading on save
claude plugin validate .
claude plugin test .

License

MIT

Source 3 files
hooks/register.tsx 281 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Flash, Row, Usage } from '../types'
5import { settle, tally } from './tally'
6
7const PANE = 'token-panel'
8const TITLE = 'Claude Token Panel'
9// Above the prompt the table lists five prompts: the frame asks for them, the title, the head, `etc` and `sum`.
10const LIST = 5
11// Column widths in cells: 3, 10 for the model, 7 per token count, 8 for the cost.
12const WIDTH = 49
13const OPEN = { id: PANE, title: TITLE, columns: WIDTH, rows: LIST + 4 }
14const HEAD = ['  #', ' model    ', '     in', '    out', ' cacheR', ' cacheW', '    cost']
15const COUNTS = ['input', 'output', 'cacheRead', 'cacheWrite'] as const
16const NONE: Usage = { path: null, rows: [], offset: null, mark: 0, marks: {}, rest: 0 }
17const DARK: Flash = null
18const usage = atom({ plugin: 'token-panel', key: 'usage' } as const, NONE)
19const flash = atom({ plugin: 'token-panel', key: 'flash' } as const, DARK)
20// How many prompts the list is scrolled up from the newest: 0 keeps the newest in view.
21const back = atom({ plugin: 'token-panel', key: 'back' } as const, 0)
22// Whether the pane is drawn: while it is not, the band above the prompt holds its button.
23const isShown = atom({ plugin: 'token-panel', key: 'isShown' } as const, false)
24
25const tokens = (n: number): string =>
26  n < 1000 ? String(n) : n < 1e6 ? `${(n / 1e3).toFixed(1)}k` : `${(n / 1e6).toFixed(2)}M`
27
28// A prompt's cost is its transcript's plus the internal calls the engine billed in it.
29const cell = (row: Row, key: (typeof COUNTS)[number] | 'usd'): number =>
30  key === 'usd' ? row.usd + row.other : row[key]
31
32const line = (
33  label: string,
34  model: string,
35  of: (key: (typeof COUNTS)[number] | 'usd') => number,
36  isUnpriced: boolean,
37): string[] => [
38  label.padStart(3),
39  ` ${model.padEnd(9)}`,
40  ...COUNTS.map(key => tokens(of(key)).padStart(7)),
41  `${isUnpriced ? '?' : '$'}${of('usd').toFixed(2)}`.padStart(8),
42]
43
44// Where the CLI keeps the transcript, once it is written: for when no classic
45// event named it (another plugin may bypass this one's classic hooks).
46const locate = async ($: EngineInterface): Promise<string | null> => {
47  const path = `${await $.env.get('HOME')}/.claude/projects/${(await $.session.root()).replace(/[^a-zA-Z0-9]/g, '-')}/${await $.session.id()}.jsonl`
48
49  return (await $.fs.exists(path)) ? path : null
50}
51
52// Counts the session again from its transcript, and its subagents' beside it,
53// then sets what the engine billed beyond them against the prompt it fell in.
54// ponytail: every file is read whole each time, tail them if a session's transcripts grow past tens of MB
55const refresh = async ($: EngineInterface, seen?: string, isNewPrompt = false): Promise<void> => {
56  const path = seen ?? (await read($, usage)).path ?? (await locate($))
57
58  if (path === null) {
59    return
60  }
61
62  const folder = path.replace(/\.jsonl$/, '/subagents')
63  const entries = (await $.fs.exists(folder)) ? await $.fs.list(folder) : []
64  const agents = await Promise.all(
65    entries
66      .filter(entry => entry.name.endsWith('.jsonl'))
67      .map(entry => $.fs.read(`${folder}/${entry.name}`)),
68  )
69  // Before the first prompt the transcript is not written yet: count it empty, but keep its path for the next count.
70  const rows = tally((await $.fs.exists(path)) ? await $.fs.read(path) : '', agents)
71  const { cost } = await $.session.usage()
72
73  await update($, usage, old => settle(old, path, rows, cost?.usd, isNewPrompt))
74}
75
76// Opens the pane; one that waits undrawn leaves the button up.
77// ponytail: a waiting pane the terminal later widens to seat keeps the button beside it until pressed
78const show = async ($: EngineInterface): Promise<void> => {
79  const { isPlaced } = await $.ui.open(OPEN)
80  await update($, isShown, () => isPlaced)
81}
82
83const shut = ($: EngineInterface): Promise<void> => update($, isShown, () => false)
84
85// The running beat's timer: a module's own, so a reload drops it with the module.
86let beat: Timer | undefined
87// The prompts the list showed when last drawn: how far it can scroll.
88let shown = LIST
89
90// Brings the prompt's row into view and beats its border for three seconds.
91const reveal = async ($: EngineInterface, uuid: string): Promise<void> => {
92  // A row that cannot be brought into view says why; one already in view still beats.
93  const { deny } = await $.ui
94    .scroll({ to: { requestId: uuid }, block: 'center' })
95    .catch((error: unknown) => ({ deny: String(error) }))
96
97  if (deny !== undefined) {
98    $.ui.toast(`Token panel: ${deny}`)
99  }
100
101  let beats = 0
102  beat?.cancel()
103  await update($, flash, () => ({ uuid, isOn: true }))
104  beat = $.clock.every(500, () => {
105    beats += 1
106
107    if (beats >= 6) {
108      beat?.cancel()
109    }
110
111    void update($, flash, () => (beats < 6 ? { uuid, isOn: beats % 2 === 0 } : null))
112  })
113}
114
115export const register: Register = on => {
116  on('session.start', async ($, e, next) => {
117    await $.command.register({
118      name: PANE,
119      description: 'Show or hide the per-prompt token usage and cost pane',
120    })
121    // A reload drops the timer: no beat is left running.
122    await update($, flash, () => null)
123
124    // Every session starts with the pane shut: the button above the prompt or /token-panel opens it.
125    await refresh($)
126
127    return next(e)
128  })
129
130  on('command.run', { command: PANE }, async $ => {
131    const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
132
133    if (isOpen) {
134      // The pane's own `ui.close` hook below shuts it.
135      await $.ui.close({ id: PANE })
136
137      return { text: 'Token panel off.' }
138    }
139
140    await show($)
141
142    return { text: 'Token panel on.' }
143  })
144
145  // The pane's own close mark: the button takes its place.
146  on('ui.close', { id: PANE }, async ($, e, next) => {
147    const closed = await next(e)
148    await shut($)
149
150    return closed
151  })
152
153  on('classic.SessionStart', async ($, e, next) => {
154    await refresh($, e.transcript_path)
155
156    return next(e)
157  })
158
159  on('classic.UserPromptSubmit', async ($, e, next) => {
160    await refresh($, e.transcript_path, true)
161
162    return next(e)
163  })
164
165  on('classic.PostToolUse', async ($, e, next) => {
166    await refresh($, e.transcript_path)
167
168    return next(e)
169  })
170
171  // The engine billed something: count again, so the sum keeps up with /cost.
172  on('session.measure', async ($, e, next) => {
173    if (e.changed.includes('cost')) {
174      await refresh($)
175    }
176
177    return next(e)
178  })
179
180  on('turn.complete', async ($, e, next) => {
181    const ended = await next(e)
182    await refresh($)
183    // The turn's last response may reach the file a moment after the turn ends.
184    $.clock.after(1000, () => void refresh($))
185
186    return ended
187  })
188
189  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
190    const { Box, Button, Text } = $.ui.resolve(e)
191    const { rows, rest = 0 } = await read($, usage)
192    // Docked, the table keeps two empty rows above and below it.
193    const inset = e.props.placement === 'dock' ? 2 : 0
194    const room =
195      e.props.placement === 'inline' ? LIST : Math.max(1, (e.viewport?.rows ?? 24) - 8 - 2 * inset)
196    const end = rows.length - Math.min(await read($, back), Math.max(0, rows.length - room))
197    const start = Math.max(0, end - room)
198    shown = room
199    // Above the prompt the table spreads over the pane's width; docked it stays compact.
200    const spare = e.props.placement === 'inline' ? e.props.bodyColumns - WIDTH : 0
201    const gap = ' '.repeat(Math.max(0, Math.floor(spare / (HEAD.length - 1))))
202
203    return (
204      <Box flexDirection="column" paddingY={inset}>
205        <Text bold wrap="truncate-end">
206          {TITLE}
207        </Text>
208        <Text bold wrap="truncate-end">
209          {HEAD.join(gap)}
210        </Text>
211        {rows.length === 0 && <Text dimColor>No prompts yet.</Text>}
212        {rows.slice(start, end).map((row: Row, i) => (
213          <Button
214            key={`row:${row.uuid}`}
215            plain
216            label={line(String(start + i + 1), row.model, key => cell(row, key), row.isUnpriced).join(gap)}
217            onPress={() => reveal($, row.uuid)}
218          />
219        ))}
220        {rest >= 0.005 && (
221          <Text dimColor wrap="truncate-end">
222            {line('etc', 'unplaced', key => (key === 'usd' ? rest : 0), false).join(gap)}
223          </Text>
224        )}
225        <Text bold wrap="truncate-end">
226          {line(
227            'sum',
228            '',
229            key => rows.reduce((sum, row) => sum + cell(row, key), key === 'usd' ? rest : 0),
230            rows.some(row => row.isUnpriced),
231          ).join(gap)}
232        </Text>
233      </Box>
234    )
235  })
236
237  // The list scrolls under the title, the head and the sum, which stay put.
238  on('ui.scroll', { requestId: PANE }, async ($, e) => {
239    const { rows } = await read($, usage)
240    await update($, back, old => Math.min(Math.max(0, rows.length - shown), Math.max(0, old - e.by)))
241
242    return {}
243  })
244
245  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
246    if (e.props.hasSurvey || (await read($, isShown))) {
247      return next(e)
248    }
249
250    const { Box, Button } = $.ui.resolve(e)
251
252    return (
253      <Box width={e.props.bodyColumns} justifyContent="flex-end">
254        <Button
255          key="open"
256          label="TokenPanel"
257          hover={{ scope: 'open', backgroundColor: 'suggestion', color: 'inverseText' }}
258          onPress={() => show($)}
259        />
260      </Box>
261    )
262  })
263
264  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
265    const lit = await read($, flash)
266
267    // The engine names a prompt's row by its uuid with the last group zeroed: match the rest.
268    if (lit === null || lit.uuid.slice(0, 23) !== e.requestId.slice(0, 23)) {
269      return next(e)
270    }
271
272    const { Box, Text } = $.ui.resolve(e)
273
274    return (
275      <Box borderStyle="round" borderColor="red" borderDimColor={!lit.isOn} paddingX={1}>
276        <Text bold={lit.isOn}>{e.props.text}</Text>
277      </Box>
278    )
279  })
280}
281
hooks/tally.ts 182 lines
1import type { Row, Usage } from '../types'
2
3// USD per million tokens at standard API rates, which an Enterprise plan bills
4// usage at: [model id part, input, output, cache read]. The first match wins.
5// A cache write costs 1.25x input (5 minutes) or 2x input (1 hour).
6// ponytail: a table that rots, refresh it from platform.claude.com/docs/en/about-claude/pricing.
7// Not priced: fast mode, US-only inference (1.1x), web search requests.
8const RATES: readonly (readonly [string, number, number, number])[] = [
9  ['claude-fable-5-1', 10, 50, 0.25],
10  ['claude-mythos-5-1', 10, 50, 0.25],
11  ['claude-fable-5', 10, 50, 1],
12  ['claude-mythos-5', 10, 50, 1],
13  ['claude-opus-5-5', 4, 20, 0.2],
14  ['claude-opus-4-1', 15, 75, 1.5],
15  // Opus 4, `claude-opus-4-20250514`: its date follows the version.
16  ['claude-opus-4-2', 15, 75, 1.5],
17  ['claude-opus', 5, 25, 0.5],
18  ['claude-sonnet-5', 2, 10, 0.2],
19  ['claude-sonnet', 3, 15, 0.3],
20  ['claude-haiku-4-5', 1, 5, 0.1],
21]
22
23// A response's usage as the transcript stores it; `iterations` splits it per
24// request when one response held several (an advisor's call names its own model).
25type Use = {
26  input_tokens?: number
27  output_tokens?: number
28  cache_read_input_tokens?: number
29  cache_creation_input_tokens?: number
30  cache_creation?: { ephemeral_1h_input_tokens?: number }
31  model?: string
32  iterations?: Use[] | null
33}
34
35const spend = (row: Row, model: string, use: Use): void => {
36  const input = use.input_tokens ?? 0
37  const output = use.output_tokens ?? 0
38  const read = use.cache_read_input_tokens ?? 0
39  const write = use.cache_creation_input_tokens ?? 0
40  const hour = use.cache_creation?.ephemeral_1h_input_tokens ?? 0
41  const rate = RATES.find(([part]) => model.includes(part))
42
43  row.input += input
44  row.output += output
45  row.cacheRead += read
46  row.cacheWrite += write
47
48  if (rate === undefined) {
49    row.isUnpriced ||= input + output + read + write > 0
50
51    return
52  }
53
54  const [, perInput, perOutput, perRead] = rate
55  row.usd +=
56    (input * perInput +
57      (write - hour) * perInput * 1.25 +
58      hour * perInput * 2 +
59      read * perRead +
60      output * perOutput) /
61    1e6
62}
63
64/**
65 * The session's prompts with what each one used, from its transcript's text
66 * and its subagents' transcripts: a subagent counts toward the prompt that
67 * spawned it.
68 */
69export const tally = (main: string, agents: readonly string[] = []): Row[] => {
70  const rows: Row[] = []
71
72  for (const text of [main, ...agents]) {
73    // A response is stored once per content block, each copy with the whole usage.
74    const responses = new Map<string, { row: Row; model: string; usage: Use }>()
75    let row: Row | undefined
76
77    for (const line of text.split('\n')) {
78      let entry
79
80      try {
81        entry = JSON.parse(line)
82      } catch {
83        // A blank line, or one still being written.
84        continue
85      }
86
87      // A client-local command (/reload-plugins, /clear, …) echoes itself in a user entry but
88      // runs no model turn; it is not a prompt. A model-invoking command keeps its expanded
89      // prompt in a separate entry, so skipping the echo still leaves its row.
90      const content: unknown = entry?.message?.content
91      const isCommand = typeof content === 'string' && /^\s*<(command-|local-command-)/.test(content)
92
93      // A meta entry (a slash command's caveat) is never drawn: the prompt's row takes the next one's uuid.
94      if (
95        entry?.type === 'user' &&
96        entry.isMeta !== true &&
97        !isCommand &&
98        typeof entry.promptId === 'string' &&
99        entry.promptId !== row?.id
100      ) {
101        row = rows.find(known => known.id === entry.promptId)
102
103        if (row === undefined && text === main) {
104          row = {
105            id: entry.promptId,
106            uuid: String(entry.uuid),
107            model: '',
108            input: 0,
109            output: 0,
110            cacheRead: 0,
111            cacheWrite: 0,
112            usd: 0,
113            other: 0,
114            isUnpriced: false,
115          }
116          rows.push(row)
117        }
118      }
119
120      const message = entry?.type === 'assistant' ? entry.message : undefined
121      const owner = responses.get(message?.id)?.row ?? row ?? rows.at(-1)
122
123      if (message?.usage && owner !== undefined) {
124        responses.set(message.id, { row: owner, model: String(message.model), usage: message.usage })
125      }
126    }
127
128    for (const { row: owner, model, usage } of responses.values()) {
129      // The prompt's model is its own loop's last, never a subagent's or a made-up row's.
130      if (text === main && !model.startsWith('<')) {
131        owner.model = model.replace(/^.*claude-/, '').replace(/-\d{8}$/, '').slice(0, 9)
132      }
133
134      for (const use of usage.iterations?.length ? usage.iterations : [usage]) {
135        spend(owner, use.model ?? model, use)
136      }
137    }
138  }
139
140  return rows
141}
142
143/**
144 * The pane's state after a count: `rows` with each prompt's share of what the
145 * engine billed beyond the transcripts. `engine` is the engine's cost total,
146 * and `isNewPrompt` says a prompt was just submitted: growth up to here is
147 * the earlier prompt's, growth from here the new one's.
148 */
149export const settle = (
150  old: Usage,
151  path: string,
152  rows: Row[],
153  engine: number | undefined,
154  isNewPrompt: boolean,
155): Usage => {
156  if (engine === undefined) {
157    return { ...old, path, rows, rest: 0 }
158  }
159
160  const gap = engine - rows.reduce((sum, row) => sum + row.usd, 0)
161  const offset = old.offset ?? gap
162  const grown = gap - offset
163  const mark = isNewPrompt ? grown : old.mark
164  const marks = { ...old.marks }
165
166  for (const row of rows) {
167    marks[row.id] ??= mark
168  }
169
170  rows.forEach((row, i) => {
171    const next = rows[i + 1]
172    const end = next === undefined ? grown : (marks[next.id] ?? grown)
173    // A count taken between the engine's bill and the transcript's row can dip below zero.
174    row.other = Math.max(0, end - (marks[row.id] ?? end))
175  })
176
177  // Never below zero: a resumed session's engine total may not cover its earlier prompts.
178  const rest = Math.max(0, engine - rows.reduce((sum, row) => sum + row.usd + row.other, 0))
179
180  return { path, rows, offset, mark, marks, rest }
181}
182
types/index.d.ts 42 lines
1// One prompt of the session: `id` is the transcript's promptId, `uuid` the
2// prompt row's own id (what its UserMessage draws under).
3export type Row = {
4  id: string
5  uuid: string
6  // The main loop's model for the prompt, shortened for the table (`fable-5-1`).
7  model: string
8  input: number
9  output: number
10  cacheRead: number
11  cacheWrite: number
12  usd: number
13  // What the engine billed during the prompt beyond its transcript: internal
14  // calls (a web search's own request, titles, summaries) no transcript row holds.
15  other: number
16  // True when a model no rate is known for answered: `usd` leaves its tokens out.
17  isUnpriced: boolean
18}
19
20export type Usage = {
21  path: string | null
22  rows: Row[]
23  // The engine's cost total less the transcripts', as first seen: what the
24  // difference grows by afterwards is the internal calls' cost. Null until seen.
25  offset: number | null
26  // That growth when the latest prompt was submitted, and when each prompt began.
27  mark: number
28  marks: Record<string, number>
29  // What the engine billed that no row holds (internal calls from before the
30  // mod first counted): with it the table's sum is the engine's total, /cost's.
31  rest: number
32}
33
34// The prompt row whose border beats after its pane row was pressed.
35export type Flash = { uuid: string; isOn: boolean } | null
36
37declare module 'claude-code' {
38  interface PluginState {
39    'token-panel': { usage: Usage; flash: Flash; back: number; isShown: boolean }
40  }
41}
42