Directory, branch, worktree, model and effort, with 5h and weekly limits (subscription) or cost (pay-per-token), above the prompt; context below it

A Claude Code mod that shows, around the prompt:
● when dirty) on the left, cut short with … rather than wrapping. In a worktree, a ⎇ button stands in for its name and opens the worktree, directory and branch in full; the directory is named only when you are in a subfolder of it. A directory the branch already ends with (issue-538 on feat/issue-538) is left to the branch. Model and effort are on the right, followed by the usage meter:Cost control. Press the period button to switch between Session, Today, 7 days and 30 days (the list opens on the right, below the meter); ⓘ explains how the estimate works.The mod infers billing from what Claude Code reports: rate-limit windows (five_hour, seven_day) exist only on a subscription. With none, and a priced response already in, billing is treated as metered. Before the first response it can't tell, so it uses the mode it saw last (remembered across sessions); on first use it shows neither until it knows.
~ and est.). hooks/scan.mjs scans local logs in ~/.claude/projects (or $CLAUDE_CONFIG_DIR/projects) and the Cowork sessions folder (macOS and Windows), counts each message once and prices it at public API list prices per model (including fast mode, US-only inference, server-side compaction, Haiku 5.5's long-prompt rates and web searches). Discounts, other machines and logs older than 30 days are not reflected; unrecognised models are priced as Sonnet. Refreshed at most once a minute, and only for metered users.node on the PATH. Per-model prices are hardcoded in hooks/scan.mjs and need updating when pricing changes./plugin marketplace add akerskuuug/claude-mods
/plugin install usage-meter@claude-mods
Pick the user scope when prompted.
The mod runs as function hooks, which Claude Code only loads when they are switched on. Add this to ~/.claude/settings.json (if the file already exists, merge env into it) and restart Claude Code:
{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
}
Without it the mod installs but shows nothing.
hooks/register.tsx 466 lines1import { atom, read, update } from 'claude-code'
2import type { Color, EngineInterface, Register } from 'claude-code'
3
4import type { CostView, GitState, KnownBilling, Usage } from '../types'
5
6const usage = atom({ plugin: 'usage-meter', key: 'usage' } as const, null)
7const now = atom({ plugin: 'usage-meter', key: 'now' } as const, null)
8const git = atom({ plugin: 'usage-meter', key: 'git' } as const, null)
9const model = atom({ plugin: 'usage-meter', key: 'model' } as const, null)
10const effort = atom({ plugin: 'usage-meter', key: 'effort' } as const, null)
11
12const costView = atom({ plugin: 'usage-meter', key: 'costView' } as const, 'session' as CostView)
13const costOpen = atom({ plugin: 'usage-meter', key: 'costOpen' } as const, false)
14const costInfo = atom({ plugin: 'usage-meter', key: 'costInfo' } as const, false)
15const ledger = atom({ plugin: 'usage-meter', key: 'ledger' } as const, {} as Record<string, number>)
16const sessionCost = atom({ plugin: 'usage-meter', key: 'sessionCost' } as const, 0)
17const knownBilling = atom({ plugin: 'usage-meter', key: 'knownBilling' } as const, null as KnownBilling | null)
18const dir = atom({ plugin: 'usage-meter', key: 'dir' } as const, null)
19const worktreeOpen = atom({ plugin: 'usage-meter', key: 'worktreeOpen' } as const, false)
20
21const BAR_CELLS = 10
22const CONTEXT_CELLS = BAR_CELLS * 2
23// Dark-terminal track colour; ThemeKey has no neutral background.
24const TRACK_COLOR = '#3a3a3a'
25const MINUTE = 60_000
26
27export type BillingMode = KnownBilling | 'unknown'
28
29// Rate-limit windows only exist on a subscription (Pro/Max/Team/Enterprise
30// seats). With none and a priced response already in, billing is per token.
31// Before the first response there is no reading, so it is unknown.
32export function billingMode(u: Usage | null, cost: number): BillingMode {
33 if (u?.rateLimits.some(r => r.kind === 'five_hour' || r.kind === 'seven_day')) return 'subscription'
34 return u && cost > 0 ? 'metered' : 'unknown'
35}
36
37// The live reading wins; until there is one, the mode last seen (in any
38// session) stands in, so a metered seat does not flash the limit bars.
39export function resolveBilling(live: BillingMode, cached: KnownBilling | null): BillingMode {
40 return live === 'unknown' ? (cached ?? 'unknown') : live
41}
42
43// Last path segment, for either separator; the root stays itself.
44export function baseName(path: string): string {
45 return path.split(/[\\/]/).filter(Boolean).pop() ?? path
46}
47
48export const COST_VIEWS: { id: CostView; label: string; days: number }[] = [
49 { id: 'session', label: 'Session', days: 0 },
50 { id: 'today', label: 'Today', days: 1 },
51 { id: '7d', label: '7 days', days: 7 },
52 { id: '30d', label: '30 days', days: 30 },
53]
54
55// Local-date key, so "today" rolls over at local midnight.
56export function dayKey(ms: number): string {
57 const d = new Date(ms)
58 const p = (n: number) => String(n).padStart(2, '0')
59 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
60}
61
62// Sum of the last `days` local days including today.
63export function sumDays(map: Record<string, number>, nowMs: number, days: number): number {
64 let total = 0
65 const d = new Date(nowMs)
66 // Step by local calendar date: a day is not always 24 h across DST changes.
67 for (let i = 0; i < days; i++, d.setDate(d.getDate() - 1)) total += map[dayKey(d.getTime())] ?? 0
68 return total
69}
70
71// Session is exact (reported by Claude Code); the rest are marked as estimates.
72export function formatCost(id: CostView, usd: number): string {
73 return `${id === 'session' ? '' : '~'}$${usd.toFixed(2)}`
74}
75
76const COST_EXPLANATION = [
77 'You appear to be billed per token, so cost is shown instead of 5-hour and weekly limits.',
78 'Session is the exact cost Claude Code reports for this session.',
79 "Today, 7 days and 30 days (~) are estimates: this mod scans the local logs in ~/.claude/projects and Cowork sessions, counts each message's token usage once, and prices it at public API list prices per model.",
80 'Discounts, other machines and logs older than 30 days are not reflected, usage billed to a subscription is priced the same way, and unrecognised models are priced as Sonnet. Refreshed at most once a minute.',
81]
82
83export function formatTokens(n: number): string {
84 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
85 if (n >= 1_000) return `${Math.round(n / 1_000)}k`
86 return `${n}`
87}
88
89export function percentLeft(kind: string, u: Usage | null): number | null {
90 const window = u?.rateLimits.find(r => r.kind === kind)
91 if (!window) return null
92 return Math.max(0, Math.min(100, Math.round(100 - window.percentUsed)))
93}
94
95export function formatResetIn(resetsAt: string | undefined, nowMs: number | null): string | null {
96 if (!resetsAt || nowMs === null) return null
97 const minutes = Math.max(0, Math.ceil((Date.parse(resetsAt) - nowMs) / MINUTE))
98 if (Number.isNaN(minutes)) return null
99 const days = Math.floor(minutes / 1440)
100 const hours = Math.floor((minutes % 1440) / 60)
101 if (days > 0) return `${days}d${hours}h`
102 if (hours > 0) return `${hours}h${minutes % 60}m`
103 return `${minutes}m`
104}
105
106export function parseGitStatus(porcelainV2: string): Omit<GitState, 'worktree' | 'root'> | null {
107 const lines = porcelainV2.split('\n').filter(line => line.length > 0)
108 const head = lines.find(line => line.startsWith('# branch.head '))
109 if (!head) return null
110 const branch = head.slice('# branch.head '.length)
111 return {
112 branch: branch === '(detached)' ? 'detached' : branch,
113 isDirty: lines.some(line => !line.startsWith('#')),
114 }
115}
116
117export function barCells(left: number, cells: number): { filled: string; empty: string } {
118 const label = `${left}%`
119 const start = Math.max(0, Math.floor((cells - label.length) / 2))
120 const text = (' '.repeat(start) + label).padEnd(cells).slice(0, cells)
121 const filledCount = Math.round((left / 100) * cells)
122 return { filled: text.slice(0, filledCount), empty: text.slice(filledCount) }
123}
124
125// Percent of the context window still free, from the tokens in use.
126export function contextLeft(u: Usage): number {
127 const used = u.context.percent ?? (u.context.window > 0 ? ((u.context.tokens ?? 0) / u.context.window) * 100 : 0)
128 return Math.max(0, Math.min(100, Math.round(100 - used)))
129}
130
131// A bar with no text inside: just the filled and empty runs.
132export function barFill(left: number, cells: number): { filled: string; empty: string } {
133 const filledCount = Math.round((left / 100) * cells)
134 return { filled: ' '.repeat(filledCount), empty: ' '.repeat(cells - filledCount) }
135}
136
137export function formatModel(id: string): string {
138 const bare = id.replace(/\[.*\]$/, '')
139 const match = /^claude-([a-z]+)-(\d+)(?:-(\d+))?/.exec(bare)
140 if (!match) return bare
141 const [, family = '', major, minor] = match
142 const name = family.charAt(0).toUpperCase() + family.slice(1)
143 return minor ? `${name} ${major}.${minor}` : `${name} ${major}`
144}
145
146async function refreshModel($: EngineInterface) {
147 const name = await $.session.model()
148 await update($, model, () => name)
149}
150
151function barColor(left: number): Color {
152 if (left > 50) return 'success'
153 if (left > 20) return 'warning'
154 return 'error'
155}
156
157export function parseWorktree(revParse: string): { worktree: string | null; root: string | null } {
158 const [gitDir, commonDir, topLevel] = revParse.trim().split('\n')
159 if (!topLevel) return { worktree: null, root: null }
160 const linked = gitDir && commonDir && gitDir !== commonDir
161 return { worktree: linked ? baseName(topLevel) : null, root: topLevel }
162}
163
164// What the git row names, without saying the same thing twice. In a worktree
165// the directory is its root, so only a subfolder (relative) is worth naming;
166// the worktree itself is behind the ⎇ toggle. A directory the branch already
167// ends with (feat/x in x) is left to the branch.
168export function locationLabel(cwd: string | null, repo: GitState | null): string | null {
169 if (!cwd) return null
170 let label: string | null = baseName(cwd)
171 if (repo?.worktree) {
172 const root = repo.root?.replace(/\/+$/, '')
173 if (root && cwd.startsWith(`${root}/`)) label = cwd.slice(root.length + 1).replace(/\/+$/, '') || null
174 else if (root === cwd.replace(/\/+$/, '') || label === repo.worktree) label = null
175 }
176 if (label && repo && (repo.branch === label || repo.branch.endsWith(`/${label}`))) return null
177 return label
178}
179
180async function refreshGit($: EngineInterface) {
181 try {
182 const [status, paths] = await Promise.all([
183 $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { timeoutMs: 5_000 }),
184 $.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir', '--show-toplevel'], {
185 timeoutMs: 5_000,
186 }),
187 ])
188 const parsed = status.exitCode === 0 ? parseGitStatus(status.stdout) : null
189 const where = paths.exitCode === 0 ? parseWorktree(paths.stdout) : { worktree: null, root: null }
190 const state = parsed && { ...parsed, ...where }
191 await update($, git, () => state)
192 } catch {
193 await update($, git, () => null)
194 }
195}
196
197async function refreshDir($: EngineInterface) {
198 const name = await $.session.cwd()
199 await update($, dir, () => name)
200}
201
202let lastScan = 0
203let lastDays: Record<string, number> | null = null
204
205// Re-estimates the per-day cost from local logs; throttled to 60 s and only
206// run for metered users. Where a host refuses $.process, the last value stays.
207async function refreshLedger($: EngineInterface) {
208 const time = await $.clock.now()
209 if (time - lastScan < MINUTE) {
210 // /clear, /resume and /branch reset the ledger atom; put the last scan back.
211 if (lastDays && Object.keys(await read($, ledger)).length === 0) await update($, ledger, () => lastDays!)
212 return
213 }
214 lastScan = time
215 try {
216 const run = await $.process.run(['node', `${$.plugin.root}/hooks/scan.mjs`, '30'], { timeoutMs: 60_000 })
217 if (run.exitCode !== 0) return
218 const { days } = JSON.parse(run.stdout) as { days: Record<string, number> }
219 lastDays = days
220 await update($, ledger, () => days)
221 } catch {}
222}
223
224async function noteCost($: EngineInterface, cost: { usd: number } | undefined, u: Usage) {
225 // The store is the source of truth: /clear and /resume reset state without a session.start.
226 const picked = await $.store.get('costView')
227 if (picked && picked !== (await read($, costView))) await update($, costView, () => picked as CostView)
228 if (cost) await update($, sessionCost, () => cost.usd)
229 const live = billingMode(u, await read($, sessionCost))
230 const stored = await $.store.get('billing')
231 const cached = live !== 'unknown' ? live : stored === 'subscription' || stored === 'metered' ? stored : null
232 if (cached !== (await read($, knownBilling))) await update($, knownBilling, () => cached)
233 if (live !== 'unknown' && live !== stored) await $.store.set('billing', live)
234 if (cached === 'metered') refreshLedger($).catch(() => {})
235}
236
237async function currentBilling($: EngineInterface): Promise<BillingMode> {
238 return resolveBilling(billingMode(await read($, usage), await read($, sessionCost)), await read($, knownBilling))
239}
240
241async function tick($: EngineInterface) {
242 const time = await $.clock.now()
243 await update($, now, () => time)
244 await Promise.all([refreshGit($), refreshDir($).catch(() => {})])
245 // Other sessions keep writing logs while this one is idle; pick their usage up too.
246 if ((await currentBilling($)) === 'metered') refreshLedger($).catch(() => {})
247}
248
249export const register: Register = on => {
250 on('session.start', async ($, e, next) => {
251 const result = await next(e)
252 const { context, rateLimits, cost } = await $.session.usage()
253 await update($, usage, () => ({ context, rateLimits }))
254 await noteCost($, cost, { context, rateLimits })
255 await tick($)
256 await refreshModel($)
257 $.clock.every(MINUTE, () => tick($))
258 return result
259 })
260
261 on('session.measure', async ($, e, next) => {
262 const u = { context: e.context, rateLimits: e.rateLimits }
263 await update($, usage, () => u)
264 await noteCost($, e.cost, u)
265 return next(e)
266 })
267
268 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
269 const result = await next(e)
270 refreshGit($).catch(() => {})
271 refreshDir($).catch(() => {})
272 return result
273 })
274
275 on('turn.step', async function* ($, e, next) {
276 if (e.agentId === undefined) {
277 const level = e.effort === undefined ? null : `${e.effort}`
278 update($, effort, () => level).catch(() => {})
279 update($, model, () => e.model).catch(() => {})
280 }
281 return yield* next(e)
282 })
283
284 on('turn.complete', async ($, e, next) => {
285 tick($).catch(() => {})
286 return next(e)
287 })
288
289 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
290 const u = await read($, usage)
291 if (e.props.hasSurvey || !u) return next(e)
292 const repo = await read($, git)
293 const cwd = await read($, dir)
294 const dirName = locationLabel(cwd, repo)
295 const isWorktreeOpen = !!repo?.worktree && (await read($, worktreeOpen))
296 const modelId = await read($, model)
297 const effortLevel = await read($, effort)
298 const spentSession = await read($, sessionCost)
299 const mode = await currentBilling($)
300 const isMetered = mode === 'metered'
301
302 const { Box, Button, Text } = $.ui.resolve(e)
303
304 const meter = (label: string, kind: string, nowMs: number | null) => {
305 const left = percentLeft(kind, u)
306 if (left === null) {
307 return (
308 <Box key={kind}>
309 <Text dimColor>{label} –</Text>
310 </Box>
311 )
312 }
313 const { filled, empty } = barCells(left, BAR_CELLS)
314 const resetIn = formatResetIn(u.rateLimits.find(r => r.kind === kind)?.resetsAt, nowMs)
315 return (
316 <Box key={kind}>
317 <Text dimColor>{label} </Text>
318 <Text color="inverseText" backgroundColor={barColor(left)} bold>
319 {filled}
320 </Text>
321 <Text backgroundColor={TRACK_COLOR}>{empty}</Text>
322 {resetIn && <Text dimColor> · {resetIn}</Text>}
323 </Box>
324 )
325 }
326
327 const current = await read($, costView)
328 const isOpen = await read($, costOpen)
329 const isInfo = await read($, costInfo)
330 const ledgerNow = await read($, ledger)
331 const nowMs = await $.clock.now()
332 const costs = COST_VIEWS.map(v => formatCost(v.id, v.id === 'session' ? spentSession : sumDays(ledgerNow, nowMs, v.days)))
333 const index = COST_VIEWS.findIndex(v => v.id === current)
334 const estimated = current !== 'session'
335
336 // Subscription: the two limit bars. Metered: the cost control. Unknown: nothing yet.
337 const limits = mode === 'unknown' ? null : isMetered ? (
338 <Box key="cost" columnGap={1}>
339 <Text dimColor>Cost</Text>
340 <Button
341 key="cost-toggle"
342 label={`${COST_VIEWS[index]!.label} ${isOpen ? '▴' : '▾'}`}
343 onPress={() => update($, costOpen, v => !v)}
344 />
345 <Text>
346 {costs[index]}
347 {estimated ? ' est.' : ''}
348 </Text>
349 <Button key="cost-info" label={isInfo ? 'ⓘ Hide' : 'ⓘ'} onPress={() => update($, costInfo, v => !v)} />
350 </Box>
351 ) : (
352 <Box key="limits" columnGap={3}>
353 {meter('5h', 'five_hour', await read($, now))}
354 {meter('Weekly', 'seven_day', await read($, now))}
355 </Box>
356 )
357
358 const costWidth = Math.max(...costs.map(c => c.length))
359 const labelWidth = Math.max(...COST_VIEWS.map(v => v.label.length))
360
361 return (
362 <Box flexDirection="column">
363 <Box flexDirection="row" width={e.props.bodyColumns} justifyContent="space-between" columnGap={3}>
364 {repo || dirName ? (
365 <Box key="git" flexShrink={1} minWidth={0} columnGap={1}>
366 {repo?.worktree && (
367 <Button
368 key="worktree-toggle"
369 label={isWorktreeOpen ? '⎇ Hide' : '⎇'}
370 onPress={() => update($, worktreeOpen, v => !v)}
371 />
372 )}
373 <Text wrap="truncate-end">
374 {dirName && <Text>{dirName}</Text>}
375 {dirName && repo && <Text dimColor> on </Text>}
376 {repo && <Text color="suggestion">{repo.branch}</Text>}
377 {repo?.isDirty && <Text color="warning"> ●</Text>}
378 </Text>
379 </Box>
380 ) : (
381 <Text key="git"> </Text>
382 )}
383 <Box key="right" columnGap={3} flexShrink={0}>
384 {modelId && (
385 <Box key="model">
386 <Text color="claude">{formatModel(modelId)}</Text>
387 {effortLevel && <Text dimColor> · {effortLevel}</Text>}
388 </Box>
389 )}
390 {limits}
391 </Box>
392 </Box>
393 {isWorktreeOpen && repo?.worktree && (
394 <Box flexDirection="column" borderStyle="round" paddingX={1}>
395 {[
396 ['Worktree', repo.worktree],
397 ['Directory', cwd ?? ''],
398 ['Branch', repo.branch],
399 ].map(([label, value]) => (
400 <Box key={label}>
401 <Box width={11} flexShrink={0}>
402 <Text dimColor>{label}</Text>
403 </Box>
404 <Text>{value}</Text>
405 </Box>
406 ))}
407 </Box>
408 )}
409 {isMetered && isInfo && (
410 <Box flexDirection="column" borderStyle="round" paddingX={1}>
411 {COST_EXPLANATION.map((paragraph, i) => (
412 <Box key={i} marginTop={i === 0 ? 0 : 1}>
413 <Text dimColor>{paragraph}</Text>
414 </Box>
415 ))}
416 </Box>
417 )}
418 {isMetered && isOpen && (
419 <Box flexDirection="column" alignSelf="flex-end">
420 {COST_VIEWS.map((v, i) => (
421 <Box key={v.id}>
422 <Box width={labelWidth + 8}>
423 <Button
424 key={v.id}
425 label={`${v.id === current ? '●' : '○'} ${v.label}`}
426 onPress={async () => {
427 await update($, costView, () => v.id)
428 await update($, costOpen, () => false)
429 await $.store.set('costView', v.id)
430 }}
431 />
432 </Box>
433 <Box width={costWidth + 1} justifyContent="flex-end">
434 <Text>{costs[i]}</Text>
435 </Box>
436 </Box>
437 ))}
438 </Box>
439 )}
440 </Box>
441 )
442 })
443
444 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
445 const u = await read($, usage)
446 if (!u) return next(e)
447 const { Box, Text } = $.ui.resolve(e)
448
449 const left = contextLeft(u)
450 // Fills as the window is used; the colour still turns red as little is left.
451 const { filled, empty } = barFill(100 - left, CONTEXT_CELLS)
452
453 return (
454 <Box flexDirection="row" flexGrow={1} flexShrink={1} justifyContent="flex-end">
455 <Text dimColor>Context </Text>
456 <Text backgroundColor={barColor(left)}>{filled}</Text>
457 <Text backgroundColor={TRACK_COLOR}>{empty}</Text>
458 <Text dimColor>
459 {' '}
460 {formatTokens(u.context.tokens ?? 0)}/{formatTokens(u.context.window)}
461 </Text>
462 </Box>
463 )
464 })
465}
466types/index.d.ts 29 lines1export type RateWindow = { kind: string; percentUsed: number; resetsAt?: string }
2export type Usage = {
3 context: { tokens?: number; window: number; percent?: number }
4 rateLimits: RateWindow[]
5}
6export type CostView = 'session' | 'today' | '7d' | '30d'
7export type KnownBilling = 'subscription' | 'metered'
8export type GitState = { branch: string; isDirty: boolean; worktree: string | null; root: string | null }
9
10declare module 'claude-code' {
11 interface PluginState {
12 'usage-meter': {
13 usage: Usage | null
14 now: number | null
15 git: GitState | null
16 model: string | null
17 effort: string | null
18 costView: CostView
19 costOpen: boolean
20 costInfo: boolean
21 ledger: Record<string, number>
22 sessionCost: number
23 knownBilling: KnownBilling | null
24 dir: string | null
25 worktreeOpen: boolean
26 }
27 }
28}
29