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

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

Requires Claude Code 2.1.287 or newer.
/plugin install token-panel --marketplace flowmux-ai/claude-token-panel
The pane starts hidden. The TokenPanel button above the prompt or /token-panel opens it, and /token-panel hides it again.
cost only.sum row matches /cost. Cost not tied to any prompt shows as etc unplaced.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 $.
claude --plugin-dir . # run the mod from this folder, reloading on save
claude plugin validate .
claude plugin test .
hooks/register.tsx 281 lines1import { 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}
281hooks/tally.ts 182 lines1import 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}
182types/index.d.ts 42 lines1// 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