SLOPSHOPPER

cost-info

What this session has cost so far, and the tokens it took, live.

newpanespinnercommandtoast
v0.7.1MITupdated 2026-10-04falconsw/cost-info/cost-info
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cost-info
│ ┃ cost-info ✕ › fix the failing auth test and add an audit log call │ ┃ ◉ 5h ━─── 31% │ ⏺ 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 │ │ › /spend │ ⎿ cost-info: Plan usage: │ ⎿ cost-info: 5h 31% used │ ⎿ cost-info: Turns 1 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ◉ 5h ━─── 31% ⟨Claude Code's own drawing⟩

Draws

Pane · cost-info
◉ 5h ━─── 31%
README

Cost Info

A Claude Code mod that shows what your session has cost so far, and the tokens it took, live.

Cost Info at the right of the line under the Claude Code prompt: ◉ 5h ━─── 15%  wk ━─── 7%  Resets in 3h 55m | Token Usage 104k / 70k

  • Under the prompt. ◉ $0.16 | Token Usage 142k / 30k | now $0.02 at the right of the line under the prompt, the figures in green, live, mid-turn too; once the turn ends now becomes last. A narrow terminal (under 120 columns) shows the session alone. The cost is the same figure /cost shows, subagents included.
  • Tokens. Every model request's input, cache writes, cache reads and output, summed, subagents included.
  • Budget. A one-time warning when the session crosses it.
  • /spend. Tokens, turns, average per turn, your priciest turn, and how much of the budget is used.
  • VS Code. The extension doesn't draw that line, so there the meter opens as a Cost pane. Run /spend to bring it back if you close it.

On a Pro or Max plan the dollars aren't what you're billed, so when Claude Code reports rate limits the meter shows how much of each is used instead: ◉ 5h ━─── 15% wk ━─── 7% Resets in 3h 55m | Token Usage 104k / 70k: a thin gauge for the 5-hour and weekly windows, and the time until the sooner one resets. It reads what Claude Code already receives, so it costs nothing extra. Without rate limits (API billing) it shows cost as before. Tokens count from when the mod loaded into the session, so a resumed session's earlier tokens aren't in the total.

Install

Requires Claude Code 2.1.288 or later. Run these in a shell, not inside a Claude Code session:

claude plugin marketplace add falconsw/cost-info
claude plugin install cost-info@falconsw-mods

Then load it into your open Claude Code session:

/reload-plugins

Or start a new session, in the terminal or in VS Code.

To get a new version, run claude plugin update cost-info@falconsw-mods, then /reload-plugins. If it says it's already at the latest version, run claude plugin marketplace update falconsw-mods first. To remove it, run claude plugin uninstall cost-info@falconsw-mods.

Configure

The budget is $5 unless you set it; 0 turns it off. Set it from a shell, the value written as a string, then restart Claude Code:

echo '{"budget": "10"}' | claude plugin configure cost-info@falconsw-mods --values-stdin

Or set it while installing:

claude plugin install cost-info@falconsw-mods --config budget=10

claude plugin configure cost-info@falconsw-mods shows whether it's set.

Develop

claude plugin validate ./cost-info
claude plugin test ./cost-info
claude --plugin-dir ./cost-info   # reloads as you save

A mod runs inside Claude Code with the same access Claude Code has. Read the source before you install it: it's one file, cost-info/hooks/register.tsx.

Source 2 files
hooks/register.tsx 357 lines
1// Cost Info: what this session has cost so far, and the tokens it took, live.
2//
3// The figure is the one /cost shows ($.session.usage().cost.usd): every priced
4// API response this session, subagents included. On a Pro or Max plan it is
5// what the same usage would have cost on the API. Tokens are every model
6// request's four counts summed (input, cache writes, cache reads, output),
7// subagents included, counted from when the mod loaded into the session.
8
9import { atom, read, update } from 'claude-code'
10import type { BoxProps, ElementConstructor, EngineInterface, ModelUsage, Register, TextProps } from 'claude-code'
11
12import type { Limit, Totals } from '../types'
13
14const EMPTY: Totals = {
15  session: null,
16  total: 0,
17  turnBase: 0,
18  last: null,
19  turns: 0,
20  turnsCost: 0,
21  priciest: 0,
22  warned: false,
23  tokens: 0,
24  turnTokens: 0,
25  isWorking: false,
26  limits: [],
27}
28// Held by the host, so the totals survive a hot reload of this file.
29const meter = atom({ plugin: 'cost-info', key: 'meter' } as const, EMPTY)
30// VS Code draws no prompt footer, so there the meter opens in a pane of its own.
31const PANE = 'cost-info'
32const TITLE = 'Cost'
33const COMMAND = 'spend'
34const NOTHING = 'Nothing spent yet this session.'
35// Below this many columns the footer leaves the turn out.
36const WIDE = 120
37
38type Kit = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
39
40// One run of the meter's text: a figure, drawn green, or the words around the figures.
41type Piece = { text: string; isFigure?: boolean; color?: string }
42
43const money = (usd: number): string => `$${usd < 0.01 ? usd.toFixed(4) : usd.toFixed(2)}`
44
45const count = (n: number): string =>
46  n < 1000 ? `${n}` : n < 999_500 ? `${(n / 1000).toFixed(n < 9_950 ? 1 : 0)}k` : `${(n / 1_000_000).toFixed(2)}M`
47
48const tok = (n: number): string => `${count(n)} tok`
49
50// A plan's rate-limit window, read from whatever shape the host reports it in. Plans that have
51// none (API billing) report an empty list, which is how the meter knows to show cost instead.
52const WINDOWS: Record<string, string> = {
53  five_hour: '5h',
54  seven_day: 'wk',
55  seven_day_opus: 'wk Opus',
56  seven_day_sonnet: 'wk Sonnet',
57}
58
59const limitOf = (raw: unknown): Limit | null => {
60  const r = raw as Record<string, unknown> | null
61  if (r === null || typeof r !== 'object') {
62    return null
63  }
64  const pick = (...keys: string[]): unknown => keys.map(k => r[k]).find(v => v !== undefined && v !== null)
65  const used = pick('percentUsed', 'usedPercentage', 'used_percentage', 'usedPercent', 'percent', 'utilization')
66  if (typeof used !== 'number') {
67    return null
68  }
69  const name = String(pick('kind', 'label', 'name', 'type', 'rateLimitType', 'window', 'id') ?? 'limit')
70  const at = pick('resetsAt', 'resets_at', 'resetAt', 'reset')
71  const ms = typeof at === 'number' ? (at < 1e11 ? at * 1000 : at) : typeof at === 'string' ? Date.parse(at) : NaN
72
73  return {
74    label: WINDOWS[name] ?? name,
75    percent: Math.round(used <= 1 && !Number.isInteger(used) ? used * 100 : used),
76    resetsAt: Number.isNaN(ms) ? null : ms,
77  }
78}
79
80const limitsOf = (raw: unknown): Limit[] => (Array.isArray(raw) ? raw.map(limitOf).filter((l): l is Limit => l !== null) : [])
81
82// Time until a window resets, in its largest units: 6d, 3h 20m, 45m. Empty when unknown or past.
83const left = (resetsAt: number | null, now = Date.now()): string => {
84  if (resetsAt === null || resetsAt <= now) {
85    return ''
86  }
87  const min = Math.ceil((resetsAt - now) / 60_000)
88  if (min >= 1440) {
89    const days = Math.floor(min / 1440)
90    const hours = Math.floor((min % 1440) / 60)
91
92    return days >= 2 || hours === 0 ? `${days}d` : `${days}d ${hours}h`
93  }
94
95  return min >= 60 ? `${Math.floor(min / 60)}h ${min % 60}m` : `${min}m`
96}
97
98const tokensOf = (u: ModelUsage): number =>
99  u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
100
101const BAR = 4
102
103// A thin gauge: the used share filled (at least a dot once anything is used), the rest a track.
104// It turns yellow from 70% and red from 90%.
105const gauge = (percent: number): Piece[] => {
106  const filled = percent <= 0 ? 0 : Math.min(BAR, Math.max(1, Math.round((percent / 100) * BAR)))
107
108  return [
109    { text: '━'.repeat(filled), color: percent >= 90 ? 'red' : percent >= 70 ? 'yellow' : 'green' },
110    { text: '─'.repeat(BAR - filled), color: 'gray' },
111  ]
112}
113
114// The plan's windows as gauges, and when the soonest one resets.
115const limitsPieces = (limits: Limit[]): Piece[] => {
116  const next = limits.filter(l => left(l.resetsAt) !== '').sort((a, b) => a.resetsAt! - b.resetsAt!)[0]
117  const reset = next === undefined ? '' : left(next.resetsAt)
118
119  return [
120    ...limits.flatMap((l, i): Piece[] => [
121      { text: i > 0 ? '  ' : '' },
122      { text: `${l.label} `, color: 'gray' },
123      ...gauge(l.percent),
124      { text: ` ${l.percent}%`, isFigure: false },
125    ]),
126    ...(reset === '' ? [] : [{ text: `  Resets in ${reset}`, color: 'gray' }]),
127  ]
128}
129
130// Tokens used: the session's total, then the running or last turn's, as `Token Usage 104k / 70k`.
131const usageOf = (m: Totals, withLast: boolean): string =>
132  `Token Usage ${count(m.tokens)}${withLast && m.turnTokens > 0 ? ` / ${count(m.turnTokens)}` : ''}`
133
134// The session's cost and tokens behind its mark, and the budget where one is shown.
135// On a plan with rate limits the dollars mean nothing, so the limits' used share stands in for them.
136const sessionOf = (m: Totals, budget = 0, withLast = false): Piece[] => [
137  { text: '◉ ' },
138  ...(m.limits.length > 0 ? limitsPieces(m.limits) : [{ text: money(m.total), isFigure: true }]),
139  ...(m.tokens > 0 ? [{ text: ' | ' }, { text: usageOf(m, withLast), isFigure: true }] : []),
140  ...(budget > 0 && m.limits.length === 0 ? [{ text: ` of ${money(budget)} budget` }] : []),
141]
142
143// The running turn's cost while it works, the last turn's after; null before the first and on a
144// plan, where the dollars mean nothing and the turn's tokens already sit beside the session's.
145const turnOf = (m: Totals, withCount = false): Piece[] | null => {
146  const cost = m.isWorking ? m.total - m.turnBase : m.last
147  if (cost === null || m.limits.length > 0) {
148    return null
149  }
150
151  return [
152    { text: m.isWorking ? 'now ' : 'last ' },
153    { text: money(cost), isFigure: true },
154    ...(withCount && !m.isWorking ? [{ text: ` · ${m.turns} ${m.turns === 1 ? 'turn' : 'turns'}` }] : []),
155  ]
156}
157
158const draw = ({ Box, Text }: Kit, key: string, pieces: Piece[]) => (
159  <Box key={key} flexDirection="row">
160    {pieces.map(piece =>
161      piece.text === '' ? null : piece.isFigure || piece.color !== undefined ? (
162        <Text color={piece.isFigure ? 'green' : piece.color}>{piece.text}</Text>
163      ) : (
164        <Text>{piece.text}</Text>
165      ),
166    )}
167  </Box>
168)
169
170export const register: Register = (on, options) => {
171  const budget = typeof options.budget === 'number' ? options.budget : 0
172
173  on('session.start', async ($, e, next) => {
174    const result = await next(e)
175    const { startedAt, cost, rateLimits } = await $.session.usage()
176    await spend($, startedAt, cost?.usd, budget, undefined, limitsOf(rateLimits)) // a new session starts from zero, a reload keeps its totals
177    await $.command.register({ name: COMMAND, description: 'Show what this session has cost, turn by turn' })
178
179    return result
180  })
181
182  // Fires whenever the status line's figures move, mid-turn too, so the meter keeps up live.
183  on('session.measure', async ($, e, next) => {
184    const result = await next(e)
185    if (e.changed.includes('cost') && e.cost !== undefined) {
186      await spend($, (await $.session.usage()).startedAt, e.cost.usd, budget, undefined, limitsOf(e.rateLimits))
187    } else if (e.changed.some(c => (c as string) === 'rateLimits')) {
188      await update($, meter, m => ({ ...m, limits: limitsOf(e.rateLimits) }))
189    }
190
191    return result
192  })
193
194  on('turn.start', async ($, e, next) => {
195    await update($, meter, m => ({ ...m, turnTokens: 0, isWorking: true }))
196
197    return next(e)
198  })
199
200  // One model request, the main loop's or a subagent's: its tokens count once its response is in.
201  on('turn.step', async function* ($, e, next) {
202    const result = yield* next(e)
203    if (result.usage !== null) {
204      const n = tokensOf(result.usage)
205      await update($, meter, m => ({
206        ...m,
207        tokens: m.tokens + n,
208        turnTokens: m.isWorking ? m.turnTokens + n : m.turnTokens,
209      }))
210    }
211
212    return result
213  })
214
215  on('turn.complete', async ($, e, next) => {
216    const result = await next(e)
217    if (e.agentId === undefined) {
218      const { startedAt, cost, rateLimits } = await $.session.usage()
219      await spend($, startedAt, cost?.usd, budget, m => {
220        const last = m.total - m.turnBase
221
222        return {
223          ...m,
224          turnBase: m.total,
225          last,
226          turns: m.turns + 1,
227          turnsCost: m.turnsCost + last,
228          priciest: Math.max(m.priciest, last),
229          isWorking: false,
230        }
231      }, limitsOf(rateLimits))
232    }
233
234    return result
235  })
236
237  on('session.attach', { surface: 'vscode' }, async ($, e, next) => {
238    const result = await next(e)
239    void $.ui.open({ id: PANE, title: TITLE })
240
241    return result
242  })
243
244  on('command.run', { command: COMMAND }, async $ => {
245    const m = await read($, meter)
246    if ((await $.session.surfaces()).includes('vscode')) {
247      await $.ui.open({ id: PANE, title: TITLE }) // asked for, so it is placed at any width
248    }
249
250    const text = report(m, budget)
251    if (m.limits.length > 0) {
252      return { text }
253    }
254    // No limits parsed: show what the host reports, unparsed, to find out why.
255    const u = await $.session.usage()
256
257    return { text: `${text}\n\n(no plan limits) usage keys: ${Object.keys(u).join(', ')}\nrateLimits: ${JSON.stringify(u.rateLimits)}` }
258  })
259
260  // Under the prompt, left of the engine's own mode labels, which stay as the engine drew them.
261  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
262    const modes = await next(e)
263    const m = await read($, meter)
264    if (m.total === 0 && m.tokens === 0 && m.limits.length === 0) {
265      return modes
266    }
267    const isWide = (e.viewport?.columns ?? WIDE) >= WIDE
268    const turn = isWide ? turnOf(m) : null
269    const pieces = turn === null ? sessionOf(m, 0, isWide) : [...sessionOf(m, 0, true), { text: ' | ' }, ...turn]
270    const kit = $.ui.resolve(e)
271
272    return (
273      <kit.Box flexDirection="row">
274        {draw(kit, 'meter', pieces)}
275        {e.props.modes.length > 0 && <kit.Text>{'  '}</kit.Text>}
276        {modes}
277      </kit.Box>
278    )
279  })
280
281  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
282    const m = await read($, meter)
283    const kit = $.ui.resolve(e)
284    if (m.total === 0 && m.limits.length === 0) {
285      return <kit.Text>{NOTHING}</kit.Text>
286    }
287    const turn = turnOf(m, true)
288
289    return (
290      <kit.Box flexDirection="column" paddingX={1}>
291        {draw(kit, 'session', sessionOf(m, budget, true))}
292        {turn !== null && draw(kit, 'turn', turn)}
293      </kit.Box>
294    )
295  })
296}
297
298// The stored totals, or empty ones if they belong to an earlier session.
299const fresh = (m: Totals, startedAt: number): Totals =>
300  m.session === startedAt ? { ...EMPTY, ...m } : { ...EMPTY, session: startedAt }
301
302// Moves the session's cost to `usd` (kept where the host has no ledger) and applies `then`,
303// warning once when the cost passes the budget.
304const spend = async (
305  $: EngineInterface,
306  startedAt: number,
307  usd: number | undefined,
308  budget: number,
309  then: (m: Totals) => Totals = m => m,
310  limits?: Limit[],
311): Promise<Totals> => {
312  let isCrossed = false
313  const m = await update($, meter, value => {
314    const m = fresh(value, startedAt)
315    const total = usd ?? m.total
316    const isOver = budget > 0 && total >= budget && (limits ?? m.limits).length === 0
317    isCrossed = isOver && !m.warned
318    // A meter that starts mid-session (installed into it, a resumed session) counts turns from here.
319    const turnBase = value.session === startedAt ? Math.min(m.turnBase, total) : total
320
321    return then({ ...m, total, turnBase, warned: m.warned || isOver, limits: limits ?? m.limits })
322  })
323  if (isCrossed) {
324    await $.ui.toast(`Cost Info: this session passed your ${money(budget)} budget`)
325  }
326
327  return m
328}
329
330const report = (m: Totals, budget: number): string => {
331  if (m.total === 0 && m.limits.length === 0) {
332    return NOTHING
333  }
334  if (m.limits.length > 0) {
335    return [
336      'Plan usage:',
337      ...m.limits.map(l => `  ${l.label.padEnd(10)} ${l.percent}% used${left(l.resetsAt) === '' ? '' : ` (resets in ${left(l.resetsAt)})`}`),
338      ...(m.tokens > 0 ? [`  Tokens     ${tok(m.tokens)} this session`] : []),
339      ...(m.turns > 0 ? [`  Turns      ${m.turns}`] : []),
340    ].join('\n')
341  }
342  const lines = [`This session: ${money(m.total)}`]
343  if (m.tokens > 0) {
344    lines.push(`  Tokens        ${tok(m.tokens)}`)
345  }
346  if (m.turns > 0) {
347    lines.push(`  Turns         ${m.turns}`)
348    lines.push(`  Per turn      ${money(m.turnsCost / m.turns)} on average`)
349    lines.push(`  Priciest turn ${money(m.priciest)}`)
350  }
351  if (budget > 0) {
352    lines.push(`  Budget        ${money(budget)} (${Math.round((m.total / budget) * 100)}% used)`)
353  }
354
355  return lines.join('\n')
356}
357
types/index.d.ts 23 lines
1export type Limit = { label: string; percent: number; resetsAt: number | null }
2
3export type Totals = {
4  session: number | null
5  total: number
6  turnBase: number
7  last: number | null
8  turns: number
9  turnsCost: number
10  priciest: number
11  warned: boolean
12  tokens: number
13  turnTokens: number
14  isWorking: boolean
15  limits: Limit[]
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    'cost-info': { meter: Totals }
21  }
22}
23