SLOPSHOPPER

hud-band

claude-hud's session view drawn as a band above the prompt

newbandguard
v0.1.0NOASSERTIONupdated 2026-10-06minsubb13/claude-hud-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hud-band
› 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 [Opus 5.5] │ app Context █████░░░░░ 49% │ Usage ███░░░░░░░ 31% (5h) | ░░░░░░░░░░ -- (7d) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[Opus 5.5] │ app Context █████░░░░░ 49% │ Usage ███░░░░░░░ 31% (5h) | ░░░░░░░░░░ -- (7d)
README

hud-band

A Claude Code mod that shows the session's status (model, effort, context, usage, agents, todos) above the prompt.

[Opus 5.5 | high] │ claude-hud-mod
Context ██░░░░░░░░ 21% │ Usage █░░░░░░░░░ 8% (3h 20m / 5h) | ███░░░░░░░ 28% (52h / 7d)
◐ Explore [haiku]: Finding auth code (2m 15s)
▸ Fix authentication bug (2/5)

Add the repository path to env in ~/.claude/settings.json to load it in every session:

"env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-hud-mod" }

Run ./scripts/check.sh to validate, type-check and test it. Tested on Claude Code 2.1.287; the mod API is in early access, so other versions may break it.

Source 4 files
hooks/register.tsx 177 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Activity, AgentRun, Header } from '../types'
5import {
6  agentLines, applyTaskCreate, applyTaskUpdate, applyTodoWrite, settleAgents,
7  todoLine,
8} from './activity'
9import { gaugeLine, headerLine } from './format'
10import type { Line } from './format'
11
12const header = atom({ plugin: 'hud-band', key: 'header' } as const, null)
13const NO_ACTIVITY: Activity = { agents: [], todos: [] }
14const activity =
15    atom({ plugin: 'hud-band', key: 'activity' } as const, NO_ACTIVITY)
16
17let runSeq = 0
18
19function limit(limits: readonly SessionRateLimit[], kind: string) {
20  return limits.find(l => l.kind === kind)
21}
22
23// `effort` undefined keeps the last known effort; it arrives only with a
24// model request (turn.step), so it is unknown until the first one.
25async function refresh($: EngineInterface, effort?: string): Promise<void> {
26  const prev = await read($, header)
27  const usage = await $.session.usage()
28  const fiveHour = limit(usage.rateLimits, 'five_hour')
29  const sevenDay = limit(usage.rateLimits, 'seven_day')
30  const h: Header = {
31    model: await $.session.model(),
32    effort: effort ?? prev?.effort ?? null,
33    cwd: await $.session.cwd(),
34    ctx: usage.context.percent ?? null,
35    fiveHour: fiveHour?.percentUsed ?? null,
36    fiveHourResetsAt: fiveHour?.resetsAt ?? null,
37    sevenDay: sevenDay?.percentUsed ?? null,
38    sevenDayResetsAt: sevenDay?.resetsAt ?? null,
39  }
40  await update($, header, () => h)
41
42  const runs = (await read($, activity)).agents
43  if (runs.some(r => r.status === 'running' && r.id !== null)) {
44    const listed = await $.agent.list()
45    const now = await $.clock.now()
46    await update($, activity,
47        a => ({ ...a, agents: settleAgents(a.agents, listed, now) }))
48  }
49}
50
51async function updateRun(
52  $: EngineInterface,
53  key: string,
54  change: (r: AgentRun) => AgentRun | null,
55): Promise<void> {
56  await update($, activity, a => ({
57    ...a,
58    agents: a.agents.flatMap(r => {
59      if (r.key !== key) return [r]
60      const changed = change(r)
61      return changed ? [changed] : []
62    }),
63  }))
64}
65
66export const register: Register = on => {
67  on('session.start', async ($, e, next) => {
68    const r = await next(e)
69    await update($, activity, () => NO_ACTIVITY)
70    await refresh($)
71    return r
72  })
73
74  on('turn.step', async function* ($, e, next) {
75    await refresh($, e.effort === undefined ? undefined : String(e.effort))
76    return yield* next(e)
77  })
78
79  on('session.measure', async ($, e, next) => {
80    await refresh($)
81    return next(e)
82  })
83
84  // Only the main loop's calls count, as claude-hud reads the main
85  // transcript; every hook passes the call on untouched.
86  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
87    if (e.agentId !== undefined) return next(e)
88    const startMs = await $.clock.now()
89    const key = `${startMs}-${runSeq++}`
90    const run: AgentRun = {
91      key,
92      id: null,
93      type: e.subagent_type ?? 'general-purpose',
94      model: e.model ?? null,
95      description: e.description,
96      status: 'running',
97      startMs,
98      endMs: null,
99    }
100    await update($, activity, a => ({ ...a, agents: [...a.agents, run] }))
101
102    const ran = await next(e)
103    const result = ran.deny === undefined && !ran.isError ? ran.result : null
104    const status = result && 'status' in result ? String(result.status) : null
105    const id = result && 'agentId' in result ? result.agentId : null
106    if (id !== null && status === 'completed') {
107      const endMs = await $.clock.now()
108      await updateRun($, key, r => ({ ...r, id, status: 'completed', endMs }))
109    } else if (id !== null && status === 'async_launched') {
110      await updateRun($, key, r => ({ ...r, id }))
111    } else {
112      // Denied, failed or remote: no local run to follow.
113      await updateRun($, key, () => null)
114    }
115    return ran
116  })
117
118  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
119    const ran = await next(e)
120    if (e.agentId === undefined && ran.deny === undefined && !ran.isError) {
121      await update($, activity, a => ({ ...a, todos: applyTodoWrite(e.todos) }))
122    }
123    return ran
124  })
125
126  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
127    const ran = await next(e)
128    if (e.agentId === undefined && ran.deny === undefined && !ran.isError) {
129      const id = ran.result.task.id
130      await update($, activity,
131          a => ({ ...a, todos: applyTaskCreate(a.todos, id, e.subject) }))
132    }
133    return ran
134  })
135
136  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
137    const ran = await next(e)
138    if (e.agentId === undefined && ran.deny === undefined && !ran.isError &&
139        ran.result.success) {
140      const change = { status: e.status, subject: e.subject }
141      await update($, activity,
142          a => ({ ...a, todos: applyTaskUpdate(a.todos, e.taskId, change) }))
143    }
144    return ran
145  })
146
147  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
148    // Not `h`: JSX compiles to the global `h`, which a local would shadow.
149    const head = await read($, header)
150    if (e.props.hasSurvey || head === null) return next(e)
151
152    const { Box, Text } = $.ui.resolve(e)
153    const now = await $.clock.now()
154    const { agents, todos } = await read($, activity)
155    const todo = todoLine(todos)
156    const lines: Line[] = [
157      headerLine(head),
158      gaugeLine(head, now),
159      ...agentLines(agents, now),
160      ...(todo ? [todo] : []),
161    ]
162    return (
163      <Box flexDirection="column">
164        {lines.map((line, n) => (
165          <Box key={`line-${n}`} flexDirection="row">
166            {line.map((s, i) => (
167              <Text key={String(i)} color={s.color} dimColor={s.dim}>
168                {s.text}
169              </Text>
170            ))}
171          </Box>
172        ))}
173      </Box>
174    )
175  })
176}
177
hooks/activity.ts 104 lines
1// Activity the band tracks from tool calls, and its lines, following
2// claude-hud's render/agents-line.ts and render/todos-line.ts.
3
4import type { AgentRun, Todo } from '../types'
5import type { Line } from './format'
6
7const MAX_AGENTS = 3
8const RECENT_COMPLETED = 2
9
10// Applies the agent list's statuses: a run the list no longer reports as
11// running is marked completed at `nowMs`, the first time it is seen so.
12export function settleAgents(
13  runs: readonly AgentRun[],
14  listed: readonly { id: string; status: string }[],
15  nowMs: number,
16): AgentRun[] {
17  const status = new Map(listed.map(a => [a.id, a.status]))
18  return runs.map(r => {
19    if (r.status !== 'running' || r.id === null) return r
20    const s = status.get(r.id)
21    if (s === undefined || s === 'running') return r
22    return { ...r, status: 'completed', endMs: nowMs }
23  })
24}
25
26export function applyTodoWrite(
27  todos: readonly { content: string; status: Todo['status'] }[],
28): Todo[] {
29  return todos.map(t => ({ id: null, content: t.content, status: t.status }))
30}
31
32export function applyTaskCreate(
33  todos: readonly Todo[],
34  id: string,
35  subject: string,
36): Todo[] {
37  return [...todos, { id, content: subject, status: 'pending' }]
38}
39
40export function applyTaskUpdate(
41  todos: readonly Todo[],
42  id: string,
43  change: { status?: Todo['status'] | 'deleted'; subject?: string },
44): Todo[] {
45  if (change.status === 'deleted') return todos.filter(t => t.id !== id)
46  const status = change.status
47  return todos.map(t => t.id !== id ? t : {
48    ...t,
49    status: status ?? t.status,
50    content: change.subject ?? t.content,
51  })
52}
53
54function truncate(text: string, maxLen: number): string {
55  return text.length <= maxLen ? text : text.slice(0, maxLen - 3) + '...'
56}
57
58export function elapsed(startMs: number, endMs: number): string {
59  const ms = endMs - startMs
60  if (ms < 1000) return '<1s'
61  if (ms < 60000) return `${Math.round(ms / 1000)}s`
62  const mins = Math.floor(ms / 60000)
63  const secs = Math.round((ms % 60000) / 1000)
64  return `${mins}m ${secs}s`
65}
66
67export function agentLines(runs: readonly AgentRun[], nowMs: number): Line[] {
68  const running = runs.filter(r => r.status === 'running')
69  const completed = runs.filter(r => r.status === 'completed')
70      .slice(-RECENT_COMPLETED)
71  return [...running, ...completed].slice(-MAX_AGENTS).map(r => {
72    const line: Line = r.status === 'running'
73        ? [{ text: '◐', color: 'yellow' }]
74        : [{ text: '✓', color: 'green' }]
75    line.push({ text: ' ' }, { text: r.type, color: 'magenta' })
76    if (r.model) line.push({ text: ' ' }, { text: `[${r.model}]`, dim: true })
77    if (r.description) {
78      line.push({ text: `: ${truncate(r.description, 40)}`, dim: true })
79    }
80    line.push({ text: ' ' },
81        { text: `(${elapsed(r.startMs, r.endMs ?? nowMs)})`, dim: true })
82    return line
83  })
84}
85
86export function todoLine(todos: readonly Todo[]): Line | null {
87  if (todos.length === 0) return null
88  const completed = todos.filter(t => t.status === 'completed').length
89  const progress = { text: ` (${completed}/${todos.length})`, dim: true }
90  const current = todos.find(t => t.status === 'in_progress')
91  if (current) {
92    return [
93      { text: '▸', color: 'yellow' },
94      { text: ` ${truncate(current.content, 50)}` },
95      progress,
96    ]
97  }
98  if (completed === todos.length) {
99    return [{ text: '✓', color: 'green' }, { text: ' All todos complete' },
100        progress]
101  }
102  return null
103}
104
hooks/format.ts 112 lines
1// Pure formatting for the band, following claude-hud's render rules
2// (render/colors.ts, render/lines/project.ts, identity.ts, usage.ts).
3
4import type { Header } from '../types'
5
6export type Segment = { text: string; color?: string; dim?: boolean }
7export type Line = Segment[]
8
9const BAR_WIDTH = 10
10
11export function plain(line: Line): string {
12  return line.map(s => s.text).join('')
13}
14
15// `claude-opus-5-5[1m]` -> `Opus 5.5`, `claude-haiku-4-5-20251001` ->
16// `Haiku 4.5`; anything else is shown as given.
17export function modelName(id: string): string {
18  const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?:\[[^\]]*\])?$/
19      .exec(id)
20  if (!m || !m[1] || !m[2]) return id
21  const family = m[1].charAt(0).toUpperCase() + m[1].slice(1)
22  return m[3] ? `${family} ${m[2]}.${m[3]}` : `${family} ${m[2]}`
23}
24
25export function pathTail(cwd: string): string {
26  const segments = cwd.split(/[/\\]/).filter(Boolean)
27  return segments.length > 0 ? segments[segments.length - 1]! : '/'
28}
29
30export function contextColor(percent: number): string {
31  if (percent >= 85) return 'red'
32  if (percent >= 70) return 'yellow'
33  return 'green'
34}
35
36export function quotaColor(percent: number): string {
37  if (percent >= 90) return 'red'
38  if (percent >= 75) return 'magentaBright'
39  return 'blueBright'
40}
41
42function clampPercent(percent: number): number {
43  return Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0
44}
45
46export function bar(percent: number, color: string): Segment[] {
47  const filled = Math.round((clampPercent(percent) / 100) * BAR_WIDTH)
48  return [
49    { text: '█'.repeat(filled), color },
50    { text: '░'.repeat(BAR_WIDTH - filled), dim: true },
51  ]
52}
53
54// Time left until `resetsAt`, as claude-hud prints it: `45m`, `3h 20m`,
55// `52h`; empty when unknown or already past.
56export function resetIn(resetsAt: string | null, nowMs: number): string {
57  if (!resetsAt) return ''
58  const diffMs = Date.parse(resetsAt) - nowMs
59  if (!Number.isFinite(diffMs) || diffMs <= 0) return ''
60  const mins = Math.ceil(diffMs / 60000)
61  if (mins < 60) return `${mins}m`
62  const hours = Math.floor(mins / 60)
63  const rest = mins % 60
64  return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`
65}
66
67function quota(
68  percent: number | null,
69  resetsAt: string | null,
70  window: string,
71  nowMs: number,
72): Segment[] {
73  const shown: Segment = percent === null
74    ? { text: '--', dim: true }
75    : { text: `${percent}%`, color: contextColor(percent) }
76  const reset = resetIn(resetsAt, nowMs)
77  return [
78    ...bar(percent ?? 0, quotaColor(percent ?? 0)),
79    { text: ' ' },
80    shown,
81    { text: reset ? ` (${reset} / ${window})` : ` (${window})` },
82  ]
83}
84
85export function headerLine(h: Header): Line {
86  const tag = h.effort
87      ? `[${modelName(h.model)} | ${h.effort}]`
88      : `[${modelName(h.model)}]`
89  return [
90    { text: tag, color: 'cyan' },
91    { text: ' │ ' },
92    { text: pathTail(h.cwd), color: 'yellow' },
93  ]
94}
95
96export function gaugeLine(h: Header, nowMs: number): Line {
97  const ctx = h.ctx ?? 0
98  return [
99    { text: 'Context', dim: true },
100    { text: ' ' },
101    ...bar(ctx, contextColor(ctx)),
102    { text: ' ' },
103    { text: h.ctx === null ? '--' : `${h.ctx}%`, color: contextColor(ctx) },
104    { text: ' │ ' },
105    { text: 'Usage', dim: true },
106    { text: ' ' },
107    ...quota(h.fiveHour, h.fiveHourResetsAt, '5h', nowMs),
108    { text: ' | ' },
109    ...quota(h.sevenDay, h.sevenDayResetsAt, '7d', nowMs),
110  ]
111}
112
types/index.d.ts 39 lines
1export type Header = {
2  model: string
3  effort: string | null
4  cwd: string
5  ctx: number | null
6  fiveHour: number | null
7  fiveHourResetsAt: string | null
8  sevenDay: number | null
9  sevenDayResetsAt: string | null
10}
11
12export type AgentRun = {
13  // Tells concurrent runs apart before the Agent call returns.
14  key: string
15  // The agent id once the Agent call returned one; null while it starts.
16  id: string | null
17  type: string
18  model: string | null
19  description: string
20  status: 'running' | 'completed'
21  startMs: number
22  endMs: number | null
23}
24
25export type Todo = {
26  // The task id for TaskCreate todos; null for TodoWrite ones.
27  id: string | null
28  content: string
29  status: 'pending' | 'in_progress' | 'completed'
30}
31
32export type Activity = { agents: AgentRun[]; todos: Todo[] }
33
34declare module 'claude-code' {
35  interface PluginState {
36    'hud-band': { header: Header | null; activity: Activity }
37  }
38}
39