Per-turn token, cache-hit and cost line under the prompt

Personal Claude Code mods (function-hook plugins).
| Mod | What it does |
|---|---|
context-bar | Draws the context window above the prompt as a stacked bar, one colour per /context category, with a legend and the auto-compact point. Toggle with /context-bar. |
turn-usage | Per-turn token, cache-hit and cost line under the prompt. |
/plugin install context-bar --marketplace Joxtacy/claude-code-mods
/plugin install turn-usage --marketplace Joxtacy/claude-code-mods
Answer y to add the marketplace, then pick a scope (user scope loads it in every session).
claude --plugin-dir plugins/<mod> # run a mod from its folder
claude plugin validate plugins/<mod>
claude plugin test plugins/<mod>hooks/register.ts 85 lines1import type { EngineInterface, Register, TurnUsage } from 'claude-code'
2
3import { formatBreakdown } from './breakdown'
4import { formatTokens } from './format'
5
6export { formatTokens }
7
8const totalTokens = (u: TurnUsage): number =>
9 u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
10
11export const formatLine = (
12 usage: TurnUsage,
13 costUsd: number | undefined,
14 durationMs: number,
15 agentTokens: number,
16): string => {
17 const input = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
18 const cached = input > 0 ? Math.round((usage.cache_read_input_tokens / input) * 100) : 0
19 const parts = [
20 `last turn ${formatTokens(input)} in`,
21 `${cached}% cached`,
22 `${formatTokens(usage.output_tokens)} out`,
23 ]
24 if (costUsd !== undefined) parts.push(`$${costUsd.toFixed(2)}`)
25 parts.push(`${Math.round(durationMs / 1000)}s`)
26 if (agentTokens > 0) parts.push(`agents ${formatTokens(agentTokens)}`)
27 return parts.join(' · ')
28}
29
30// Cost is a nicety: a failed lookup leaves it off the line rather than dropping the line.
31const readCost = async ($: EngineInterface): Promise<number | undefined> => {
32 try {
33 return (await $.session.usage()).cost?.usd
34 } catch {
35 return undefined
36 }
37}
38
39export const register: Register = on => {
40 // Module variables reset on reload; that only loses the turn in flight.
41 let costAtStart: number | undefined
42 let agentTokens = 0
43
44 on('session.start', async ($, e, next) => {
45 await $.command.register({
46 name: 'usage-breakdown',
47 description: 'Show what fills the context window, biggest items first',
48 argumentHint: '[full]',
49 immediate: true,
50 })
51 return next(e)
52 })
53
54 on('command.run', { command: 'usage-breakdown' }, async ($, e) => {
55 const isFull = e.args.trim() === 'full'
56 const usage = await $.session.usage({ breakdown: isFull ? 'full' : 'summary' })
57 const breakdown = usage.context.breakdown
58 if (!breakdown) return { text: 'No context breakdown is available in this session yet.' }
59 return { text: formatBreakdown(breakdown, isFull) }
60 })
61
62 on('turn.start', async ($, e, next) => {
63 costAtStart = await readCost($)
64 agentTokens = 0
65 return next(e)
66 })
67
68 on('turn.complete', async ($, e, next) => {
69 const done = await next(e)
70 const usage = done.usage ?? e.usage
71
72 if (e.agentId !== undefined) {
73 if (usage) agentTokens += totalTokens(usage)
74 return done
75 }
76 if (!usage) return done
77
78 const costNow = await readCost($)
79 const costDelta =
80 costNow !== undefined && costAtStart !== undefined ? costNow - costAtStart : undefined
81 $.ui.status(formatLine(usage, costDelta, e.durationMs, agentTokens))
82 return done
83 })
84}
85hooks/breakdown.ts 65 lines1import type { SessionContextBreakdown } from 'claude-code'
2
3import { formatTokens } from './format'
4
5const TOP = 5
6
7const pad = (label: string, tokens: number, width = 26): string =>
8 ` ${label.length > width - 1 ? `${label.slice(0, width - 2)}…` : label.padEnd(width)}${formatTokens(tokens).padStart(7)}`
9
10const top = <T>(items: readonly T[], tokens: (item: T) => number): T[] =>
11 [...items].sort((a, b) => tokens(b) - tokens(a)).slice(0, TOP)
12
13const shortPath = (path: string): string => path.replace(/^\/Users\/[^/]+/, '~')
14
15// Plain text for the transcript: what fills the window, then the biggest items
16// inside the categories a person can trim (memory files, MCP servers, skills, agents).
17export const formatBreakdown = (b: SessionContextBreakdown, isFull: boolean): string => {
18 const lines: string[] = []
19 const auto = b.isAutoCompactEnabled && b.autoCompactThreshold !== undefined
20 ? ` · autocompact at ${formatTokens(b.autoCompactThreshold)}`
21 : ''
22 lines.push(`Context ${formatTokens(b.totalTokens)} / ${formatTokens(b.rawMaxTokens)} (${b.percentage}%) · ${b.model}${auto}`)
23 lines.push(isFull ? '(exact counts)' : '(estimates; /usage-breakdown full for exact counts)')
24
25 lines.push('', 'By category')
26 for (const c of b.categories) {
27 if (c.kind === 'used' && c.tokens > 0) lines.push(pad(c.name, c.tokens))
28 }
29 const deferred = b.categories.filter(c => c.kind === 'deferred').reduce((sum, c) => sum + c.tokens, 0)
30 if (deferred > 0) lines.push(pad('Deferred (not loaded)', deferred))
31
32 if (b.memoryFiles.length > 0) {
33 lines.push('', 'Memory files')
34 for (const f of top(b.memoryFiles, f => f.tokens)) lines.push(pad(shortPath(f.path), f.tokens, 48))
35 }
36
37 const servers = new Map<string, number>()
38 for (const t of b.mcpTools) {
39 if (t.isLoaded) servers.set(t.serverName, (servers.get(t.serverName) ?? 0) + t.tokens)
40 }
41 if (servers.size > 0) {
42 lines.push('', 'MCP servers (loaded tools)')
43 for (const [name, tokens] of top([...servers], s => s[1])) lines.push(pad(name, tokens, 48))
44 }
45
46 if (b.skills && b.skills.tokens > 0) {
47 lines.push('', `Skills listing ${formatTokens(b.skills.tokens)} (${b.skills.includedSkills} of ${b.skills.totalSkills} listed)`)
48 for (const s of top(b.skills.skillFrontmatter, s => s.tokens)) lines.push(pad(s.name, s.tokens, 48))
49 }
50
51 if (b.agents.length > 0) {
52 lines.push('', 'Agent descriptions')
53 for (const a of top(b.agents, a => a.tokens)) lines.push(pad(a.agentType, a.tokens, 48))
54 }
55
56 const u = b.apiUsage
57 if (u) {
58 const input = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
59 if (input > 0) {
60 lines.push('', `Last request: ${formatTokens(input)} in, ${Math.round((u.cache_read_input_tokens / input) * 100)}% from cache`)
61 }
62 }
63 return lines.join('\n')
64}
65hooks/format.ts 6 lines1// 42300 -> "42.3k", matching ~/.claude/statusline.sh.
2export const formatTokens = (n: number): string =>
3 n >= 1_000_000 ? `${(Math.floor(n / 100_000) / 10).toFixed(1)}M`
4 : n >= 1_000 ? `${(Math.floor(n / 100) / 10).toFixed(1)}k`
5 : String(n)
6