SLOPSHOPPER

token-bar

How full the context window and the quota are, and what today cost, above the prompt.

newbandprocesstimer
v2.2.1MITupdated 2026-10-07santosli/claude-mods/token-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-bar
› fix the failing auth test and add an audit log call ⏺ 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 [██░░░] 49% of context ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[██░░░] 49% of context
README

claude-mods

Claude Code mods, shipped as plugins. Requires Claude Code 2.1.287 or later.

token-bar

One line above the prompt that shows how full your context window and your plan's usage limits are, and what today's usage would cost at API prices.

https://github.com/user-attachments/assets/d3748a88-43b6-4c02-a343-623e2c3aecdd

token-bar in the Claude desktop app, above the prompt

◔ 36% of context │ ◔ 12% of session ↻ 2h 46m │ ◌ 8% of weekly │ $42.17 today · 118M tokens
  • Context: how much of the current conversation's context window is used.
  • Session: how much of the 5-hour usage limit is used, and when it resets.
  • Weekly: how much of the weekly usage limit is used.
  • Today: tokens used today across all your Claude Code sessions, and what they would cost at API prices (see below).
  • Each one is colored like a phone battery: green under 60%, yellow under 80%, red from 80%.
  • It updates after every turn and after /compact or /clear; the quota, countdown and today's total refresh every 30 seconds.
  • The desktop app draws rings (context and session fill clockwise; weekly is split into seven arcs, one a day) with a rule between groups; the terminal draws text.
  • When the band is too narrow, groups are hidden from the right and leave a colored • behind.

Session and weekly only appear on a Claude subscription, once the first reply of the session has arrived. With an API key there are no usage limits to show, so only the context is drawn.

How today's cost is counted

Today's figure is read from Claude Code's local transcripts (~/.claude/projects, ~/.config/claude/projects, every entry of CLAUDE_CONFIG_DIR, and the desktop app's Cowork sessions), so it includes sessions that ran before the plugin loaded. It follows ccusage's rules and matches its claude daily output to the cent:

  • one count per reply (message id + request id), keeping the fullest copy; a subagent's replay of its parent is not counted twice;
  • tokens are input + output + cache reads + cache writes, bucketed by your local day;
  • cost uses Anthropic's list prices: 5-minute cache writes at 1.25× input, 1-hour at 2×, fast mode at the model's multiplier.

It is an API-equivalent figure: on a subscription you are not billed this amount. Models it has no price for count toward tokens but not cost. The scan reads only new bytes each time and keeps its offsets in ~/.token-bar/. It runs through osascript, so today's figure is macOS only; elsewhere the rest of the band works as before.

Install

In Claude Code:

/plugin marketplace add santosli/claude-mods
/plugin install token-bar@santos-mods

Or from a shell:

claude plugin marketplace add santosli/claude-mods
claude plugin install token-bar@santos-mods --scope user

Then start a new session (or restart the desktop app).

Develop

claude --plugin-dir ./token-bar      # loads it and reloads on every save
claude plugin validate ./token-bar
claude plugin test ./token-bar

中文说明

token-bar 在 Claude Code 输入框上方显示一行用量(30 秒宣传片):

  • context:当前对话的上下文窗口已用多少
  • session:5 小时额度已用多少,以及多久后重置
  • weekly:每周额度已用多少
  • today:今天所有 Claude Code 会话一共用了多少 token,按 API 价格折算值多少钱

颜色跟手机电量一样:60% 以下绿色,60–79% 黄色,80% 及以上红色。每轮回复后、/compact 和 /clear 后更新;额度、倒计时和今日用量每 30 秒刷新一次。session 和 weekly 只在订阅账号下显示(会话收到第一条回复后出现)。

今日用量读取本地对话记录,所以插件加载之前的会话也算在内。计算规则和 ccusage 一致(按回复去重、按本地日期统计、按官方价格计费),结果与 ccusage claude daily 逐日一致。金额是按 API 价格的折算值,订阅用户并不会被这样扣费。今日用量依赖 osascript,仅支持 macOS。

安装:在 Claude Code 里运行

/plugin marketplace add santosli/claude-mods
/plugin install token-bar@santos-mods

然后新开一个会话(桌面端重启一下 app)。

License

MIT

Source 4 files
hooks/register.tsx 401 lines
1// Token Bar: how full the context window and the quota are, above the prompt.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register } from 'claude-code'
4
5import type { TokenBarLimit, TokenBarReading, TokenBarToday } from '../types'
6import { bars, barsWidth, ring, week } from './draw'
7import { SCAN_SCRIPT, SCAN_STATE, scanRoots } from './scan'
8
9const HISTORY = 12
10const TICK_MS = 30_000
11const BARS = '▁▂▃▄▅▆▇█'
12// Colored by how much is used, as a phone's battery is: green, then yellow, then red.
13// `color` is the terminal's name; `hex` the desktop's, chosen to read on a light card and a dark one.
14const LEVELS: readonly { upTo: number; color: string; hex: string }[] = [
15  { upTo: 60, color: 'green', hex: '#30a14e' },
16  { upTo: 80, color: 'yellow', hex: '#d99a00' },
17  { upTo: Infinity, color: 'red', hex: '#e5484d' },
18]
19// The rule between two groups on the desktop.
20const RULE = 'rgba(128,128,128,0.45)'
21// The turns chart and last-turn delta: off for now; the history is still kept.
22const SHOW_TURNS = false
23
24// Held by the host, so they survive a hot reload of this file.
25const readings = atom({ plugin: 'token-bar', key: 'readings' } as const, [] as TokenBarReading[])
26const limits = atom({ plugin: 'token-bar', key: 'limits' } as const, [] as TokenBarLimit[])
27// The clock the countdown is drawn against; ticked so the band redraws.
28const now = atom({ plugin: 'token-bar', key: 'now' } as const, 0)
29const today = atom({ plugin: 'token-bar', key: 'today' } as const, null as TokenBarToday | null)
30
31export const register: Register = on => {
32  on('session.start', async ($, e, next) => {
33    const result = await next(e)
34    // The quota is the account's: show the last one known until a response reports it again.
35    const stored = await $.store.get('limits').catch(() => undefined)
36    if (Array.isArray(stored)) await saveLimits($, stored as TokenBarLimit[])
37    await takeReading($)
38    await askApp($)
39    await scanToday($)
40    // Quota moves between turns too, and the countdown runs down: refresh both.
41    $.clock.every(TICK_MS, () => refresh($))
42    return result
43  })
44
45  on('turn.complete', async ($, e, next) => {
46    const result = await next(e)
47    if (!e.agentId) {
48      await takeReading($) // main-loop turns only, not subagents
49    }
50    if (!e.agentId) await scanToday($)
51    return result
52  })
53
54  // The engine's own measurements: the context's fill and the quota, whenever either moves.
55  on('session.measure', async ($, e, next) => {
56    if (e.changed.includes('rateLimits')) await saveLimits($, e.rateLimits, true)
57    if (e.changed.includes('context') && e.context.tokens !== undefined) await addReading($, e.context.tokens, e.context.window)
58    return next(e)
59  })
60
61  // A compaction is not a turn, and no response has measured the window since: estimate it,
62  // system prompt and tools included, as /context does. The size the compaction reports
63  // counts the conversation alone.
64  on('session.compact', async ($, e, next) => {
65    const result = await next(e)
66    if (e.agentId || e.trigger === 'precompute' || 'skip' in result) return result
67    const after = 'tokensAfter' in result ? result.tokensAfter : undefined
68    await setContext($, (await estimate($)) ?? after)
69    return result
70  })
71
72  // A /clear starts the conversation over, with no session.start.
73  on('session.end', async ($, e, next) => {
74    if (e.reason !== 'clear') return next(e)
75    await setContext($, 0)
76    const result = await next(e)
77    // Once the conversation is gone, what is left is the system prompt and the tools.
78    $.clock.after(1_000, () => void estimate($).then(t => setContext($, t)))
79    return result
80  })
81
82  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
83    const history = await read($, readings)
84    const last = history.at(-1)
85    if (e.props.hasSurvey || !last) {
86      return next(e)
87    }
88
89    const quota = await read($, limits)
90    const at = await read($, now)
91    const lv = level(last.percent)
92    const fiveHour = quota.find(l => l.kind === 'five_hour')
93    const sevenDay = quota.find(l => l.kind === 'seven_day')
94    const columns = e.props.bodyColumns
95    const spent = await read($, today)
96
97    if (e.surface !== 'terminal') {
98      const { Box, Text, Svg } = $.ui.resolve(e)
99      // Each group's width in columns, so a narrow band drops the right-most first.
100      const groups: { key: string; color: string; cells: number; draw: () => JSX.Element }[] = [
101        {
102          key: 'context',
103          color: lv.hex,
104          cells: fit(2, `${last.percent}%`, 'of context'),
105          draw: () => (
106            <Box key="context" flexDirection="row" alignItems="center" gap={1}>
107              <Svg source={ring(last.percent, lv.hex)} alt={`${last.percent}% of context used`} width={18} height={18} />
108              <Text color={lv.hex} bold>{`${last.percent}%`}</Text>
109              <Text dimColor>of context</Text>
110            </Box>
111          ),
112        },
113      ]
114      if (fiveHour) {
115        const q = level(fiveHour.percentUsed)
116        const qUsed = used(fiveHour.percentUsed)
117        groups.push({
118          key: 'five-hour',
119          color: q.hex,
120          cells: fit(2, `${qUsed}%`, 'of session', fiveHour.resetsAt ? `↻ ${countdown(fiveHour.resetsAt, at)}` : ''),
121          draw: () => (
122            <Box key="five-hour" flexDirection="row" alignItems="center" gap={1}>
123              <Svg source={ring(qUsed, q.hex)} alt={`${qUsed}% of session used`} width={18} height={18} />
124              <Text color={q.hex} bold>{`${qUsed}%`}</Text>
125              <Text dimColor>of session</Text>
126              {fiveHour.resetsAt && <Text dimColor>{`↻ ${countdown(fiveHour.resetsAt, at)}`}</Text>}
127            </Box>
128          ),
129        })
130      }
131      if (sevenDay) {
132        const q = level(sevenDay.percentUsed)
133        const qUsed = used(sevenDay.percentUsed)
134        groups.push({
135          key: 'seven-day',
136          color: q.hex,
137          cells: fit(2, `${qUsed}%`, 'of weekly'),
138          draw: () => (
139            <Box key="seven-day" flexDirection="row" alignItems="center" gap={1}>
140              <Svg source={week(qUsed, q.hex)} alt={`${qUsed}% of weekly used`} width={18} height={18} />
141              <Text color={q.hex} bold>{`${qUsed}%`}</Text>
142              <Text dimColor>of weekly</Text>
143            </Box>
144          ),
145        })
146      }
147      if (spent) {
148        groups.push({
149          key: 'today',
150          color: '#8a8a8a',
151          cells: fit(0, money(spent.usd), `today · ${short(spent.tokens)} tokens`),
152          draw: () => (
153            <Box key="today" flexDirection="row" alignItems="center" gap={1}>
154              <Text bold>{money(spent.usd)}</Text>
155              <Text dimColor>{`today · ${short(spent.tokens)} tokens`}</Text>
156            </Box>
157          ),
158        })
159      }
160      if (SHOW_TURNS && history.length > 1) {
161        groups.push({
162          key: 'trend',
163          color: lv.hex,
164          cells: 22,
165          draw: () => (
166            <Box key="trend" flexDirection="row" alignItems="center" gap={1}>
167              <Svg source={bars(history.map(r => r.tokens), lv.hex)} alt={`Context after each of the last ${history.length} turns`} width={barsWidth(history.length)} height={14} />
168              <Text dimColor>{trend(history).trim()}</Text>
169            </Box>
170          ),
171        })
172      }
173      let room = columns - 2
174      // Groups sit a rule apart: a column of space, the rule, and a column again; three in all.
175      const shown = groups.filter((g, i) => (room -= g.cells + (i > 0 ? 3 : 0)) >= 0 || i === 0)
176      const hidden = groups.slice(shown.length)
177
178      return (
179        <Box flexDirection="row" alignItems="center" paddingX={1} gap={1}>
180          {shown.flatMap((g, i) => [
181            ...(i > 0 ? [<Text key={`rule-${g.key}`} color={RULE}>│</Text>] : []),
182            g.draw(),
183          ])}
184          {hidden.length > 0 && (
185            <Box key="hidden" flexDirection="row" gap={1}>
186              {hidden.map(g => (
187                <Text key={g.key} color={g.color}>•</Text>
188              ))}
189            </Box>
190          )}
191        </Box>
192      )
193    }
194
195    const { Box, Text } = $.ui.resolve(e)
196    const q5 = fiveHour && level(fiveHour.percentUsed)
197    const q7 = sevenDay && level(sevenDay.percentUsed)
198
199    return (
200      <Box flexDirection="row" paddingX={1}>
201        <Text color={lv.color}>{gauge(last.percent)}</Text>
202        <Text color={lv.color} bold>{` ${last.percent}%`}</Text>
203        <Text dimColor>{' of context'}</Text>
204        {columns >= 80 && fiveHour && <Text color={q5!.color} bold>{`   ${used(fiveHour.percentUsed)}%`}</Text>}
205        {columns >= 80 && fiveHour && <Text dimColor>{' of session'}</Text>}
206        {columns >= 80 && fiveHour?.resetsAt && <Text dimColor>{` ↻ ${countdown(fiveHour.resetsAt, at)}`}</Text>}
207        {columns >= 80 && sevenDay && <Text color={q7!.color} bold>{`   ${used(sevenDay.percentUsed)}%`}</Text>}
208        {columns >= 80 && sevenDay && <Text dimColor>{' of weekly'}</Text>}
209        {columns >= 110 && spent && <Text bold>{`   ${money(spent.usd)}`}</Text>}
210        {columns >= 110 && spent && <Text dimColor>{` today · ${short(spent.tokens)} tokens`}</Text>}
211        {SHOW_TURNS && columns >= 120 && <Text dimColor>{'   last turns '}</Text>}
212        {SHOW_TURNS && columns >= 120 && <Text color={lv.color}>{sparkline(history)}</Text>}
213        {SHOW_TURNS && columns >= 120 && history.length > 1 && <Text dimColor>{trend(history)}</Text>}
214      </Box>
215    )
216  })
217}
218
219// The desktop's text is proportional: a character takes about 0.8 of a column there
220// (measured on the band: `of context` is 123px, a column 16px). Rounded up for bold.
221const CHAR = 0.85
222
223// A group's width in columns: its icon's, its texts' and one between each.
224function fit(icon: number, ...texts: string[]) {
225  const parts = texts.filter(Boolean)
226  return Math.ceil(icon + parts.reduce((n, t) => n + t.length * CHAR, 0) + parts.length - (icon ? 0 : 1))
227}
228
229async function takeReading($: EngineInterface) {
230  const { context, rateLimits } = await $.session.usage()
231  await saveLimits($, rateLimits, true)
232  if (!context?.window) return
233  // Before a response has measured the window (a new session, or one just compacted) there
234  // is no fill: estimate it rather than draw an empty window.
235  const tokens = context.tokens ?? (await estimate($))
236  if (tokens !== undefined) await addReading($, tokens, context.window)
237}
238
239// The window's fill as /context estimates it locally, without a request.
240async function estimate($: EngineInterface) {
241  try {
242    const { context } = await $.session.usage({ breakdown: 'summary' })
243    return context.breakdown?.totalTokens
244  } catch {
245    return undefined
246  }
247}
248
249// A reading of the size given, against the window the last reading had.
250async function setContext($: EngineInterface, tokens: number | undefined) {
251  if (tokens === undefined) return
252  const last = (await read($, readings)).at(-1)
253  const window = last?.window ?? (await $.session.usage()).context?.window
254  if (window) await addReading($, tokens, window)
255}
256
257async function addReading($: EngineInterface, tokens: number, window: number) {
258  const percent = Math.round((tokens / window) * 100)
259  await update($, readings, history => {
260    const last = history.at(-1)
261    if (last && last.tokens === tokens && last.window === window) return history
262    return [...history, { tokens, window, percent }].slice(-HISTORY)
263  })
264}
265
266async function refresh($: EngineInterface) {
267  const { rateLimits } = await $.session.usage()
268  await saveLimits($, rateLimits, true)
269  await askApp($)
270  await scanToday($) // other sessions add to today too
271  const at = await $.clock.now()
272  await update($, now, () => at)
273}
274
275// Whether the engine has reported the quota in this session. The desktop app's sessions
276// may not pass it on; the app's own usage card is asked then.
277let engineReports = false
278let askedAt = -Infinity
279const APP_USAGE = 'mcp__ccd_session_mgmt__get_usage'
280const ASK_EVERY_MS = 120_000
281
282async function askApp($: EngineInterface) {
283  if (engineReports) return
284  const at = await $.clock.now()
285  if (at - askedAt < ASK_EVERY_MS) return
286  askedAt = at
287  try {
288    if (!(await $.tool.list()).some(t => t.name === APP_USAGE)) return
289    const answer = await $.tool.call({ tool: APP_USAGE } as Parameters<EngineInterface['tool']['call']>[0])
290    if (answer.isError || answer.deny !== undefined) return
291    const text = answer.text ?? textOf(answer.result)
292    const plan = JSON.parse(text.slice(text.indexOf('{'))).plan as
293      | { status: string; windows?: { label: string; percentUsed: number; resetsAt?: string }[] }
294      | undefined
295    if (plan?.status !== 'ok' || !plan.windows) return
296    const found: TokenBarLimit[] = []
297    for (const w of plan.windows) {
298      const kind = /^5-hour/i.test(w.label) ? 'five_hour' : /^weekly · all/i.test(w.label) ? 'seven_day' : undefined
299      if (kind && typeof w.percentUsed === 'number') found.push({ kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt })
300    }
301    if (!engineReports) await saveLimits($, found)
302  } catch {
303    // No app to ask, or it could not answer: keep what is shown.
304  }
305}
306
307// An MCP tool's output as text: a string, or its content blocks' text joined.
308function textOf(result: unknown): string {
309  if (typeof result === 'string') return result
310  if (Array.isArray(result)) return result.map(b => (b && typeof b.text === 'string' ? b.text : '')).join('')
311  return ''
312}
313
314async function saveLimits($: EngineInterface, rateLimits: readonly TokenBarLimit[], fromEngine = false) {
315  if (fromEngine && rateLimits.length > 0) engineReports = true
316  const at = await $.clock.now()
317  // A response may report only some windows: keep each window's last reading until it resets.
318  await update($, limits, kept => {
319    const byKind = new Map(kept.map(l => [l.kind, l]))
320    for (const { kind, percentUsed, resetsAt } of rateLimits) byKind.set(kind, { kind, percentUsed, resetsAt })
321    return [...byKind.values()].filter(l => !l.resetsAt || Date.parse(l.resetsAt) > at)
322  })
323  // Kept across sessions too, for the next one to start from.
324  if (rateLimits.length > 0) await $.store.set('limits', await read($, limits)).catch(() => {})
325  await update($, now, () => at)
326}
327
328// The local calendar day, as YYYY-MM-DD.
329function dayOf(ms: number) {
330  const d = new Date(ms)
331  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
332}
333
334// One scan at a time per session; a request while one runs is folded into it.
335let scanning: Promise<void> | null = null
336
337function scanToday($: EngineInterface) {
338  scanning ??= runScan($).finally(() => (scanning = null))
339  return scanning
340}
341
342// Counts today from the transcripts. Off macOS, or when the scan fails, the band
343// simply goes without today's group.
344async function runScan($: EngineInterface) {
345  try {
346    const day = dayOf(await $.clock.now())
347    const roots = scanRoots(await $.env.get('CLAUDE_CONFIG_DIR'))
348    const { exitCode, stdout } = await $.process.run(
349      ['osascript', '-l', 'JavaScript', '-e', SCAN_SCRIPT, roots, SCAN_STATE, day],
350      { timeoutMs: 60_000 },
351    )
352    if (exitCode !== 0) return
353    const r = JSON.parse(stdout) as { day: string; tokens: number; usd: number }
354    if (r.day !== day || typeof r.tokens !== 'number' || typeof r.usd !== 'number') return
355    await update($, today, () => ({ day: r.day, tokens: r.tokens, usd: r.usd }))
356  } catch {
357    // Leave the last count in place.
358  }
359}
360
361function money(usd: number) {
362  return usd >= 100 ? `$${Math.round(usd)}` : `$${usd.toFixed(2)}`
363}
364
365function level(percentUsed: number) {
366  return LEVELS.find(l => percentUsed < l.upTo) ?? LEVELS[LEVELS.length - 1]!
367}
368
369function used(percentUsed: number) {
370  return Math.round(percentUsed)
371}
372
373// The terminal's gauge: five cells, filled as far as the context is used.
374function gauge(used: number) {
375  const full = Math.round((Math.min(Math.max(used, 0), 100) / 100) * 5)
376  return `[${'█'.repeat(full)}${'░'.repeat(5 - full)}]`
377}
378
379function countdown(resetsAt: string, at: number) {
380  const minutes = Math.max(0, Math.floor((Date.parse(resetsAt) - at) / 60_000))
381  if (minutes >= 60) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
382  return `${minutes}m`
383}
384
385function sparkline(history: TokenBarReading[]) {
386  const top = Math.max(...history.map(r => r.tokens), 1)
387  return history.map(r => BARS[Math.floor((r.tokens / top) * (BARS.length - 1))]).join('')
388}
389
390function trend(history: TokenBarReading[]) {
391  const delta = (history.at(-1)?.tokens ?? 0) - (history.at(-2)?.tokens ?? 0)
392  if (delta === 0) return '  steady'
393  return delta > 0 ? `  ▲ +${short(delta)} last turn` : `  ▼ ${short(-delta)} last turn`
394}
395
396function short(n: number) {
397  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
398  if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`
399  return String(n)
400}
401
hooks/draw.ts 73 lines
1// The drawings the surfaces with Svg show, as plain SVG markup.
2// Each takes how much is used (0 to 100) and the level's color.
3
4// A mid-tone that reads on the desktop's light card and on its dark one.
5const TRACK = 'rgba(128,128,128,0.28)'
6const QUIET = 'rgba(128,128,128,0.55)'
7
8// A ring that fills clockwise from twelve as the share is used.
9export function ring(used: number, color: string) {
10  const r = 7
11  const c = 2 * Math.PI * r
12  return svg(
13    18,
14    18,
15    `<circle cx="9" cy="9" r="${r}" fill="none" stroke="${TRACK}" stroke-width="3"/>` +
16      (used > 0
17        ? `<circle cx="9" cy="9" r="${r}" fill="none" stroke="${color}" stroke-width="3" stroke-linecap="round" ` +
18          `stroke-dasharray="${Math.max(share(used) * c, 0.1).toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 9 9)"/>`
19        : ''),
20  )
21}
22
23// The week as a ring of seven arcs, one a day, filled clockwise as the quota is used.
24export function week(used: number, color: string) {
25  const filled = share(used) * 7
26  const span = 360 / 7
27  const gap = 9
28  let body = ''
29  for (let i = 0; i < 7; i++) {
30    const from = i * span + gap / 2
31    const to = (i + 1) * span - gap / 2
32    body += `<path d="${arc(from, to)}" fill="none" stroke="${TRACK}" stroke-width="3"/>`
33    const part = Math.min(Math.max(filled - i, 0), 1)
34    // A started day shows at least a quarter of its arc, so a sliver does not read as a glitch.
35    if (part > 0) body += `<path d="${arc(from, from + (to - from) * Math.max(part, 0.25))}" fill="none" stroke="${color}" stroke-width="3"/>`
36  }
37  return svg(18, 18, body)
38}
39
40// An arc of the ring, clockwise from twelve, between two angles in degrees.
41function arc(from: number, to: number) {
42  const at = (deg: number) => {
43    const rad = ((deg - 90) * Math.PI) / 180
44    return `${(9 + 7 * Math.cos(rad)).toFixed(2)} ${(9 + 7 * Math.sin(rad)).toFixed(2)}`
45  }
46  return `M ${at(from)} A 7 7 0 0 1 ${at(to)}`
47}
48
49// The context after each of the last turns, as bars; the newest in the level's color.
50export function bars(values: number[], color: string) {
51  const top = Math.max(...values, 1)
52  const body = values
53    .map((v, i) => {
54      const h = Math.max((v / top) * 14, 1.5)
55      const fill = i === values.length - 1 ? color : QUIET
56      return `<rect x="${i * 5}" y="${(14 - h).toFixed(1)}" width="3.5" height="${h.toFixed(1)}" rx="1" fill="${fill}"/>`
57    })
58    .join('')
59  return svg(barsWidth(values.length), 14, body)
60}
61
62export function barsWidth(count: number) {
63  return Math.max(count * 5 - 1.5, 1)
64}
65
66function share(percent: number) {
67  return Math.min(Math.max(percent, 0), 100) / 100
68}
69
70function svg(w: number, h: number, body: string) {
71  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">${body}</svg>`
72}
73
hooks/scan.ts 188 lines
1// Today's tokens and their API-price cost, counted from Claude Code's own transcripts, so
2// every session of the day counts, those from before the plugin loaded included.
3// Run by osascript as JavaScript for Automation (macOS); a run reads only what the
4// transcripts gained since the last, from the offsets it keeps in its state directory.
5// Counting rules follow ccusage and OpenUsage (see the script's header).
6export const SCAN_STATE = '~/.token-bar'
7
8// The transcript roots, as ccusage and OpenUsage find them: CLAUDE_CONFIG_DIR's entries
9// (comma-separated, each a config directory or its projects directory), or else both
10// default homes. The script adds the desktop app's Cowork sessions itself.
11export function scanRoots(configDir: string | undefined) {
12  const dirs = configDir?.split(',').map(d => d.trim()).filter(Boolean) ?? []
13  if (dirs.length === 0) return '~/.config/claude/projects,~/.claude/projects'
14  return dirs.map(d => (/\/projects\/?$/.test(d) ? d : `${d.replace(/\/+$/, '')}/projects`)).join(',')
15}
16
17export const SCAN_SCRIPT = String.raw`ObjC.import('Foundation')
18
19// Today's tokens and API-price cost, from Claude Code's transcripts.
20// argv: transcript roots ('projects' directories, comma-separated, '~' allowed),
21//       state directory ('~' allowed), day (YYYY-MM-DD, local).
22// Also reads the Claude desktop app's Cowork sessions. Incremental: the state file keeps
23// each transcript's read offset and what each reply was counted at, so a run reads only
24// what was appended since, and a fuller copy of a reply corrects its count.
25// The rules follow ccusage and OpenUsage: one count per reply (message id + request id),
26// the fullest copy wins, a sidechain replay of a parent reply is the same reply.
27
28// $ per million tokens: input, output, cache read, fast-mode multiplier. Cache writes
29// cost 1.25x input (5 minutes) or 2x (1 hour).
30const PRICES = [
31  ['claude-fable-5-1', 10, 50, 0.25, 2], ['claude-mythos-5-1', 10, 50, 0.25, 2],
32  ['claude-fable-5', 10, 50, 1, 2], ['claude-mythos-5', 10, 50, 1, 2],
33  ['claude-opus-5-5', 4, 20, 0.2, 2], ['claude-opus-5', 5, 25, 0.5, 2],
34  ['claude-opus-4-8', 5, 25, 0.5, 2], ['claude-opus-4-7', 5, 25, 0.5, 6], ['claude-opus-4-6', 5, 25, 0.5, 6],
35  ['claude-sonnet-5-5', 2, 10, 0.2, 2], ['claude-sonnet-5', 2, 10, 0.2, 2], ['claude-sonnet-4-6', 3, 15, 0.3, 2],
36  ['claude-haiku-4-5', 1, 5, 0.1, 2],
37]
38
39function priceOf(model) {
40  // The longest matching id wins ('claude-opus-5-5' before 'claude-opus-5'); a dated or
41  // provider-spelled id ('claude-opus-4-8@20260101', 'anthropic.claude-...') still matches.
42  const id = model.replace(/^(anthropic\.|us\.anthropic\.|eu\.anthropic\.)/, '').replace(/[@.]/g, '-')
43  let best = null
44  for (const p of PRICES) if (id === p[0] || id.indexOf(p[0] + '-') === 0) if (!best || p[0].length > best[0].length) best = p
45  return best
46}
47
48function tokensOf(u) {
49  return (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
50}
51
52function costOf(model, u) {
53  const p = priceOf(model)
54  if (!p) return null
55  const [, input, output, read, fast] = p
56  const cc = u.cache_creation || {}
57  const w1h = cc.ephemeral_1h_input_tokens || 0
58  const w5m = cc.ephemeral_5m_input_tokens !== undefined ? cc.ephemeral_5m_input_tokens : Math.max((u.cache_creation_input_tokens || 0) - w1h, 0)
59  const usd = ((u.input_tokens || 0) * input + (u.output_tokens || 0) * output + (u.cache_read_input_tokens || 0) * read
60    + w5m * input * 1.25 + w1h * input * 2) / 1e6
61  return u.speed === 'fast' ? usd * fast : usd
62}
63
64function dayOf(ms) {
65  const d = new Date(ms)
66  return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
67}
68
69function run(argv) {
70  const home = $.NSHomeDirectory().js
71  const fm = $.NSFileManager.defaultManager
72  const isDir = path => { const flag = Ref(); return fm.fileExistsAtPathIsDirectory(path, flag) && flag[0] }
73  const roots = []
74  for (const r of argv[0].split(',')) {
75    const path = r.trim().replace(/^~/, home).replace(/\/+$/, '')
76    if (path && isDir(path) && roots.indexOf(path) === -1) roots.push(path)
77  }
78  // Cowork sessions keep a Claude Code home of their own, deep under the desktop app's data.
79  const cowork = home + '/Library/Application Support/Claude/local-agent-mode-sessions'
80  if (isDir(cowork)) {
81    const walker = fm.enumeratorAtPath(cowork)
82    let rel
83    while (!(rel = walker.nextObject).isNil()) {
84      if (/(^|\/)\.claude\/projects$/.test(rel.js)) { roots.push(cowork + '/' + rel.js); walker.skipDescendants }
85    }
86  }
87  const stateDir = argv[1].replace(/^~/, home)
88  const day = argv[2]
89  fm.createDirectoryAtPathWithIntermediateDirectoriesAttributesError(stateDir, true, $(), $())
90  const statePath = stateDir + '/scan-' + day + '.json'
91
92  // counted: key -> [tokens, usd, sidechain, message id]; byMessage: message id -> key
93  let state = { version: 2, day: day, files: {}, counted: {}, byMessage: {}, tokens: 0, usd: 0, unpriced: {} }
94  const old = $.NSString.stringWithContentsOfFileEncodingError(statePath, $.NSUTF8StringEncoding, $())
95  if (!old.isNil()) { try { const s = JSON.parse(old.js); if (s.day === day && s.version === 2) state = s } catch (e) {} }
96
97  // A reply's count, replacing what it was counted at before.
98  function count(key, entry) {
99    const was = state.counted[key]
100    if (was) { state.tokens -= was[0]; state.usd -= was[1] }
101    state.counted[key] = entry
102    state.tokens += entry[0]
103    state.usd += entry[1]
104  }
105
106  // Midnight today, local: a transcript last written before then holds nothing of today's.
107  const parts = day.split('-').map(Number)
108  const startOfDay = new Date(parts[0], parts[1] - 1, parts[2]).getTime()
109
110  for (const root of roots) {
111    const walker = fm.enumeratorAtPath(root)
112    let rel
113    while (!(rel = walker.nextObject).isNil()) {
114      if (!rel.js.endsWith('.jsonl')) continue
115      const path = root + '/' + rel.js
116      const attrs = fm.attributesOfItemAtPathError(path, $())
117      if (attrs.isNil()) continue
118      if (attrs.objectForKey($.NSFileModificationDate).timeIntervalSince1970 * 1000 < startOfDay) continue
119      const size = Number(attrs.objectForKey($.NSFileSize).longLongValue)
120      let offset = state.files[path] || 0
121      if (size < offset) offset = 0 // rewritten: read again; replies already counted stay counted once
122      if (size === offset) continue
123
124      const fh = $.NSFileHandle.fileHandleForReadingAtPath(path)
125      if (fh.isNil()) continue
126      fh.seekToFileOffset(offset)
127      const data = fh.readDataToEndOfFile
128      fh.closeFile
129      const text = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding).js || ''
130      // Whole lines only; a line still being written waits for the next run.
131      const end = text.lastIndexOf('\n') + 1
132      const rest = text.slice(end)
133      state.files[path] = offset + Number(data.length) - (rest ? $(rest).lengthOfBytesUsingEncoding($.NSUTF8StringEncoding) : 0)
134
135      for (const line of text.slice(0, end).split('\n')) {
136        if (line.indexOf('"usage"') === -1 || line.indexOf('"assistant"') === -1) continue
137        let d
138        try { d = JSON.parse(line) } catch (e) { continue }
139        const m = d.message
140        if (d.type !== 'assistant' || !m || !m.usage || !d.timestamp) continue
141        if (dayOf(Date.parse(d.timestamp)) !== day) continue
142        const u = m.usage
143        const model = m.model || ''
144        const tokens = tokensOf(u)
145        const usd = model === '<synthetic>' ? 0 : costOf(model, u)
146        if (usd === null) state.unpriced[model] = (state.unpriced[model] || 0) + 1
147        const sidechain = !!d.isSidechain
148        const entry = [tokens, usd || 0, sidechain, m.id || '']
149
150        // A reply is its message id and request id; without a request id, its session and time.
151        const key = !m.id ? 'line:' + (d.uuid || d.sessionId + ':' + d.timestamp)
152          : d.requestId ? m.id + ':' + d.requestId : m.id + ':' + d.sessionId + ':' + d.timestamp
153        // A sidechain replaying a parent reply under a new request id is that reply.
154        let same = state.counted[key] ? key : null
155        if (!same && m.id && state.byMessage[m.id]) {
156          const other = state.counted[state.byMessage[m.id]]
157          if (other && (other[2] || sidechain)) same = state.byMessage[m.id]
158        }
159        if (!same) {
160          count(key, entry)
161          if (m.id && !state.byMessage[m.id]) state.byMessage[m.id] = key
162          continue
163        }
164        // Keep the better copy: the parent over a replay, then the fullest.
165        const was = state.counted[same]
166        const better = was[2] && !sidechain ? true : !was[2] && sidechain ? false : tokens > was[0]
167        if (better) {
168          if (same !== key) { state.tokens -= was[0]; state.usd -= was[1]; delete state.counted[same] }
169          count(key, entry)
170          if (m.id) state.byMessage[m.id] = key
171        }
172      }
173    }
174  }
175
176  const tmp = statePath + '.' + $.NSProcessInfo.processInfo.processIdentifier + '.tmp'
177  $(JSON.stringify(state)).writeToFileAtomicallyEncodingError(tmp, true, $.NSUTF8StringEncoding, $())
178  fm.removeItemAtPathError(statePath, $())
179  fm.moveItemAtPathToPathError(tmp, statePath, $())
180  const names = fm.contentsOfDirectoryAtPathError(stateDir, $())
181  if (!names.isNil()) for (let i = 0; i < names.count; i++) {
182    const n = names.objectAtIndex(i).js
183    if (/^scan-\d{4}-\d{2}-\d{2}\.json$/.test(n) && n !== 'scan-' + day + '.json') fm.removeItemAtPathError(stateDir + '/' + n, $())
184  }
185  return JSON.stringify({ day: day, tokens: state.tokens, usd: Math.round(state.usd * 1e4) / 1e4, unpriced: state.unpriced, roots: roots.length })
186}
187`
188
types/index.d.ts 16 lines
1export type TokenBarReading = { tokens: number; window: number; percent: number }
2export type TokenBarLimit = { kind: string; percentUsed: number; resetsAt?: string }
3// What today has used across every session, as the transcript scan last counted it.
4export type TokenBarToday = { day: string; tokens: number; usd: number }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'token-bar': {
9      readings: TokenBarReading[]
10      limits: TokenBarLimit[]
11      now: number
12      today: TokenBarToday | null
13    }
14  }
15}
16