A slim Tokyo Night session HUD: activity, context, limits, disk and modified files

Personal macOS development environment, managed with GNU Stow for symlink deployment.
# 1. Clone into ~/dotfiles
git clone git@github.com:jmartinn/dotfiles.git ~/dotfiles && cd ~/dotfiles
# 2. Bootstrap (installs Homebrew, runs all setup scripts)
./scripts/run
# 3. Deploy symlinks
stow .
Once just is installed (from step 2), prefer just <verb> for everyday tasks — see just --list.
.config/ Application configs (nvim, ghostty, tmux, aerospace, …)
.zshrc Shell config
brew/Brewfile Homebrew package definitions
scripts/ Setup orchestration
run Entrypoint, supports filter + --dry
runs/ Modular setup scripts (homebrew, fonts, ssh-gpg, …)
Justfile Day-to-day task runner
CLAUDE.md Guidance for Claude Code sessions
| Command | What it does |
|---|---|
just bootstrap | Run full machine setup |
just stow | Deploy symlinks |
just check | Dry-run stow (see what would change) |
just update | brew update + upgrade + bundle install |
just brew-dump | Refresh brew/Brewfile from current state |
just clean | Clear caches / regenerable tool dirs |
Herdr is installed with its official direct installer into ~/.local/bin, not Homebrew. The bootstrap script installs it only when missing, so it does not update or restart a live session.
When ready to update, detach from the UI and run:
herdr update
just herdr-refresh
just herdr-check
herdr-refresh reinstalls the version-matched Claude/Codex session integrations and regenerates the user-level Herdr skill. It does not stop the server. If an update requires a protocol restart, handle that interactively after confirming no pane process would be lost. tmux remains installed during the Herdr adoption period.
On a Mac dedicated to remote agent work, opt in to the GUI-domain LaunchAgent after signing in to GitHub, Claude, and Codex from that Mac:
just herdr-host-install
just herdr-host-status
The GUI launch domain is intentional: it lets Herdr's agent processes use credentials stored in the login Keychain while remote SSH sessions attach as thin clients. The service starts at GUI login, restarts after crashes, and stays stopped after an intentional just herdr-host-stop. Use just herdr-host-start to start it again. FileVault still requires a local unlock after a cold boot before the service can start.
See .config/nvim/LICENSE for the Neovim config; the rest is MIT-style "do what you want."
hooks/register.tsx 422 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Activity, Change, Disk, Tokens, Usage } from '../types'
5
6const PANE = 'hud'
7const TITLE = 'HUD'
8const WIDTH = 34
9
10// Tokyo Night: one accent (blue), status colors only where they carry meaning.
11const C = {
12 bg: '#1a1b26',
13 fg: '#c0caf5',
14 sub: '#a9b1d6',
15 dim: '#565f89',
16 rule: '#3b4261',
17 blue: '#7aa2f7',
18 green: '#9ece6a',
19 yellow: '#e0af68',
20 red: '#f7768e',
21 magenta: '#bb9af7',
22}
23
24const active = atom({ plugin: 'hud', key: 'active' } as const, [] as Activity[])
25const changes = atom({ plugin: 'hud', key: 'changes' } as const, {} as Record<string, Change>)
26const tokens = atom(
27 { plugin: 'hud', key: 'tokens' } as const,
28 {
29 input: 0,
30 output: 0,
31 cacheRead: 0,
32 cacheWrite: 0,
33 } as Tokens,
34)
35const turns = atom({ plugin: 'hud', key: 'turns' } as const, [] as number[])
36const usage = atom({ plugin: 'hud', key: 'usage' } as const, null as Usage | null)
37const disk = atom({ plugin: 'hud', key: 'disk' } as const, null as Disk | null)
38const cwd = atom({ plugin: 'hud', key: 'cwd' } as const, '')
39const now = atom({ plugin: 'hud', key: 'now' } as const, 0)
40
41const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
42const SPARKS = '▁▂▃▄▅▆▇█'
43
44function str(value: unknown): string {
45 return typeof value === 'string' ? value : ''
46}
47
48function base(path: string): string {
49 return path.split('/').filter(Boolean).pop() ?? path
50}
51
52function lines(text: string): number {
53 return text === '' ? 0 : text.split('\n').length
54}
55
56/** One short line saying what a call is about. */
57function targetOf(tool: string, input: Record<string, unknown>): string {
58 switch (tool) {
59 case 'Bash':
60 return str(input.command).split('\n')[0] ?? ''
61 case 'Read':
62 case 'Edit':
63 case 'Write':
64 case 'MultiEdit':
65 case 'NotebookEdit':
66 return base(str(input.file_path) || str(input.notebook_path))
67 case 'Grep':
68 case 'Glob':
69 return str(input.pattern)
70 case 'Agent':
71 case 'Task':
72 return str(input.description)
73 case 'WebFetch':
74 return (
75 str(input.url)
76 .replace(/^https?:\/\//, '')
77 .split('/')[0] ?? ''
78 )
79 case 'WebSearch':
80 return str(input.query)
81 case 'Skill':
82 return str(input.skill)
83 default:
84 return ''
85 }
86}
87
88/** Lines an edit adds and removes, as its input states them. */
89function changeOf(tool: string, input: Record<string, unknown>): Change | null {
90 if (tool === 'Edit') {
91 return {
92 added: lines(str(input.new_string)),
93 removed: lines(str(input.old_string)),
94 }
95 }
96 if (tool === 'Write') return { added: lines(str(input.content)), removed: 0 }
97 if (tool === 'MultiEdit' && Array.isArray(input.edits)) {
98 return (input.edits as Record<string, unknown>[]).reduce<Change>(
99 (sum, one) => ({
100 added: sum.added + lines(str(one.new_string)),
101 removed: sum.removed + lines(str(one.old_string)),
102 }),
103 { added: 0, removed: 0 },
104 )
105 }
106 return null
107}
108
109function compact(n: number): string {
110 if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
111 if (n >= 1e3) return `${Math.round(n / 1e3)}k`
112 return String(n)
113}
114
115function elapsed(ms: number): string {
116 const s = Math.max(0, Math.floor(ms / 1000))
117 if (s < 60) return `${s}s`
118 const m = Math.floor(s / 60)
119 if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
120 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
121}
122
123function heat(percent: number): string {
124 return percent >= 85 ? C.red : percent >= 60 ? C.yellow : C.green
125}
126
127function sparkline(values: number[]): string {
128 const max = Math.max(...values, 1)
129 return values.map(v => SPARKS[Math.min(7, Math.floor((v / max) * 7))]).join('')
130}
131
132function limitLabel(kind: string): string {
133 if (kind === 'five_hour') return '5h'
134 if (kind === 'seven_day') return '7d'
135 return kind.replace(/_/g, ' ')
136}
137
138/** `claude-opus-5-5` reads as `opus 5.5`. */
139function modelName(id: string): string {
140 return id
141 .replace(/^claude-/, '')
142 .replace(/-(\d+)-(\d+)(-.*)?$/, ' $1.$2')
143 .replace(/-(\d+)$/, ' $1')
144}
145
146function relative(path: string, root: string): string {
147 return root !== '' && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : base(path)
148}
149
150async function refreshUsage($: EngineInterface): Promise<void> {
151 const [u, model, t] = await Promise.all([$.session.usage(), $.session.model(), $.clock.now()])
152 await update($, usage, () => ({
153 model,
154 startedAt: u.startedAt,
155 contextTokens: u.context.tokens ?? null,
156 contextWindow: u.context.window,
157 contextPercent: u.context.percent ?? null,
158 limits: u.rateLimits.map(l => ({
159 kind: l.kind,
160 percentUsed: l.percentUsed,
161 })),
162 }))
163 await update($, now, () => t)
164}
165
166async function refreshDisk($: EngineInterface): Promise<void> {
167 // The writable APFS volume; `/` is the sealed system snapshot.
168 const { exitCode, stdout } = await $.process.run(['df', '-k', '/System/Volumes/Data'])
169 if (exitCode !== 0) return
170 const fields = stdout.trim().split('\n').pop()?.split(/\s+/) ?? []
171 const used = Number(fields[2])
172 const available = Number(fields[3])
173 if (!Number.isFinite(used) || !Number.isFinite(available)) return
174 await update($, disk, () => ({
175 usedPercent: Math.round((used / (used + available)) * 100),
176 freeGb: Math.round((available * 1024) / 1e9),
177 }))
178}
179
180export const register: Register = on => {
181 on('session.start', async ($, e, next) => {
182 await $.command.register({
183 name: 'hud',
184 description: 'Toggle the session HUD',
185 })
186 const root = await $.session.cwd()
187 await update($, cwd, () => root)
188 void refreshUsage($)
189 void refreshDisk($)
190 $.clock.every(5_000, () => void refreshUsage($))
191 $.clock.every(60_000, () => void refreshDisk($))
192 // Fast ticks only while something runs: the spinner turns, idle stays still.
193 $.clock.every(150, () => {
194 void (async () => {
195 if ((await read($, active)).length > 0) {
196 const t = await $.clock.now()
197 await update($, now, () => t)
198 }
199 })()
200 })
201
202 return next(e)
203 })
204
205 on('command.run', { command: 'hud' }, async $ => {
206 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
207 if (isOpen) {
208 await $.ui.close({ id: PANE })
209 return { text: 'closed' }
210 }
211
212 await $.ui.open({ id: PANE, title: TITLE, columns: WIDTH + 2, rows: 14 })
213 return { text: 'opened' }
214 })
215
216 on('tool.call', async ($, e, next) => {
217 const tool = String(e.tool)
218 const input = e as unknown as Record<string, unknown>
219 const startedAt = await $.clock.now()
220 const call: Activity = {
221 id: e.tool_use_id,
222 tool,
223 target: targetOf(tool, input),
224 startedAt,
225 isAgent: e.agentId !== undefined,
226 }
227 await update($, active, list => [...list, call])
228 await update($, now, () => startedAt)
229
230 try {
231 const ran = await next(e)
232 const change = changeOf(tool, input)
233 const path = str(input.file_path)
234 if (ran.deny === undefined && ran.isError !== true && change !== null && path !== '') {
235 await update($, changes, all => {
236 const was = all[path] ?? { added: 0, removed: 0 }
237 return {
238 ...all,
239 [path]: {
240 added: was.added + change.added,
241 removed: was.removed + change.removed,
242 },
243 }
244 })
245 }
246
247 return ran
248 } finally {
249 await update($, active, list => list.filter(one => one.id !== call.id))
250 }
251 })
252
253 on('turn.complete', async ($, e, next) => {
254 const spent = e.usage
255 if (spent !== undefined) {
256 await update($, tokens, t => ({
257 input: t.input + spent.input_tokens,
258 output: t.output + spent.output_tokens,
259 cacheRead: t.cacheRead + spent.cache_read_input_tokens,
260 cacheWrite: t.cacheWrite + spent.cache_creation_input_tokens,
261 }))
262 }
263 if (e.agentId === undefined && !e.isAborted) {
264 await update($, turns, list => [...list, e.durationMs].slice(-40))
265 }
266 void refreshUsage($)
267
268 return next(e)
269 })
270
271 // /hud: a narrow sidebar of aligned rows, quiet until something needs a look.
272 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
273 const { Box, Text } = $.ui.resolve(e)
274 const width = Math.max(24, Math.min(WIDTH, e.props.bodyColumns))
275 const [list, files, tok, durations, u, d, root, tick] = await Promise.all([
276 read($, active),
277 read($, changes),
278 read($, tokens),
279 read($, turns),
280 read($, usage),
281 read($, disk),
282 read($, cwd),
283 read($, now),
284 ])
285
286 const row = (label: string, value: JSX.Element) => (
287 <Box>
288 <Box width={9}>
289 <Text color={C.dim}>{label}</Text>
290 </Box>
291 {value}
292 </Box>
293 )
294 const gap = <Text> </Text>
295
296 const pct = u?.contextPercent ?? null
297 const cells = width - 9 - 5
298 const lit = pct === null ? 0 : Math.round((Math.min(100, pct) / 100) * cells)
299 const totalIn = tok.input + tok.cacheRead + tok.cacheWrite
300 const hit = totalIn > 0 ? Math.round((tok.cacheRead / totalIn) * 100) : null
301 const fileRows = Object.entries(files)
302 .sort((a, b) => b[1].added + b[1].removed - (a[1].added + a[1].removed))
303 .slice(0, 6)
304 const spin = SPINNER[Math.floor(tick / 150) % SPINNER.length]
305
306 const isDocked = e.props.placement === 'dock'
307
308 return (
309 <Box
310 flexDirection="column"
311 width={isDocked ? e.props.bodyColumns : width}
312 minHeight={isDocked ? e.props.scroll.bodyRows : undefined}
313 backgroundColor={isDocked ? C.bg : undefined}
314 >
315 <Box flexDirection="column" width={width}>
316 <Box justifyContent="space-between">
317 <Text color={C.fg} bold>
318 {u === null ? 'claude' : modelName(u.model)}
319 </Text>
320 <Text color={C.dim}>
321 {u === null ? '' : elapsed(Math.max(tick, u.startedAt) - u.startedAt)}
322 </Text>
323 </Box>
324 <Text color={C.rule}>{'─'.repeat(width)}</Text>
325
326 {list.length === 0 ? (
327 <Text color={C.dim}>idle</Text>
328 ) : (
329 list.slice(-4).map((call, i) => (
330 <Box justifyContent="space-between">
331 <Text wrap="truncate-end">
332 <Text color={i === 0 ? C.blue : C.dim}>{i === 0 ? spin : '·'} </Text>
333 <Text color={call.isAgent ? C.magenta : C.sub}>{call.tool.toLowerCase()} </Text>
334 <Text color={C.dim}>{call.target}</Text>
335 </Text>
336 <Text color={C.dim}>
337 {' '}
338 {elapsed(Math.max(tick, call.startedAt) - call.startedAt)}
339 </Text>
340 </Box>
341 ))
342 )}
343 {gap}
344
345 {row(
346 'context',
347 pct === null ? (
348 <Text color={C.dim}>…</Text>
349 ) : (
350 <Text>
351 <Text color={pct >= 60 ? heat(pct) : C.blue}>{'━'.repeat(lit)}</Text>
352 <Text color={C.rule}>{'━'.repeat(cells - lit)}</Text>
353 <Text color={pct >= 60 ? heat(pct) : C.sub}> {String(pct).padStart(3)}%</Text>
354 </Text>
355 ),
356 )}
357 {u !== null &&
358 u.contextTokens !== null &&
359 row(
360 '',
361 <Text color={C.dim}>
362 {compact(u.contextTokens)} of {compact(u.contextWindow)}
363 </Text>,
364 )}
365 {hit !== null &&
366 row(
367 'cache',
368 <Text color={C.sub}>
369 {hit}% <Text color={C.dim}>hit</Text>
370 </Text>,
371 )}
372 {u !== null &&
373 u.limits.length > 0 &&
374 row(
375 'limits',
376 <Text>
377 {u.limits.map((l, i) => (
378 <Text>
379 {i > 0 && <Text>{' '}</Text>}
380 <Text color={C.dim}>{limitLabel(l.kind)} </Text>
381 <Text color={l.percentUsed >= 60 ? heat(l.percentUsed) : C.sub}>
382 {Math.round(l.percentUsed)}%
383 </Text>
384 </Text>
385 ))}
386 </Text>,
387 )}
388 {durations.length > 1 &&
389 row(
390 'turns',
391 <Text>
392 <Text color={C.blue}>{sparkline(durations.slice(-(width - 9 - 6)))}</Text>
393 <Text color={C.dim}> {elapsed(durations[durations.length - 1] ?? 0)}</Text>
394 </Text>,
395 )}
396 {d !== null &&
397 row(
398 'disk',
399 <Text color={d.usedPercent >= 80 ? heat(d.usedPercent) : C.sub}>
400 {d.freeGb} GB <Text color={C.dim}>free</Text>
401 </Text>,
402 )}
403
404 {fileRows.length > 0 && gap}
405 {fileRows.length > 0 && <Text color={C.dim}>modified</Text>}
406 {fileRows.map(([path, change]) => (
407 <Box justifyContent="space-between">
408 <Text color={C.sub} wrap="truncate-start">
409 {relative(path, root)}
410 </Text>
411 <Text>
412 {change.added > 0 && <Text color={C.green}> +{change.added}</Text>}
413 {change.removed > 0 && <Text color={C.red}> −{change.removed}</Text>}
414 </Text>
415 </Box>
416 ))}
417 </Box>
418 </Box>
419 )
420 })
421}
422types/index.d.ts 32 lines1export type Activity = { id: string; tool: string; target: string; startedAt: number; isAgent: boolean }
2
3export type Change = { added: number; removed: number }
4
5export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
6
7export type Usage = {
8 model: string
9 startedAt: number
10 contextTokens: number | null
11 contextWindow: number
12 contextPercent: number | null
13 limits: { kind: string; percentUsed: number }[]
14}
15
16export type Disk = { usedPercent: number; freeGb: number }
17
18declare module 'claude-code' {
19 interface PluginState {
20 hud: {
21 active: Activity[]
22 changes: Record<string, Change>
23 tokens: Tokens
24 turns: number[]
25 usage: Usage | null
26 disk: Disk | null
27 cwd: string
28 now: number
29 }
30 }
31}
32