SLOPSHOPPER

hud

One line under the prompt: permission-mode arrows, context weather bar, prompt-cache countdown and a pomodoro mascot

newspinnerguardpromptprocesstimer
v0.1.0no licenseupdated 2026-10-14aidan-nunn-sys/claude-config/mods/hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hud
› 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⧗ 31% (╥_╥) ? for shortcuts

Draws

Prompt hint
⧗ 31% (╥_╥) ? for shortcuts
README

claude-config

My Claude Code setup: mods, plugins, skills and settings, so a new machine is one clone away.

New machine

git clone git@github-personal:aidan-nunn-sys/claude-config.git ~/Work/personal/claude-config
~/Work/personal/claude-config/install.sh

Then:

  • Fill in any <SET-ME> values install.sh reports in ~/.claude.json (MCP secrets are never committed).
  • optimaizr CLI (audited 0.11.0; re-audit before bumping), in ~/.config/mise/config.toml: "npm:optimaizr" = { version = "0.11.0", allow_low_downloads = true }
  • Restart Claude Code.

After changing settings

Mods and skills are symlinked from this repo, so edits land here directly. Settings changed through Claude Code live in ~/.claude; run ./capture.sh and review git diff to bring them back.

Layout

PathWhat
mods/Mods loaded through CLAUDE_CODE_PLUGIN_DIRS (built by install.sh from this folder)
plugins/plugins.jsonMarketplaces and which marketplace plugins are enabled
skills/Personal skills, symlinked into ~/.claude/skills
settings/settings.json~/.claude/settings.json minus plugin dirs, plugins and hooks; ${HOME} for paths
settings/mcp.jsonUser MCP servers, secrets blanked
settings/rulebook.jsonGeneric example rules (copied only when the machine has none; not captured)
docs/specs/Design specs

Mods

ModSourceLocal changes
hudownprompt hint, context weather bar, cache countdown, kaomoji mascot
rulebookownhard rules in ~/.claude/rulebook.json, /rules, mcp__rulebook__add_rule
xagentowndispatch/route tools for Codex, Copilot, Antigravity
handoffown/handoff [next] writes a handoff doc to ~/.claude/handoffs/
agent-radarhamzafer/claude-code-mods @ 87f108479bbf/radar runs mid-turn (immediate) and toggles closed; shows xagent's external agents
mission-controlhamzafer/claude-code-mods @ 87f108479bbfshows xagent's external agents; code-map view (Haiku diff summaries, headless Chrome) stripped
blast-radiushamzafer/claude-code-mods @ 87f108479bbf60 s auto-cancel
review-watchhamzafer/claude-code-mods @ 87f108479bbfnone
where-am-ihamzafer/claude-code-mods @ 87f108479bbfshows only "next"
secret-boxray-amjad/claude-code-mods @ 467b20300d8enone
task-boardMarcoCarnevali/claude-code-mods @ a3e528dd00ce/board (not /tasks), no auto-open, mid-turn + toggle

Upstream commits are the repos' HEAD on 2026-10-14, when this repo was made; installs were from around then. Read upstream changes before pulling them in.

Not in this repo

  • ~/.claude/.credentials.json and MCP secrets.
  • Managed by other tools: omarchy skills (diagnose-crash, omarchy, symlinks into /usr/share/omarchy), themes/omarchy.json, the herdr SessionStart hook.
  • Per-machine state: memory, history, sessions, plans.
  • .claude-plugin/types/ in each mod: SDK types Claude Code writes itself.
  • Hiding Claude Code's native subagent area: the mod API exposes no component for it (checked 2026-10-14; Spinner props are word, message, suffix, mode).
Source 2 files
hooks/register.tsx 388 lines
1// HUD: one line under the prompt, in place of the permission-mode text.
2//   ⏵⏵  ☀ ███▒▒······ 23% ❄ 4:12 ⧗ 41% 2h13m   (•ᴗ•)✎__
3// The ⧗ reading is the 5-hour session limit and the time until it resets.
4// The mascot follows what Claude is doing (writing to you, thinking, editing, running, reading,
5// an agent, idle, asleep), easing from one pose to the next through a blink.
6// The context reading and the cache countdown are after hamzafer's context-bar and
7// token-weather mods (github.com/hamzafer/claude-code-mods).
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register, Timer } from 'claude-code'
10
11import type { Activity, CacheTtl, Context, Limit, Mood, Slice } from '../types'
12
13const FRAME_MS = 600
14// A pose is held at least this long, so quick switches (text, tool, text) do not flicker.
15const HOLD_MS = 1_200
16// The blink shown between two poses.
17const BLINK_MS = 300
18// Idle this long and the mascot falls asleep.
19const SLEEP_MS = 3 * 60_000
20const BAR = 10
21const ORANGE = '#d97757'
22const PALETTE = ['#7aa2f7', '#7dcfff', '#bb9af7', '#9ece6a', '#e0af68', '#f7768e', '#73daca', '#ff9e64']
23const TTL_MS = { '5m': 5 * 60_000, '1h': 60 * 60_000 } as const
24const TAIL_BYTES = 1024 * 1024
25
26const WEATHER = [
27  { upTo: 25, icon: '☀', color: 'yellow' },
28  { upTo: 50, icon: '☁', color: 'cyan' },
29  { upTo: 75, icon: '☂', color: 'blue' },
30  { upTo: 90, icon: '☇', color: 'magenta' },
31  { upTo: Infinity, icon: '↯', color: 'red' },
32] as const
33
34const MODES = [
35  { pattern: /auto mode on/i, arrows: '⏵⏵', color: 'warning' },
36  { pattern: /accept edits on/i, arrows: '⏵⏵', color: 'autoAccept' },
37  { pattern: /plan mode on/i, arrows: '⏸', color: 'planMode' },
38  { pattern: /bypass permissions on/i, arrows: '⏵⏵', color: 'error' },
39] as const
40const MODE_TEXT = /(⏵⏵|⏸)?\s*(auto mode|accept edits|plan mode|bypass permissions) on(\s*\([^)]*\))?/i
41// Hint parts dropped from the line: the mode cycle key, the interrupt key, and the queued-message notice.
42const HIDDEN = /shift\+tab|esc to interrupt|queued|to edit/i
43
44// Each mascot pose loops through its frames; all frames are padded to one width so the line never jumps.
45const POSES = {
46  idle: ['(•ᴗ•)', '(•ᴗ•)', '(-ᴗ-)', '(•ᴗ•)', '(•ᴗ•)♪', '(•ᴗ•)'],
47  thinking: ['(•_•) .', '(•_•) ..', '(•_•) ...', '(・_・)?'],
48  writing: ['(•ᴗ•)✎__', '(•ᴗ•)_✎_', '(•ᴗ•)__✎', '(•ᴗ•)_✎_'],
49  editing: ['(•̀ᴗ•́)⌨', '(•̀ᴗ•́)⌨.', '(•̀ᴗ•́)⌨..', '(•̀ᴗ•́)⌨.'],
50  running: ['(•o•)◐', '(•o•)◓', '(•o•)◑', '(•o•)◒'],
51  reading: ['(°_°)⌕', '(°_°) ⌕', '(°-°) ⌕', '(°_°)⌕'],
52  agent: ['(•ᴗ•)ノ⚑', '(•ᴗ•)ノ ⚑', '(•ᴗ•)ノ⚑', '(•ᴗ•)ノ ⚑'],
53  sad: ['(╥_╥)', '(╥_╥)', '(T_T)', '(╥_╥) .'],
54  happy: ['(ᵔᴗᵔ)♪', '\\(ᵔᴗᵔ)/', '(ᵔᴗᵔ)♫', '\\(ᵔᴗᵔ)/'],
55  sleeping: ['(-ᴗ-)z', '(-ᴗ-)zZ', '(-ᴗ-)zZz', '(-ᴗ-)zZ'],
56} as const
57const BLINK = '(-ᴗ-)'
58type Pose = keyof typeof POSES
59const POSE_WIDTH = 10
60
61const SAD_WORDS = /\b(no+|nope|wrong|stop|undo|revert|broke|broken|ugh|wtf|why did you|that'?s not|didn'?t work|doesn'?t work|not what i)\b/i
62const HAPPY_WORDS = /\b(thanks?|thank you|ty|great|perfect|nice|awesome|love it|good job|works|amazing)\b/i
63
64const context = atom({ plugin: 'hud', key: 'context' } as const, null as Context | null)
65const lastRequestAt = atom({ plugin: 'hud', key: 'lastRequestAt' } as const, null as number | null)
66const ttl = atom({ plugin: 'hud', key: 'ttl' } as const, '5m' as CacheTtl)
67const activity = atom({ plugin: 'hud', key: 'activity' } as const, 'idle' as Activity)
68const mood = atom({ plugin: 'hud', key: 'mood' } as const, null as Mood | null)
69const limit = atom({ plugin: 'hud', key: 'limit' } as const, null as Limit | null)
70
71// The animation clock and the pose on screen; a reload of this file starts them over.
72let frame = 0
73let ticker: Timer | undefined
74let shown: { pose: Pose; since: number; frame: number } | null = null
75let lastActiveAt = 0
76// The last activity a response chunk set, so a stream of chunks writes the state once.
77let streaming: Activity | null = null
78
79export const register: Register = on => {
80  on('session.start', async ($, e, next) => {
81    const result = await next(e)
82    lastActiveAt = await $.clock.now()
83    const known = await $.store.get('ttl').catch(() => undefined)
84    if (known === '5m' || known === '1h') await update($, ttl, () => known)
85    await refreshContext($).catch(() => {})
86    await refreshLimit($).catch(() => {})
87    ticker?.cancel()
88    ticker = $.clock.every(FRAME_MS, () => {
89      frame += 1
90      void tick($).catch(() => {})
91    })
92    return result
93  })
94
95  on('session.end', async ($, e, next) => {
96    if (e.reason === 'clear' || e.reason === 'resume') await update($, lastRequestAt, () => null)
97    return next(e)
98  })
99
100  on('prompt.submit', async ($, e, next) => {
101    await react($, e.text).catch(() => {})
102    return next(e)
103  })
104
105  on('tool.call', async ($, e, next) => {
106    if (e.agentId) return next(e)
107    await setActivity($, activityFor(e.tool)).catch(() => {})
108    const result = await next(e)
109    await afterTool($, e.tool, String((e as { command?: unknown }).command ?? ''), result).catch(() => {})
110    return result
111  })
112
113  // Claude's response as it streams: visible text is writing to you, thinking is thinking.
114  on('turn.step', async function* ($, e, next) {
115    const stream = next(e)
116    let step = await stream.next()
117    while (!step.done) {
118      const c = step.value
119      if (!e.agentId && (c.kind === 'text' || c.kind === 'thinking')) {
120        const a: Activity = c.kind === 'text' ? 'writing' : 'thinking'
121        if (a !== streaming) {
122          streaming = a
123          await setActivity($, a).catch(() => {})
124        }
125      }
126      yield c
127      step = await stream.next()
128    }
129    const result = step.value
130    if (!e.agentId && result.stopReason !== null) {
131      const now = await $.clock.now()
132      await update($, lastRequestAt, () => now)
133      await refreshContext($).catch(() => {})
134      await refreshLimit($).catch(() => {})
135    }
136    return result
137  })
138
139  on('turn.complete', async ($, e, next) => {
140    const result = await next(e)
141    if (!e.agentId) {
142      await setActivity($, 'idle')
143      await refreshContext($).catch(() => {})
144      const found = ttlFromTranscript(await readTail($))
145      if (found !== null && found !== (await read($, ttl))) {
146        await update($, ttl, () => found)
147        await $.store.set('ttl', found).catch(() => {})
148      }
149    }
150    return result
151  })
152
153  // Pushed after each turn and whenever a rate-limit window moves a whole point.
154  on('session.measure', async ($, e, next) => {
155    if (e.changed.includes('rateLimits')) await setLimit($, e.rateLimits).catch(() => {})
156    return next(e)
157  })
158
159  on('session.compact', async ($, e, next) => {
160    const result = await next(e)
161    if (!e.agentId) await refreshContext($).catch(() => {})
162    return result
163  })
164
165  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
166    const { Box, Text } = $.ui.resolve(e)
167    const now = await $.clock.now()
168    const mode = MODES.find(m => m.pattern.test(e.props.hint))
169    const rest = e.props.hint
170      .replace(MODE_TEXT, '')
171      .split('·')
172      .map(part => part.trim())
173      .filter(part => part !== '' && !HIDDEN.test(part))
174      .join(' · ')
175    const ctx = await read($, context)
176    const since = await read($, lastRequestAt)
177    const cacheLeft = since === null ? null : since + TTL_MS[await read($, ttl)] - now
178    const face = display(await currentPose($, now, e.props.isWorking), now)
179    const lim = await read($, limit)
180
181    return (
182      <Box flexDirection="row">
183        <Text wrap="truncate-end">
184          {mode && <Text color={mode.color}>{`${mode.arrows}  `}</Text>}
185          {ctx && weather(ctx)}
186          {cacheLeft !== null && cachePart(cacheLeft)}
187          {lim && limitPart(lim)}
188          <Text color={ORANGE}>{`  ${face}`}</Text>
189          {rest !== '' && <Text dimColor>{`   ${rest}`}</Text>}
190        </Text>
191      </Box>
192    )
193
194    function weather(c: Context) {
195      const w = WEATHER.find(b => c.percent < b.upTo) ?? WEATHER[4]
196      return (
197        <Text>
198          <Text color={w.color}>{`${w.icon} `}</Text>
199          {cells(c.slices, c.window, BAR).map(cell => (
200            <Text color={cell.color} dimColor={cell.kind !== 'used'}>{cell.text}</Text>
201          ))}
202          <Text color={c.percent >= 90 ? 'red' : c.percent >= 70 ? 'yellow' : undefined}>{` ${c.percent}%`}</Text>
203        </Text>
204      )
205    }
206
207    function cachePart(left: number) {
208      if (left <= 0) return <Text dimColor>{'  ❄ cold'}</Text>
209      return <Text color={left < 60_000 ? 'yellow' : 'cyan'} dimColor={left >= 60_000}>{`  ❄ ${clock(left)}`}</Text>
210    }
211
212    function limitPart(l: Limit) {
213      const color = l.percent >= 80 ? 'red' : l.percent >= 50 ? 'yellow' : 'green'
214      const resets = l.resetsAt === null ? '' : ` ${duration(l.resetsAt - now)}`
215      return (
216        <Text>
217          <Text color={color}>{`  ⧗ ${Math.round(l.percent)}%`}</Text>
218          {resets !== '' && <Text dimColor>{resets}</Text>}
219        </Text>
220      )
221    }
222
223  })
224}
225
226async function tick($: EngineInterface) {
227  $.ui.invalidate('ui.render')
228}
229
230async function setActivity($: EngineInterface, a: Activity) {
231  lastActiveAt = await $.clock.now()
232  if (a === 'idle') streaming = null
233  await update($, activity, () => a)
234}
235
236async function react($: EngineInterface, text: string) {
237  const now = await $.clock.now()
238  if (SAD_WORDS.test(text)) await setMood($, 'sad', now + 10_000)
239  else if (HAPPY_WORDS.test(text)) await setMood($, 'happy', now + 8_000)
240  await setActivity($, 'thinking')
241}
242
243async function afterTool($: EngineInterface, tool: string, command: string, result: { deny?: string; isError?: true }) {
244  streaming = null
245  await setActivity($, 'thinking')
246  const now = await $.clock.now()
247  if (result.deny !== undefined) await setMood($, 'sad', now + 5_000)
248  else if (result.isError) await setMood($, 'sad', now + 4_000)
249  else if (tool === 'Bash' && /\b(test|verify|check)\b/.test(command)) await setMood($, 'happy', now + 5_000)
250}
251
252async function setMood($: EngineInterface, kind: Mood['kind'], until: number) {
253  await update($, mood, () => ({ kind, until }))
254}
255
256async function currentPose($: EngineInterface, now: number, isWorking: boolean): Promise<Pose> {
257  const m = await read($, mood)
258  if (m && m.until > now) return m.kind
259  const a = await read($, activity)
260  if (a !== 'idle') return a
261  if (isWorking) return 'thinking'
262  return now - lastActiveAt >= SLEEP_MS ? 'sleeping' : 'idle'
263}
264
265// The pose on screen eases toward the wanted one: each pose is held a moment, a change goes
266// through a blink, and the new pose starts from its first frame.
267function display(wanted: Pose, now: number) {
268  if (!shown) shown = { pose: wanted, since: now - BLINK_MS, frame } // the first pose needs no blink
269  else if (wanted !== shown.pose && now - shown.since >= HOLD_MS) shown = { pose: wanted, since: now, frame }
270  if (now - shown.since < BLINK_MS) return BLINK.padEnd(POSE_WIDTH)
271  return pose(shown.pose, frame - shown.frame)
272}
273
274export function pose(name: Pose, frame: number) {
275  const frames = POSES[name]
276  return frames[frame % frames.length]!.padEnd(POSE_WIDTH)
277}
278
279export function activityFor(tool: string): Activity {
280  if (/^(Edit|MultiEdit|Write|NotebookEdit)$/.test(tool)) return 'editing'
281  if (tool === 'Bash') return 'running'
282  if (/^(Read|Grep|Glob|WebFetch|WebSearch)$/.test(tool)) return 'reading'
283  if (tool === 'Agent' || tool === 'Task') return 'agent'
284  return 'thinking'
285}
286
287export function clock(ms: number) {
288  const s = Math.max(0, Math.ceil(ms / 1000))
289  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
290}
291
292// Time left as `2h13m`, or `13m` under an hour.
293export function duration(ms: number) {
294  const m = Math.max(0, Math.ceil(ms / 60_000))
295  return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m` : `${m}m`
296}
297
298async function refreshLimit($: EngineInterface) {
299  const usage = await $.session.usage()
300  await setLimit($, usage.rateLimits)
301}
302
303async function setLimit($: EngineInterface, limits: { kind: string; percentUsed: number; resetsAt?: string }[]) {
304  const five = limits.find(l => l.kind === 'five_hour')
305  if (!five) return
306  const resetsAt = five.resetsAt ? Date.parse(five.resetsAt) : NaN
307  const reading: Limit = { percent: five.percentUsed, resetsAt: Number.isNaN(resetsAt) ? null : resetsAt }
308  await update($, limit, () => reading)
309}
310
311async function refreshContext($: EngineInterface) {
312  const usage = await $.session.usage({ breakdown: 'summary' })
313  const b = usage.context.breakdown
314  if (!b || !(b.rawMaxTokens > 0)) return
315  const reading = toContext(b)
316  await update($, context, () => reading)
317}
318
319export function toContext(b: {
320  categories: { name: string; tokens: number; kind: string }[]
321  totalTokens: number
322  rawMaxTokens: number
323  percentage: number
324}): Context {
325  let n = 0
326  const slices: Slice[] = b.categories
327    .filter(c => c.kind !== 'deferred' && c.tokens > 0)
328    .map(c => ({ tokens: c.tokens, kind: c.kind as Slice['kind'], name: c.name.toLowerCase() }))
329    .sort((x, y) => Number(x.kind !== 'used') - Number(y.kind !== 'used'))
330    .map(c => ({
331      tokens: c.tokens,
332      kind: c.kind,
333      color: c.kind !== 'used' ? 'gray' : c.name === 'messages' ? ORANGE : PALETTE[n++ % PALETTE.length]!,
334    }))
335  return { percent: b.percentage, tokens: b.totalTokens, window: b.rawMaxTokens, slices }
336}
337
338// The bar as runs of cells: each used slice its proportional share of `width`, the rest free.
339// Rounding the running total keeps the filled length true to the overall share; a slice too small for a cell gets none.
340export function cells(slices: Slice[], window: number, width: number) {
341  const out: { color: string; kind: Slice['kind']; text: string }[] = []
342  let used = 0
343  let tokens = 0
344  for (const s of slices.filter(x => x.kind === 'used')) {
345    tokens += s.tokens
346    const end = Math.min(width, Math.round((tokens / window) * width))
347    if (end > used) out.push({ color: s.color, kind: 'used', text: '█'.repeat(end - used) })
348    used = Math.max(used, end)
349  }
350  if (used < width) out.push({ color: 'gray', kind: 'free', text: '·'.repeat(width - used) })
351  return out
352}
353
354// The end of this session's transcript, where the last response's cache usage is.
355async function readTail($: EngineInterface) {
356  try {
357    const home = await $.env.get('HOME')
358    const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home ? `${home}/.claude` : undefined)
359    if (!configDir) return ''
360    const project = (await $.session.root()).replace(/[^a-zA-Z0-9]/g, '-')
361    const path = `${configDir}/projects/${project}/${await $.session.id()}.jsonl`
362    const { size } = await $.fs.stat(path)
363    if (size <= TAIL_BYTES) return await $.fs.read(path)
364    const { exitCode, stdout } = await $.process.run(['tail', '-c', String(TAIL_BYTES), path])
365    return exitCode === 0 ? stdout : ''
366  } catch {
367    return ''
368  }
369}
370
371export function ttlFromTranscript(text: string): CacheTtl | null {
372  const lines = text.split('\n')
373  for (let i = lines.length - 1; i >= 0; i--) {
374    let entry: any
375    try {
376      entry = JSON.parse(lines[i] ?? '')
377    } catch {
378      continue
379    }
380    if (entry?.type !== 'assistant' || entry.isSidechain || !entry.message?.usage) continue
381    const written = entry.message.usage.cache_creation
382    if (Number(written?.ephemeral_1h_input_tokens) > 0) return '1h'
383    if (Number(written?.ephemeral_5m_input_tokens) > 0) return '5m'
384    return null
385  }
386  return null
387}
388
types/index.d.ts 20 lines
1export type Slice = { color: string; tokens: number; kind: 'used' | 'free' | 'buffer' }
2export type Context = { percent: number; tokens: number; window: number; slices: Slice[] }
3export type Activity = 'idle' | 'thinking' | 'writing' | 'editing' | 'running' | 'reading' | 'agent'
4export type Mood = { kind: 'sad' | 'happy'; until: number }
5export type CacheTtl = '5m' | '1h'
6export type Limit = { percent: number; resetsAt: number | null }
7
8declare module 'claude-code' {
9  interface PluginState {
10    hud: {
11      context: Context | null
12      lastRequestAt: number | null
13      ttl: CacheTtl
14      activity: Activity
15      mood: Mood | null
16      limit: Limit | null
17    }
18  }
19}
20