SLOPSHOPPER

hud

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

newpaneguardcommandprocesstimer
v0.1.0no licenseupdated 2026-10-05jmartinn/dotfiles/.claude/mods/hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hud
│ ┃ HUD ✕ › fix the failing auth test and add an audit log call │ ┃ opus 5.5 30m00s │ ┃ ────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ idle ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ context ━━━━━━━━━━━━━━━━━━━━ 49% ⎿ Added 2 lines, removed 1 line │ ┃ 97k of 200k ⏺ Bash(bun test) │ ┃ cache 93% hit ⎿ 3 pass, 1 fail │ ┃ limits 5h 31% │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ modified │ ┃ src/cache.ts +8 ✻ Worked for 42s · done 4:20 PM │ ┃ src/audit.ts +6 │ ┃ src/auth.ts +2 −1 › /hud │ ┃ ⎿ hud: opened │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · HUD
opus 5.5 30m00s ────────────────────────────────── idle context ━━━━━━━━━━━━━━━━━━━━ 49% 97k of 200k cache 93% hit limits 5h 31% modified src/cache.ts +8 src/audit.ts +6 src/auth.ts +2 −1
README

dotfiles

Personal macOS development environment, managed with GNU Stow for symlink deployment.

What's in here

  • Shell — zsh + Starship prompt
  • Terminal — Ghostty (primary) + tmux + Herdr
  • Editor — Neovim with a Lua-based config
  • WM — AeroSpace tiling window manager
  • Tooling — Homebrew for packages, fnm for Node, Bun for JS runtime, Just for task running

Fresh-machine setup

# 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.

Layout

.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

Common tasks

CommandWhat it does
just bootstrapRun full machine setup
just stowDeploy symlinks
just checkDry-run stow (see what would change)
just updatebrew update + upgrade + bundle install
just brew-dumpRefresh brew/Brewfile from current state
just cleanClear caches / regenerable tool dirs

Herdr

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.

Persistent agent host

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.

License

See .config/nvim/LICENSE for the Neovim config; the rest is MIT-style "do what you want."

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