SLOPSHOPPER

hud

One-line session hud under the prompt: dir, branch, context, 5h/weekly usage limits with reset countdowns, running agents, model. Tokyo Night colours; a Nerd…

newspinnerprocesstimer
v0.1.0MITupdated 2026-10-24Visal-Sok/claude-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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › [/work/app: feat/auth-refresh*] │ Ctx ▰▰▱▱▱ 49% │ 5H ▰▰▱▱▱ 31% │ Agents 0 Opus 5.5 ⟨Claude Code's own drawing⟩

Draws

Prompt hint
[/work/app: feat/auth-refresh*] │ Ctx ▰▰▱▱▱ 49% │ 5H ▰▰▱▱▱ 31% │ Agents 0 ⟨Claude Code's own drawing⟩
README

hud

A one-line hud under the Claude Code prompt, in the spot the status line used to be:

[~/dotfiles: main*] │ Ctx ▰▱▱▱▱ 21% │ 5H ▰▰▰▱▱ 64% ↻ 2h13m │ Weekly ▰▱▱▱▱ 12% ↻ 3d4h │ Agents 2      󰃀 Opus 5.5 1M
PartWhat
[dir: branch*]working directory (~ for home), git branch, * when the tree has changes
Ctxcontext window used
5H, Weeklyusage limits, green → yellow at 50% → red at 80%, with the time until each resets
Agentssubagents running now (yellow when any are)
modelthe session's model, shortened (Opus 5.5 1M)
󰃀toggles the later pane; shown only when the later plugin is installed

It refreshes every 5 seconds and after every turn. Colours are Tokyo Night; the 󰃀 and meter glyphs need a Nerd Font in your terminal.

Install

/plugin marketplace add Visal-Sok/claude-mods
/plugin install hud@claude-mods

Optional, for the 󰃀 button: /plugin install later@claude-mods.

License

MIT

Source 2 files
hooks/register.tsx 195 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Hud, HudMeter } from '../types'
5
6const REFRESH_MS = 5000
7const BAR_CELLS = 5
8const LIVE_AGENT = new Set(['pending', 'running', 'waiting'])
9// The mod whose /later the pane button runs; the button shows only while it is installed
10const LATER_PLUGIN = 'later'
11const MINUTE = 60_000
12const HOUR = 60 * MINUTE
13const DAY = 24 * HOUR
14// The hud line's width: the terminal's, less the footer's indent and a margin.
15const DEFAULT_COLUMNS = 120
16const EDGE_COLUMNS = 4
17const MIN_COLUMNS = 40
18
19// Tokyo Night, matching the tmux popups and the later pane.
20const THEME = {
21  fg: '#c0caf5',
22  muted: '#565f89',
23  border: '#3b4261',
24  blue: '#7aa2f7',
25  cyan: '#7dcfff',
26  green: '#9ece6a',
27  yellow: '#e0af68',
28  red: '#f7768e',
29  purple: '#bb9af7',
30} as const
31
32const hud = atom({ plugin: 'hud', key: 'hud' } as const, null)
33
34// "claude-opus-5-5[1m]" or "Opus 5.5 (1M context)" -> "Opus 5.5 1M"
35export function shortModel(model: string): string {
36  const id = model.match(/^claude-([a-z]+)-(\d+)-(\d+)(?:\[(\w+)\])?/i)
37  if (id) {
38    const [, family = '', major, minor, window] = id
39    const name = `${family.charAt(0).toUpperCase()}${family.slice(1)} ${major}.${minor}`
40    return window ? `${name} ${window.toUpperCase()}` : name
41  }
42  return model.replace(/\s*\((\S+) context\)/i, ' $1').trim()
43}
44
45export function meterColor(percent: number): string {
46  if (percent >= 80) return THEME.red
47  if (percent >= 50) return THEME.yellow
48  return THEME.green
49}
50
51export function bar(percent: number): string {
52  const filled = Math.max(0, Math.min(BAR_CELLS, Math.round((percent / 100) * BAR_CELLS)))
53  return '▰'.repeat(filled) + '▱'.repeat(BAR_CELLS - filled)
54}
55
56export function tildePath(path: string, home: string | undefined): string {
57  if (home && (path === home || path.startsWith(`${home}/`))) return `~${path.slice(home.length)}`
58  return path
59}
60
61// Time until a reset, two units at most: "2h13m", "3d4h", "9m".
62export function countdown(ms: number): string {
63  if (ms <= 0) return 'now'
64  if (ms >= DAY) return `${Math.floor(ms / DAY)}d${Math.floor((ms % DAY) / HOUR)}h`
65  if (ms >= HOUR) return `${Math.floor(ms / HOUR)}h${Math.floor((ms % HOUR) / MINUTE)}m`
66  return `${Math.max(1, Math.floor(ms / MINUTE))}m`
67}
68
69async function git($: EngineInterface, args: string[]): Promise<string | null> {
70  try {
71    const run = await $.process.run(['git', ...args])
72    return run.exitCode === 0 ? run.stdout.trim() : null
73  } catch {
74    return null
75  }
76}
77
78async function refresh($: EngineInterface) {
79  const [model, usage, cwd, home, agents, commands, branch, status] = await Promise.all([
80    $.session.model(),
81    $.session.usage(),
82    $.session.cwd(),
83    $.env.get('HOME'),
84    $.agent.list(),
85    $.command.list(),
86    git($, ['branch', '--show-current']),
87    git($, ['status', '--porcelain']),
88  ])
89  const limit = (kind: string): HudMeter | null => {
90    const window = usage.rateLimits.find(one => one.kind === kind)
91    if (window === undefined) return null
92    const resetsAt = window.resetsAt ? Date.parse(window.resetsAt) : NaN
93    return { percent: window.percentUsed, resetsAt: Number.isNaN(resetsAt) ? null : resetsAt }
94  }
95  const next: Hud = {
96    model: shortModel(model),
97    dir: tildePath(cwd, home),
98    branch: branch ?? '',
99    isDirty: (status ?? '') !== '',
100    contextPercent: usage.context.percent ?? null,
101    fiveHour: limit('five_hour'),
102    weekly: limit('seven_day'),
103    runningAgents: agents.filter(agent => LIVE_AGENT.has(agent.status)).length,
104    hasLater: commands.some(command => command.name === 'later' && command.plugin === LATER_PLUGIN),
105  }
106  await update($, hud, () => next)
107}
108
109async function refreshQuietly($: EngineInterface) {
110  try {
111    await refresh($)
112  } catch (error) {
113    $.ui.log(`hud: ${(error as Error).message}`)
114  }
115}
116
117export const register: Register = on => {
118  on('session.start', async ($, e, next) => {
119    await refreshQuietly($)
120    $.clock.every(REFRESH_MS, () => void refreshQuietly($))
121    return next(e)
122  })
123
124  on('turn.complete', async ($, e, next) => {
125    const result = await next(e)
126    await refreshQuietly($)
127    return result
128  })
129
130  // Draws in the old status line's place: the hud, then the engine's hint row.
131  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
132    const hint = await next(e)
133    const state = await read($, hud)
134    if (state === null) return hint
135
136    const { Box, Button, Text } = $.ui.resolve(e)
137    const now = await $.clock.now()
138    const width = Math.max(MIN_COLUMNS, (e.viewport?.columns ?? DEFAULT_COLUMNS) - EDGE_COLUMNS)
139    const sep = <Text color={THEME.border}>{' │ '}</Text>
140    const meter = (label: string, percent: number | null, resetsAt: number | null = null) =>
141      percent !== null && (
142        <Text>
143          {sep}
144          <Text color={THEME.muted}>{`${label} `}</Text>
145          <Text color={meterColor(percent)}>{`${bar(percent)} ${Math.round(percent)}%`}</Text>
146          {resetsAt !== null && <Text color={THEME.muted}>{` ↻ ${countdown(resetsAt - now)}`}</Text>}
147        </Text>
148      )
149    const agents = state.runningAgents
150
151    return (
152      <Box flexDirection="column">
153        <Box flexDirection="row" justifyContent="space-between" gap={2} width={width}>
154          <Box flexShrink={1}>
155            <Text wrap="truncate-end">
156              <Text color={THEME.muted}>{'['}</Text>
157              <Text color={THEME.cyan}>{state.dir}</Text>
158              {state.branch !== '' && (
159                <Text>
160                  <Text color={THEME.muted}>{': '}</Text>
161                  <Text color={THEME.blue}>{state.branch}</Text>
162                  {state.isDirty && <Text color={THEME.yellow}>{'*'}</Text>}
163                </Text>
164              )}
165              <Text color={THEME.muted}>{']'}</Text>
166              {meter('Ctx', state.contextPercent)}
167              {meter('5H', state.fiveHour?.percent ?? null, state.fiveHour?.resetsAt)}
168              {meter('Weekly', state.weekly?.percent ?? null, state.weekly?.resetsAt)}
169              {sep}
170              <Text color={THEME.muted}>{'Agents '}</Text>
171              <Text color={agents > 0 ? THEME.yellow : THEME.muted} bold={agents > 0}>
172                {String(agents)}
173              </Text>
174            </Text>
175          </Box>
176          <Box key="actions" flexDirection="row" flexShrink={0} gap={1}>
177            {state.hasLater && (
178              <Button
179                key="later"
180                plain
181                hover={{ color: THEME.blue }}
182                onPress={() => $.command.run({ command: 'later' })}
183              >
184                <Text color={THEME.purple}>{'󰃀'}</Text>
185              </Button>
186            )}
187            <Text color={THEME.purple} bold>{state.model}</Text>
188          </Box>
189        </Box>
190        {hint}
191      </Box>
192    )
193  })
194}
195
types/index.d.ts 21 lines
1export type HudMeter = { percent: number; resetsAt: number | null }
2
3export type Hud = {
4  model: string
5  dir: string
6  branch: string
7  isDirty: boolean
8  contextPercent: number | null
9  fiveHour: HudMeter | null
10  weekly: HudMeter | null
11  runningAgents: number
12  // The later plugin is installed, so the pane button has a /later to run
13  hasLater: boolean
14}
15
16declare module 'claude-code' {
17  interface PluginState {
18    hud: { hud: Hud | null }
19  }
20}
21