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

My Claude Code setup: mods, plugins, skills and settings, so a new machine is one clone away.
git clone git@github-personal:aidan-nunn-sys/claude-config.git ~/Work/personal/claude-config
~/Work/personal/claude-config/install.sh
Then:
<SET-ME> values install.sh reports in ~/.claude.json (MCP secrets are never committed).~/.config/mise/config.toml: "npm:optimaizr" = { version = "0.11.0", allow_low_downloads = true }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.
| Path | What |
|---|---|
mods/ | Mods loaded through CLAUDE_CODE_PLUGIN_DIRS (built by install.sh from this folder) |
plugins/plugins.json | Marketplaces 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.json | User MCP servers, secrets blanked |
settings/rulebook.json | Generic example rules (copied only when the machine has none; not captured) |
docs/specs/ | Design specs |
| Mod | Source | Local changes |
|---|---|---|
| hud | own | prompt hint, context weather bar, cache countdown, kaomoji mascot |
| rulebook | own | hard rules in ~/.claude/rulebook.json, /rules, mcp__rulebook__add_rule |
| xagent | own | dispatch/route tools for Codex, Copilot, Antigravity |
| handoff | own | /handoff [next] writes a handoff doc to ~/.claude/handoffs/ |
| agent-radar | hamzafer/claude-code-mods @ 87f108479bbf | /radar runs mid-turn (immediate) and toggles closed; shows xagent's external agents |
| mission-control | hamzafer/claude-code-mods @ 87f108479bbf | shows xagent's external agents; code-map view (Haiku diff summaries, headless Chrome) stripped |
| blast-radius | hamzafer/claude-code-mods @ 87f108479bbf | 60 s auto-cancel |
| review-watch | hamzafer/claude-code-mods @ 87f108479bbf | none |
| where-am-i | hamzafer/claude-code-mods @ 87f108479bbf | shows only "next" |
| secret-box | ray-amjad/claude-code-mods @ 467b20300d8e | none |
| task-board | MarcoCarnevali/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.
~/.claude/.credentials.json and MCP secrets.diagnose-crash, omarchy, symlinks into /usr/share/omarchy), themes/omarchy.json, the herdr SessionStart hook..claude-plugin/types/ in each mod: SDK types Claude Code writes itself.hooks/register.tsx 388 lines1// 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}
388types/index.d.ts 20 lines1export 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