Live cost, context, and plan-quota meters above the Claude Code prompt (Nerd Font required for icons). Type /spend for the detail pane. Optional cost guards…

Plugin package for claude-mod-cost-visibility.
Requires a Nerd Font as your terminal font for icons (meters work without it).
# from repo root
./scripts/check-nerd-font.sh
./install.sh
claude plugin validate .
See the root README for full install and /spend.
hooks/register.js 705 lines1// Cost / context visibility mod for Claude Code (UI band + optional pane).
2// Requires Claude Code >= 2.1.287.
3// Note: "/cost" is reserved (alias of built-in /usage) — use /spend for the pane.
4
5const PANE_ID = 'cost-visibility'
6const CAPS_CONFIG_KEY = 'cost-visibility.costGuards'
7const CAPS_STORE_KEY = 'costGuards'
8
9const LIMIT_LABEL = {
10 five_hour: '5h',
11 seven_day: 'week',
12 spend_limit: 'spend',
13}
14
15const LIMIT_LABEL_LONG = {
16 five_hour: '5-hour plan window',
17 seven_day: '7-day week window',
18 spend_limit: 'spend cap',
19}
20
21// Nerd Fonts (Font Awesome). Needs a Nerd Font in the terminal.
22// Avoid fa-memory (\uf538) — missing in some NF builds (renders as tofu).
23const I = {
24 clock: '\uf017', // fa-clock-o
25 calendar: '\uf073', // fa-calendar
26 usd: '\uf155', // fa-usd
27 users: '\uf0c0', // fa-users
28 ctx: '\uf1c0', // fa-database (context window)
29 wrench: '\uf0ad', // fa-wrench
30 bolt: '\uf0e7', // fa-bolt
31 warn: '\uf071', // fa-warning
32 circle: '\uf111', // fa-circle
33}
34
35function fmtUsd(n) {
36 if (n == null || Number.isNaN(n)) return '—'
37 if (n === 0) return '$0.00'
38 if (n < 0.01) return '<$0.01'
39 if (n < 10) return '$' + n.toFixed(2)
40 return '$' + n.toFixed(1)
41}
42
43function fmtTok(n) {
44 if (n == null) return '—'
45 if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
46 if (n >= 1e3) return (n / 1e3).toFixed(0) + 'k'
47 return String(n)
48}
49
50function costUsd(usage) {
51 const c = usage?.cost
52 if (typeof c === 'number') return c
53 if (c && typeof c.usd === 'number') return c.usd
54 if (c && typeof c.total === 'number') return c.total
55 return null
56}
57
58function limitLabel(kind) {
59 return LIMIT_LABEL[kind] || kind || 'limit'
60}
61
62function limitLabelLong(kind) {
63 return LIMIT_LABEL_LONG[kind] || kind || 'limit'
64}
65
66function limitIcon(kind) {
67 if (kind === 'seven_day') return I.calendar
68 if (kind === 'spend_limit') return I.usd
69 return I.clock
70}
71
72function barParts(pct, width) {
73 const w = width || 8
74 const raw = Number(pct) || 0
75 const capped = Math.max(0, Math.min(100, raw))
76 const filled = Math.round((capped / 100) * w)
77 return {
78 filled: '█'.repeat(filled),
79 empty: '░'.repeat(w - filled),
80 over: raw > 100,
81 }
82}
83
84function bar(pct, width) {
85 const { filled, empty } = barParts(pct, width)
86 return filled + empty
87}
88
89function untilReset(iso) {
90 if (!iso) return null
91 const ms = new Date(iso).getTime() - Date.now()
92 if (Number.isNaN(ms)) return null
93 if (ms <= 0) return 'now'
94 const m = Math.floor(ms / 60000)
95 const h = Math.floor(m / 60)
96 const d = Math.floor(h / 24)
97 if (d >= 1) return d + 'd' + (h % 24 ? h % 24 + 'h' : '')
98 if (h >= 1) return h + 'h' + (m % 60 ? m % 60 + 'm' : '')
99 return m + 'm'
100}
101
102/** Soft label for the pane only — never shouted in the band. */
103function statusWord(pct) {
104 if (pct == null) return 'unknown'
105 if (pct >= 100) return 'full'
106 if (pct >= 85) return 'tight'
107 if (pct >= 70) return 'warm'
108 return 'ok'
109}
110
111function pctColor(pct) {
112 if (pct == null) return 'subtle'
113 if (pct >= 100) return 'error'
114 if (pct >= 85) return 'error'
115 if (pct >= 70) return 'warning'
116 return 'success'
117}
118
119function formatLimitShort(lim) {
120 const p = Math.round(lim.percentUsed ?? 0)
121 const reset = untilReset(lim.resetsAt)
122 const { filled, empty } = barParts(p, 6)
123 return (
124 limitIcon(lim.kind) +
125 ' ' +
126 limitLabel(lim.kind) +
127 ' ' +
128 filled +
129 empty +
130 ' ' +
131 p +
132 '%' +
133 (reset ? ' ' + reset : '')
134 )
135}
136
137/** Colored meter chip: icon + label + bar + % + countdown */
138function meterChip(Text, Box, lim, barWidth) {
139 const p = Math.round(lim.percentUsed ?? 0)
140 const color = pctColor(p)
141 const { filled, empty, over } = barParts(p, barWidth || 8)
142 const reset = untilReset(lim.resetsAt)
143 const kids = [
144 el(Text, { color }, (over ? I.bolt + ' ' : '') + limitIcon(lim.kind)),
145 el(Text, { color }, ' ' + limitLabel(lim.kind) + ' '),
146 el(Text, { color }, filled),
147 el(Text, { color: 'inactive' }, empty),
148 el(Text, { color, bold: p >= 85 }, ' ' + p + '%'),
149 ]
150 if (reset) kids.push(el(Text, { color: 'subtle' }, ' · ' + reset))
151 return el(Box, null, ...kids)
152}
153
154function sep(Text) {
155 return el(Text, { color: 'inactive' }, ' │ ')
156}
157
158function burnChip(Text, Box, icon, color, label) {
159 return el(
160 Box,
161 null,
162 el(Text, { color: color || 'subtle' }, icon + ' '),
163 el(Text, { color: color || 'text' }, label),
164 )
165}
166
167async function readUsage($) {
168 try {
169 return await $.session.usage()
170 } catch {
171 return null
172 }
173}
174
175function el(factory, props, ...children) {
176 const p = props ? { ...props } : {}
177 if (children.length === 1) p.children = children[0]
178 else if (children.length > 1) p.children = children
179 return factory(p)
180}
181
182async function openPane($) {
183 const result = await $.ui.open({
184 id: PANE_ID,
185 title: 'Cost & usage',
186 placement: 'dock',
187 rows: 24,
188 })
189 $.ui.invalidate('ui.render')
190 return result
191}
192
193/** Persist costGuards via userConfig; store is a same-session fallback. */
194async function writeCapsConfig($, enabled) {
195 try {
196 $.store.set(CAPS_STORE_KEY, enabled === true)
197 } catch {
198 /* optional */
199 }
200 try {
201 const result = await $.config.set({
202 key: CAPS_CONFIG_KEY,
203 value: enabled === true,
204 })
205 if (result?.deny) {
206 return { ok: false, reason: String(result.deny), persisted: 'denied' }
207 }
208 return { ok: true, persisted: 'config' }
209 } catch (err) {
210 return {
211 ok: true,
212 persisted: 'session-store',
213 reason: err?.message || 'config.set unavailable; kept for this session via store',
214 }
215 }
216}
217
218export function register(on, raw = {}) {
219 let lastUsage = null
220 let toolCalls = 0
221 let agentSpawns = 0
222 let lastCostSeen = 0
223 let mainCostUsd = 0
224 let agentCostUsd = 0
225 /** Live flag — default off (HUD only). Synced from userConfig / /spend caps / /config. */
226 let capsOn = raw?.costGuards === true
227 /** @type {Map<string, { type: string, description: string, usd: number, turns: number }>} */
228 const agents = new Map()
229
230 function noteCostDelta(usage, agentId) {
231 const total = costUsd(usage)
232 if (total == null) return
233 const delta = Math.max(0, total - lastCostSeen)
234 lastCostSeen = total
235 if (delta === 0) return
236 if (agentId) {
237 agentCostUsd += delta
238 const row = agents.get(agentId)
239 if (row) {
240 row.usd += delta
241 row.turns += 1
242 }
243 } else {
244 mainCostUsd += delta
245 }
246 }
247
248 on('session.start', async ($, e, next) => {
249 try {
250 await $.command.register({
251 name: 'spend',
252 description:
253 'Cost HUD pane. /spend · /spend close · /spend caps [on|off]',
254 argumentHint: '[close|caps on|caps off]',
255 })
256 } catch {
257 /* optional */
258 }
259
260 // Options from manifest win; store covers in-session toggle before reload.
261 try {
262 if (raw?.costGuards === true) capsOn = true
263 else if (raw?.costGuards === false) capsOn = false
264 else if ($.store.get(CAPS_STORE_KEY) === true) capsOn = true
265 } catch {
266 /* keep capsOn */
267 }
268
269 lastUsage = await readUsage($)
270 const total = costUsd(lastUsage)
271 if (total != null) lastCostSeen = total
272
273 // Icons need a Nerd Font in the terminal — remind once per machine.
274 try {
275 const key = 'nerdFontHintShown'
276 if (!$.store.get(key)) {
277 $.store.set(key, true)
278 await $.ui.toast(
279 'cost-visibility: set a Nerd Font as your terminal font for icons (meters work either way). See README.',
280 { timeoutMs: 8000 },
281 )
282 }
283 } catch {
284 /* toast/store unavailable — non-fatal */
285 }
286
287 $.ui.invalidate('ui.render')
288 return next(e)
289 })
290
291 // Keep live flag in sync when the person toggles via /config UI.
292 on('config.set', { key: CAPS_CONFIG_KEY }, async ($, e, next) => {
293 const result = await next(e)
294 capsOn = (result?.value ?? e.value) === true
295 try {
296 $.store.set(CAPS_STORE_KEY, capsOn)
297 } catch {
298 /* optional */
299 }
300 $.ui.invalidate('ui.render')
301 return result
302 }).catch((_$, e, next) => next(e))
303
304 on('session.measure', async ($, e, next) => {
305 lastUsage = await readUsage($)
306 $.ui.invalidate('ui.render')
307 return next(e)
308 })
309
310 on('turn.complete', async ($, e, next) => {
311 lastUsage = await readUsage($)
312 noteCostDelta(lastUsage, e.agentId)
313 $.ui.invalidate('ui.render')
314 return next(e)
315 })
316
317 on('tool.call', async ($, e, next) => {
318 toolCalls += 1
319 $.ui.invalidate('ui.render')
320 return next(e)
321 }).catch(() => {})
322
323 on('agent.spawn', async ($, e, next) => {
324 agentSpawns += 1
325 if (capsOn) {
326 const desc = String(e.description || e.prompt || '').toLowerCase()
327 const bg =
328 e.runInBackground === true ||
329 e.run_in_background === true ||
330 e.background === true
331 if (
332 desc.includes('code-review max') ||
333 desc.includes('5+5 angles') ||
334 desc.includes('multi-angle') ||
335 /angle [a-e]:/.test(desc)
336 ) {
337 return {
338 deny: 'cost-visibility: multi-angle / code-review max agents banned (cost guards on). Use /code-review medium|high, or /spend caps off.',
339 }
340 }
341 if (bg) {
342 return {
343 deny: 'cost-visibility: background agents disabled (cost guards on). /spend caps off to allow.',
344 }
345 }
346 }
347 const result = await next(e)
348 const id = result?.agentId || result?.id
349 if (id) {
350 agents.set(id, {
351 type: String(e.subagentType || e.type || 'agent'),
352 description: String(e.description || '').slice(0, 80),
353 usd: 0,
354 turns: 0,
355 })
356 }
357 $.ui.invalidate('ui.render')
358 return result
359 }).catch(($, e, next) => {
360 if (capsOn) {
361 return {
362 deny: 'cost-visibility: agent.spawn hook error — denying spawn (cost guards on, fail-closed)',
363 }
364 }
365 return next(e)
366 })
367
368 on('prompt.submit', async ($, e, next) => {
369 if (!capsOn) return next(e)
370 const text = String(e.text || '').toLowerCase()
371 if (
372 (text.includes('/code-review') || text.includes('/review')) &&
373 (/\bxhigh\b/.test(text) ||
374 /\bultra\b/.test(text) ||
375 text.includes('/code-review max') ||
376 /(^|[\s])max([\s]|$)/.test(text) ||
377 text.includes('braba') ||
378 text.includes('5+5'))
379 ) {
380 return {
381 drop: 'cost-visibility: /code-review max|xhigh|ultra|braba blocked (cost guards on). Try /code-review medium, or /spend caps off.',
382 }
383 }
384 return next(e)
385 }).catch((_$, _e, next) => next)
386
387 on('command.run', { command: 'spend' }, async ($, e) => {
388 lastUsage = await readUsage($)
389 const args = String(e.args || '').trim()
390 const words = args.split(/\s+/).filter(Boolean)
391
392 if (words[0] === 'close') {
393 await $.ui.close(PANE_ID)
394 return { text: 'Closed cost pane.' }
395 }
396
397 if (words[0] === 'caps' || words[0] === 'guards') {
398 const action = (words[1] || 'status').toLowerCase()
399 if (action === 'status' || action === '') {
400 return {
401 text:
402 'Cost guards: ' +
403 (capsOn ? 'ON' : 'OFF') +
404 ' (default OFF = HUD only).\n' +
405 'Toggle: /spend caps on · /spend caps off\n' +
406 'Also: /config → cost-visibility → Cost guards',
407 }
408 }
409 if (action === 'on' || action === 'true' || action === '1') {
410 capsOn = true
411 const r = await writeCapsConfig($, true)
412 $.ui.invalidate('ui.render')
413 return {
414 text:
415 'Cost guards ON — blocking /code-review max|xhigh|ultra|braba, background agents, multi-angle fan-out.' +
416 (r.persisted === 'config' ? '' : '\n(' + (r.reason || 'session-only until config persists') + ')'),
417 }
418 }
419 if (action === 'off' || action === 'false' || action === '0') {
420 capsOn = false
421 const r = await writeCapsConfig($, false)
422 $.ui.invalidate('ui.render')
423 return {
424 text:
425 'Cost guards OFF — HUD only, no blocks from this mod.' +
426 (r.persisted === 'config' ? '' : '\n(' + (r.reason || 'session-only until config persists') + ')'),
427 }
428 }
429 return {
430 text: 'Usage: /spend caps [on|off|status]',
431 }
432 }
433
434 await openPane($)
435 return {
436 text:
437 summarize(lastUsage) +
438 '\nGuards ' +
439 (capsOn ? 'ON' : 'OFF') +
440 ' · pane open · /spend close · /spend caps on|off',
441 }
442 })
443
444 function summarize(usage) {
445 const total = costUsd(usage)
446 const ctx = usage?.context || {}
447 const parts = []
448 for (const lim of usage?.rateLimits || []) {
449 parts.push(formatLimitShort(lim))
450 }
451 parts.push('this chat ' + fmtUsd(total))
452 parts.push('agents ' + fmtUsd(agentCostUsd) + '×' + agentSpawns)
453 if (ctx.percent != null) {
454 parts.push('ctx ' + Math.round(ctx.percent) + '%')
455 }
456 return parts.join(' · ')
457 }
458
459 // Primary UX: always-on two-line band with colors + Nerd Font icons + bars.
460 // Line 1 = plan meters. Line 2 = this-chat burn + details.
461 on('ui.render', { component: 'AbovePrompt' }, async ($, e) => {
462 if (e.surface && e.surface !== 'terminal') return undefined
463 const { Text, Box } = $.ui.resolve(e)
464 const usage = lastUsage || (await readUsage($))
465 lastUsage = usage
466 const total = costUsd(usage)
467 const ctx = usage?.context || {}
468 const ctxPct = ctx.percent != null ? Math.round(ctx.percent) : null
469 const limits = usage?.rateLimits || []
470 const width = Math.max(40, e.props.bodyColumns || 80)
471 const narrow = width < 90
472 const barW = narrow ? 6 : 8
473
474 const five = limits.find((l) => l.kind === 'five_hour')
475 const week = limits.find((l) => l.kind === 'seven_day')
476 const spend = limits.find((l) => l.kind === 'spend_limit')
477 const ordered = [five, week, spend].filter(Boolean)
478
479 const planRow = ordered.length
480 ? el(
481 Box,
482 { width, paddingRight: 5 },
483 ...ordered.flatMap((lim, i) =>
484 i === 0
485 ? [meterChip(Text, Box, lim, barW)]
486 : [sep(Text), meterChip(Text, Box, lim, barW)],
487 ),
488 )
489 : el(
490 Text,
491 { color: 'subtle' },
492 I.clock + ' plan meters after the first reply',
493 )
494
495 const ctxColor = pctColor(ctxPct)
496 const ctxLabel =
497 ctxPct != null
498 ? ctxPct + '% ' + bar(ctxPct, 5) + ' ' + fmtTok(ctx.tokens) + '/' + fmtTok(ctx.window)
499 : '—'
500 const moneyColor = (total ?? 0) >= 5 ? 'warning' : (total ?? 0) > 0 ? 'claude' : 'subtle'
501 const agentColor = agentSpawns > 0 ? 'warning' : 'subtle'
502
503 const burnRow = el(
504 Box,
505 { justifyContent: 'space-between', paddingRight: 5, width },
506 el(
507 Box,
508 null,
509 burnChip(Text, Box, I.usd, moneyColor, fmtUsd(total ?? 0)),
510 sep(Text),
511 burnChip(
512 Text,
513 Box,
514 I.circle,
515 'subtle',
516 'main ' + fmtUsd(mainCostUsd),
517 ),
518 sep(Text),
519 burnChip(
520 Text,
521 Box,
522 I.users,
523 agentColor,
524 agentSpawns > 0
525 ? fmtUsd(agentCostUsd) + ' ×' + agentSpawns
526 : '0 agents',
527 ),
528 sep(Text),
529 burnChip(Text, Box, I.ctx, ctxColor, ctxLabel),
530 sep(Text),
531 burnChip(Text, Box, I.wrench, 'subtle', String(toolCalls)),
532 ),
533 el(Text, { color: 'suggestion' }, '/spend'),
534 )
535
536 return el(
537 Box,
538 { flexDirection: 'column', width, gap: 0 },
539 planRow,
540 burnRow,
541 )
542 })
543
544 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
545 const usage = lastUsage
546 const total = costUsd(usage)
547 const pct = usage?.context?.percent
548 const five = usage?.rateLimits?.find((l) => l.kind === 'five_hour')
549 const suffix =
550 ' · ' +
551 I.usd +
552 ' ' +
553 fmtUsd(total) +
554 (pct != null ? ' · ' + I.ctx + ' ' + Math.round(pct) + '%' : '') +
555 (five
556 ? ' · ' + I.clock + ' ' + Math.round(five.percentUsed ?? 0) + '%'
557 : '') +
558 (agentSpawns ? ' · ' + I.users + ' ' + agentSpawns : '')
559 return next({ ...e, props: { ...e.props, suffix } })
560 })
561
562 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
563 if (e.requestId !== PANE_ID) return next(e)
564 const { Text, Box, Button, Markdown } = $.ui.resolve(e)
565 const usage = lastUsage || (await readUsage($))
566 lastUsage = usage
567 const ctx = usage?.context || {}
568 const limits = usage?.rateLimits || []
569 const total = costUsd(usage)
570 const ctxPct = ctx.percent != null ? Math.round(ctx.percent) : null
571
572 const limitMd = limits.length
573 ? limits
574 .map((l) => {
575 const p = Math.round(l.percentUsed ?? 0)
576 const resetAbs = l.resetsAt ? new Date(l.resetsAt).toLocaleString() : '?'
577 const resetRel = untilReset(l.resetsAt)
578 return (
579 '- ' +
580 limitIcon(l.kind) +
581 ' **' +
582 limitLabelLong(l.kind) +
583 '** — ' +
584 statusWord(p) +
585 ' at **' +
586 p +
587 '%** `' +
588 bar(p, 10) +
589 '`\n' +
590 ' refreshes in **' +
591 (resetRel || '?') +
592 '** (' +
593 resetAbs +
594 ')'
595 )
596 })
597 .join('\n')
598 : '_No plan-window reading yet (needs a reply on a Claude subscription)._'
599
600 const agentRows = [...agents.entries()]
601 const agentMd = agentRows.length
602 ? agentRows
603 .map(([id, a]) => {
604 return (
605 '- `' +
606 id.slice(0, 8) +
607 '` **' +
608 a.type +
609 '**: ' +
610 fmtUsd(a.usd) +
611 ' · ' +
612 a.turns +
613 ' turn(s)' +
614 (a.description ? ' — ' + a.description : '')
615 )
616 })
617 .join('\n')
618 : '_No subagents spawned this session._'
619
620 const md =
621 '## Money this session\n\n' +
622 'Estimated **API list price** (same yardstick as `/cost` / `cost.usd`). ' +
623 'On a Team subscription this is a burn meter, not your invoice.\n\n' +
624 '| | USD |\n|---|---:|\n' +
625 '| **Whole session** | **' +
626 fmtUsd(total) +
627 '** |\n' +
628 '| Main thread | ' +
629 fmtUsd(mainCostUsd) +
630 ' |\n' +
631 '| Subagents (sum) | ' +
632 fmtUsd(agentCostUsd) +
633 ' |\n' +
634 '| Subagents spawned | ' +
635 agentSpawns +
636 ' |\n' +
637 '| Tool calls | ' +
638 toolCalls +
639 ' |\n\n' +
640 '## Context window\n\n' +
641 (ctxPct != null
642 ? '- **' +
643 ctxPct +
644 '%** filled — ' +
645 fmtTok(ctx.tokens) +
646 ' / ' +
647 fmtTok(ctx.window) +
648 ' tokens\n- Soft target: stay under ~70–85% (auto-compact ~150k)\n'
649 : '_Waiting for the first model reply._\n') +
650 '\n## Plan / rate limits\n\n' +
651 limitMd +
652 '\n\n## Subagents\n\n' +
653 agentMd +
654 '\n\n## Cost guards\n\n' +
655 (capsOn
656 ? '**ON** — blocking `/code-review` max|xhigh|ultra|braba, background agents, multi-angle fan-out.\n'
657 : '**OFF** (default) — HUD only, no blocks from this mod.\n') +
658 'Toggle: `/spend caps on` · `/spend caps off` · `/config`\n\n' +
659 '_Org **monthly** spend: Team Owners → claude.ai → Usage._\n\n' +
660 '- Session `' +
661 $.session.id() +
662 '`\n' +
663 '- Model `' +
664 $.session.model() +
665 '`'
666
667 return el(
668 Box,
669 {
670 flexDirection: 'column',
671 gap: 1,
672 width: e.props.bodyColumns,
673 padding: 1,
674 },
675 el(Markdown, { text: md }),
676 el(
677 Box,
678 { gap: 1 },
679 el(Button, {
680 key: 'refresh',
681 label: 'Refresh',
682 hotkey: 'r',
683 onPress: async () => {
684 lastUsage = await readUsage($)
685 $.ui.invalidate('ui.render')
686 },
687 }),
688 el(Button, {
689 key: 'close',
690 label: 'Close',
691 hotkey: 'c',
692 onPress: async () => {
693 await $.ui.close(PANE_ID)
694 },
695 }),
696 ),
697 el(
698 Text,
699 { dimColor: true },
700 'Tip: dollars are list-price estimates. Shell `claude-cost` has multi-session history.',
701 ),
702 )
703 })
704}
705