SLOPSHOPPER

context-guard-bot

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…

newband
v0.3.0no licenseupdated 2026-10-08ryannorman-6494/context-guard-bot
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-guard-bot
› 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 C ╭╮╭╮ 49% · 97k · time to bail — new session ╰╯╰╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
C ╭╮╭╮ 49% · 97k · time to bail — new session ╰╯╰╯
README

context-guard-bot

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.

Install

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.

Develop

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-bot
Source 2 files
hooks/register.tsx 158 lines
1import { 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}
158
types/index.d.ts 9 lines
1export 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