A permanent, slim context-usage bar above the prompt with a chomping Pac-Man; green under 20%, amber 20-30%, red past 30%, munching faster each zone, with a…

A Claude Code mod: a slim, permanent context-usage bar above the prompt, with a small animated sparkle. Green under 20%, amber 20–30%, red past 30% — each tier has a few short phrases that rotate in. The tier only climbs during a session (never drops back down quietly) and resets on a new one.
In a claude terminal session (not the desktop app's Code tab — plugin install runs from the CLI):
/plugin install context-guard-bot --marketplace ryannorman-6494/context-guard-bot
Say y to add the marketplace, then pick the user scope (the default) so it loads in every project, not just the current one.
This folder is the mod itself — edit hooks/register.tsx directly. To try changes without reinstalling, point a session at the folder:
claude --plugin-dir /path/to/context-guard-bothooks/register.tsx 158 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { ContextTier, ContextUsage } from '../types'
5
6// A Claude Code dev mod (~/.claude/dev-mods), unrelated to the KYB onboarding
7// project this session otherwise works in.
8
9const WARN_PERCENT = 20
10const DANGER_PERCENT = 30
11const TRACK_COLOR = '#F5F5F2' // off-white, not theme-grey: Ryan asked for this literally
12
13const TIER_RANK: Record<ContextTier, number> = { ok: 0, warn: 1, danger: 2 }
14// One literal hex per tier, used for the bar fill, the text and the sparkle
15// alike (a sandboxed <svg> can't read the app's theme tokens, so this can't
16// be a ThemeKey). Amber, not the brownish amber-600 from the first pass.
17const TIER_HEX: Record<ContextTier, string> = { ok: '#16A34A', warn: '#F59E0B', danger: '#DC2626' }
18const TIER_PHRASES: Record<ContextTier, string[]> = {
19 ok: ['plenty of room', 'cruising', 'all good here', 'In the green zone'],
20 warn: ['getting cosy', 'climbing — keep an eye on it', 'snacking through tokens', 'Think about wrapping up'],
21 danger: ['burning tokens fast', "context's about to pop", 'living dangerously (and expensively)', 'time to bail — new session']
22}
23
24function tierOf(percent: number): ContextTier {
25 if (percent >= DANGER_PERCENT) return 'danger'
26 if (percent >= WARN_PERCENT) return 'warn'
27 return 'ok'
28}
29
30// One random line per tier, picked when that tier is first entered and kept
31// until the tier changes again — not reshuffled on every token, so it reads
32// as a line someone wrote, not a slot machine.
33function pickPhrase(tier: ContextTier): string {
34 const options = TIER_PHRASES[tier]
35 return options[Math.floor(Math.random() * options.length)] ?? options[0]!
36}
37
38const usageAtom = atom(
39 { plugin: 'context-guard-bot', key: 'usage' } as const,
40 { tokens: 0, percent: 0, tier: 'ok', message: pickPhrase('ok') } as ContextUsage
41)
42
43// One fixed chomp speed per tier, not a continuous function of percent:
44// percent changes far more often than the tier does, and tying the SVG's
45// source string to a fast-changing value is exactly what made the sparkle
46// stutter before (it remounted, and restarted, on every tick). The mouth only
47// speeds up on a real zone crossing, same as the colour already does.
48const TIER_CHOMP_SECONDS: Record<ContextTier, number> = { ok: 1.1, warn: 0.65, danger: 0.35 }
49
50function arcPoint(angleDeg: number): [number, number] {
51 const rad = (angleDeg * Math.PI) / 180
52 return [12 + 10 * Math.cos(rad), 12 + 10 * Math.sin(rad)]
53}
54
55// A single pie boundary, one continuous path, not a circle-plus-cutout: two
56// separately-drawn arcs that are only numerically the same circle leave a
57// hair's-width seam between them once you're down at a 13px icon — that was
58// the "tiny sliver still" left by the evenodd version. One arc, one curve,
59// nothing to seam against.
60//
61// From point1 (angle -theta) to point2 (angle +theta), the body is the long
62// way around (through 180°, not through the mouth gap) — large-arc-flag 1.
63// SVG's sweep-flag 1 means increasing angle, which on screen (y grows
64// downward) reads clockwise; the long way from -theta to +theta is
65// decreasing angle (through -90, -180, back up to +theta from the far side),
66// so sweep is 0, not the 1 guessed originally — that earlier guess is what
67// filled the mouth and left the body empty.
68function pacPath(theta: number): string {
69 const [x1, y1] = arcPoint(-theta)
70 const [x2, y2] = arcPoint(theta)
71 return `M12,12 L${x1.toFixed(2)},${y1.toFixed(2)} A10,10 0 1 0 ${x2.toFixed(2)},${y2.toFixed(2)} Z`
72}
73
74const PAC_CLOSED = pacPath(2)
75const PAC_OPEN = pacPath(35)
76
77function pacmanSvg(color: string, chompSeconds: number) {
78 return `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="${PAC_OPEN}" fill="${color}"><animate attributeName="d" values="${PAC_OPEN};${PAC_CLOSED};${PAC_OPEN}" dur="${chompSeconds}s" calcMode="linear" repeatCount="indefinite"/></path></svg>`
79}
80
81export const register: Register = on => {
82 on('session.start', async ($, e, next) => {
83 // Seed with what the session already has: this fires on a true new
84 // session (genuinely 0) and on every hot reload (already under way).
85 const { context } = await $.session.usage()
86 const tokens = context.tokens ?? 0
87 const percent = context.percent ?? 0
88 const tier = tierOf(percent)
89
90 await update($, usageAtom, () => ({ tokens, percent, tier, message: pickPhrase(tier) }))
91
92 return next(e)
93 })
94
95 on('session.measure', async ($, e, next) => {
96 if (e.changed.includes('context')) {
97 const tokens = e.context.tokens ?? 0
98 const percent = e.context.percent ?? 0
99
100 await update($, usageAtom, (prev) => {
101 // Sticky: the tier only climbs within a session, never drops back down.
102 const tier = TIER_RANK[tierOf(percent)] > TIER_RANK[prev.tier] ? tierOf(percent) : prev.tier
103 // A fresh phrase only on an actual tier change, not on every token.
104 const message = tier === prev.tier ? prev.message : pickPhrase(tier)
105
106 return { tokens, percent, tier, message }
107 })
108 }
109
110 return next(e)
111 })
112
113 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
114 const usage = await read($, usageAtom)
115 const components = $.ui.resolve(e)
116 const { Box, Text } = components
117 // Not every surface's table has Svg (the terminal has no image leaf); fall
118 // back to a plain glyph there instead of the animated mark.
119 const Svg = 'Svg' in components ? components.Svg : undefined
120
121 const trackWidth = Math.max(12, Math.min(40, (e.props.bodyColumns ?? 60) - 26))
122 const filled = usage.percent > 0 ? Math.max(1, Math.round((usage.percent / 100) * trackWidth)) : 0
123 const color = TIER_HEX[usage.tier]
124
125 const icon = Svg ? (
126 <Svg source={pacmanSvg(color, TIER_CHOMP_SECONDS[usage.tier])} alt="Pac-Man" width={13} height={13} isInteractive />
127 ) : (
128 <Text color={color} bold>C</Text>
129 )
130
131 return (
132 <Box flexDirection="row" alignItems="center" gap={1} paddingX={1}>
133 {icon}
134 <Box position="relative" width={trackWidth} height={1}>
135 <Box width={trackWidth} height={1} backgroundColor={TRACK_COLOR} borderStyle="round" borderColor="#D4D4D4" />
136 {filled > 0 && (
137 <Box
138 position="absolute"
139 top={0}
140 left={0}
141 width={filled}
142 height={1}
143 backgroundColor={color}
144 borderStyle="round"
145 borderColor={color}
146 />
147 )}
148 </Box>
149 <Text color={usage.tier === 'ok' ? undefined : color} dimColor={usage.tier === 'ok'} bold={usage.tier !== 'ok'}>
150 {Math.round(usage.percent)}%
151 </Text>
152 <Text dimColor>· {Math.round(usage.tokens / 1000)}k</Text>
153 <Text color={color}>· {usage.message}</Text>
154 </Box>
155 )
156 })
157}
158types/index.d.ts 9 lines1export type ContextTier = 'ok' | 'warn' | 'danger'
2export type ContextUsage = { tokens: number; percent: number; tier: ContextTier; message: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'context-guard-bot': { usage: ContextUsage }
7 }
8}
9