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

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.
hooks/register.tsx 177 lines1import { 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}
177hooks/activity.ts 104 lines1// 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}
104hooks/format.ts 112 lines1// 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}
112types/index.d.ts 39 lines1export 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