SLOPSHOPPER

devhud

cost, context and git hud with an ember theme

newpanebandguardcommandstatus
v0.1.0no licenseupdated 2026-10-04codywilliamson/claude-mods/devhud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · devhud
│ ┃ HUD ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ╭────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ │ ⎿ Read 6 lines │ ┃ │ SESSION COST │ ⏺ Update(src/auth.ts) │ ┃ │ $0.420 │ ⎿ Added 2 lines, removed 1 line │ ┃ │ five_hour 31% │ ⏺ 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 │ ┃ │ CONTEXT │ │ ┃ │ ██████████░░░░░░░░░░ 49% │ › /hud │ ┃ │ 97k / 200k tokens │ ⎿ devhud: HUD opened. │ ┃ │ │ │ ┃ ╰────────────────────────────────────────╯ │ ┃ │ ┃ ╭────────────────────────────────────────╮ │ ┃ │ │ │ ┃ │ GIT │ │ ┃ │ ? │ │ ┃ │ +0 staged · ~0 changed · ?0 new │ │ ┃ │ a1b2c3d fix: refresh expired tokens (… │ │ ┃ │ │ │ ┃ ╰────────────────────────────────────────╯ $0.420 │ █████░░░░░ 49% │ ? ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ devhud: $0.420 · 49% ctx · ?

Draws

Band
$0.420 │ █████░░░░░ 49% │ ?
Pane · HUD
╭────────────────────────────────────────────────────────╮ │ │ │ SESSION COST │ │ $0.420 │ │ five_hour 31% │ │ │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │ │ │ CONTEXT │ │ ██████████░░░░░░░░░░ 49% │ │ 97k / 200k tokens │ │ │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │ │ │ GIT │ │ ? │ │ +0 staged · ~0 changed · ?0 new │ │ a1b2c3d fix: refresh expired tokens (2 minutes ago) 9f… │ │ │ ╰────────────────────────────────────────────────────────╯
Source 5 files
hooks/register.tsx 293 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMeasureInput } from 'claude-code'
3
4import type { AgentRow, Git, Pr, Usage } from '../types'
5import { readGit } from './git'
6import { readPr } from './pr'
7import { THEME, levelColor } from './theme'
8
9const PANE = 'devhud'
10const TITLE = 'HUD'
11const RING_SIZE = 72
12const BAR_CELLS = 20
13const MINI_BAR_CELLS = 10
14const MAX_AGENT_ROWS = 4
15const REPO_COMMAND = /\bgh pr\b|\bgit (push|commit|switch|checkout)\b/
16
17const usage = atom({ plugin: 'devhud', key: 'usage' } as const, null)
18const git = atom({ plugin: 'devhud', key: 'git' } as const, null)
19const pr = atom({ plugin: 'devhud', key: 'pr' } as const, null)
20const agents = atom({ plugin: 'devhud', key: 'agents' } as const, [])
21const now = atom({ plugin: 'devhud', key: 'now' } as const, 0)
22
23const toUsage = (e: SessionMeasureInput): Usage => ({
24  usd: e.cost?.usd ?? 0,
25  ctxPercent: e.context.percent ?? null,
26  ctxTokens: e.context.tokens ?? null,
27  window: e.context.window,
28  limits: e.rateLimits.map(({ kind, percentUsed }) => ({ kind, percentUsed })),
29})
30
31const money = (usd: number) => `$${usd.toFixed(usd < 1 ? 3 : 2)}`
32const kilo = (n: number) => `${Math.round(n / 1000)}k`
33const dirty = (g: Git) => g.staged + g.unstaged + g.untracked > 0
34const shortModel = (model: string) => model.replace(/^claude-/, '')
35const isRunning = (a: AgentRow) => a.status === 'running'
36const commandOf = (e: object) => ('command' in e && typeof e.command === 'string' ? e.command : '')
37
38const elapsed = (ms: number) => {
39  const s = Math.max(0, Math.round(ms / 1000))
40  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
41}
42
43const statusText = (u: Usage | null, g: Git | null) =>
44  [
45    u && money(u.usd),
46    u?.ctxPercent != null && `${u.ctxPercent}% ctx`,
47    g && `${g.branch}${dirty(g) ? '*' : ''}`,
48  ]
49    .filter(Boolean)
50    .join(' · ')
51
52const ring = (percent: number) => {
53  const r = 30
54  const len = 2 * Math.PI * r
55  const color = levelColor(percent)
56  return `<svg xmlns="http://www.w3.org/2000/svg" width="${RING_SIZE}" height="${RING_SIZE}" viewBox="0 0 72 72">
57<circle cx="36" cy="36" r="${r}" fill="none" stroke="${THEME.border}" stroke-width="7"/>
58<circle cx="36" cy="36" r="${r}" fill="none" stroke="${color}" stroke-width="7" stroke-linecap="round"
59 stroke-dasharray="${(len * percent) / 100} ${len}" transform="rotate(-90 36 36)"/>
60<text x="36" y="41" text-anchor="middle" font-family="ui-monospace,monospace" font-size="15" font-weight="700" fill="${THEME.text}">${percent}%</text>
61</svg>`
62}
63
64const bar = (percent: number, cells = BAR_CELLS) => {
65  const filled = Math.round((percent / 100) * cells)
66  return '█'.repeat(filled) + '░'.repeat(cells - filled)
67}
68
69const ciColor = (p: Pr) => (p.fail > 0 ? THEME.hot : p.pending > 0 ? THEME.warn : THEME.ok)
70
71async function syncRepo($: EngineInterface) {
72  const run = (argv: readonly string[]) => $.process.run(argv)
73  const [g, p] = await Promise.all([readGit(run).catch(() => null), readPr(run).catch(() => null)])
74  await update($, git, () => g)
75  await update($, pr, () => p)
76  $.ui.status(statusText(await read($, usage), g) || undefined)
77}
78
79async function syncAgents($: EngineInterface) {
80  const rows = await read($, agents)
81  if (!rows.some(isRunning)) return
82  const live = await $.agent.list()
83  await update($, agents, list =>
84    list.map(a => ({ ...a, status: live.find(l => l.id === a.id)?.status ?? a.status })),
85  )
86  const t = await $.clock.now()
87  await update($, now, () => t)
88}
89
90export const register: Register = on => {
91  on('session.start', async ($, e, next) => {
92    await $.command.register({ name: 'hud', description: 'Open the cost, context and git HUD' })
93    void syncRepo($)
94    return next(e)
95  })
96
97  on('command.run', { command: 'hud' }, async $ => {
98    await $.ui.open({ id: PANE, title: TITLE })
99    return { text: 'HUD opened.' }
100  })
101
102  on('session.measure', async ($, e, next) => {
103    const u = toUsage(e)
104    await update($, usage, () => u)
105    $.ui.status(statusText(u, await read($, git)) || undefined)
106    return next(e)
107  })
108
109  on('turn.start', async ($, e, next) => {
110    await syncAgents($)
111    return next(e)
112  })
113
114  on('turn.complete', async ($, e, next) => {
115    void syncRepo($)
116    await syncAgents($)
117    return next(e)
118  })
119
120  on('agent.spawn', async ($, e, next) => {
121    const spawned = await next(e)
122    if (spawned.agentId) {
123      const t = await $.clock.now()
124      const row: AgentRow = {
125        id: spawned.agentId,
126        description: e.description,
127        type: e.subagentType,
128        model: shortModel(spawned.model),
129        startedAt: t,
130        status: 'running',
131        tools: 0,
132      }
133      await update($, agents, list => [...list, row])
134      await update($, now, () => t)
135    }
136    return spawned
137  })
138
139  on('tool.call', async ($, e, next) => {
140    if (e.agentId) {
141      await update($, agents, list => list.map(a => (a.id === e.agentId ? { ...a, tools: a.tools + 1 } : a)))
142      await syncAgents($)
143    }
144    const ran = await next(e)
145    if (e.tool === 'Agent') await syncAgents($)
146    if (REPO_COMMAND.test(commandOf(e))) void syncRepo($)
147    return ran
148  })
149
150  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
151    if (e.props.hasSurvey) return next(e)
152    const { Box, Text } = $.ui.resolve(e)
153    const u = await read($, usage)
154    const g = await read($, git)
155    const p = await read($, pr)
156    const rows = await read($, agents)
157    const t = await read($, now)
158    const pct = u?.ctxPercent ?? null
159    const running = rows.filter(isRunning)
160    const done = rows.length - running.length
161    const sep = <Text color={THEME.border}> │ </Text>
162
163    return (
164      <Box flexDirection="column" paddingX={1} backgroundColor={THEME.panel}>
165        <Box>
166          <Text bold color={THEME.accent}>
167            {money(u?.usd ?? 0)}
168          </Text>
169          {sep}
170          {pct === null ? (
171            <Text color={THEME.muted}>ctx --</Text>
172          ) : (
173            <Text color={levelColor(pct)}>
174              {bar(pct, MINI_BAR_CELLS)} {pct}%
175            </Text>
176          )}
177          {sep}
178          {g === null ? (
179            <Text color={THEME.muted}>no repo</Text>
180          ) : (
181            <Text color={dirty(g) ? THEME.warn : THEME.ok}>
182              {g.branch}
183              {g.ahead > 0 && ` ↑${g.ahead}`}
184              {g.behind > 0 && ` ↓${g.behind}`}
185              {dirty(g) && ` +${g.staged} ~${g.unstaged} ?${g.untracked}`}
186            </Text>
187          )}
188          {p && (
189            <>
190              {sep}
191              <Text bold color={THEME.text}>
192                #{p.number}
193              </Text>
194              <Text color={THEME.muted}> → {p.base}</Text>
195              {p.isDraft && <Text color={THEME.muted}> draft</Text>}
196              <Text color={ciColor(p)}>
197                {'  '}ci ✓{p.pass}
198                {p.fail > 0 && ` ✗${p.fail}`}
199                {p.pending > 0 && ` ⋯${p.pending}`}
200              </Text>
201              <Text color={p.unresolved > 0 ? THEME.warn : THEME.muted}>
202                {'  '}💬{p.unresolved}
203              </Text>
204              {p.hasCodex && <Text color={THEME.ok}>{'  '}codex ✓</Text>}
205            </>
206          )}
207        </Box>
208        {running.slice(0, MAX_AGENT_ROWS).map(a => (
209          <Box>
210            <Text color={THEME.accent}>◐ </Text>
211            <Text color={THEME.text} wrap="truncate-end">
212              {a.description}
213            </Text>
214            <Text color={THEME.muted}>
215              {'  '}
216              {a.type} · {a.model} · {elapsed(t - a.startedAt)} · {a.tools} tools
217            </Text>
218          </Box>
219        ))}
220        {running.length > 0 && (running.length > MAX_AGENT_ROWS || done > 0) && (
221          <Text color={THEME.muted}>
222            {running.length > MAX_AGENT_ROWS && `+${running.length - MAX_AGENT_ROWS} more running  `}
223            {done > 0 && `${done} done`}
224          </Text>
225        )}
226      </Box>
227    )
228  })
229
230  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
231    const els = $.ui.resolve(e)
232    const { Box, Text } = els
233    const u = await read($, usage)
234    const g = await read($, git)
235    const pct = u?.ctxPercent ?? null
236    const label = (text: string) => <Text color={THEME.muted}>{text}</Text>
237
238    const gauge =
239      pct === null ? (
240        <Text color={THEME.muted}>waiting for first response</Text>
241      ) : 'Svg' in els ? (
242        <els.Svg source={ring(pct)} alt={`context ${pct}% full`} width={RING_SIZE} height={RING_SIZE} />
243      ) : (
244        <Text color={levelColor(pct)}>
245          {bar(pct)} {pct}%
246        </Text>
247      )
248
249    return (
250      <Box flexDirection="column" gap={1} padding={1} backgroundColor={THEME.bg}>
251        <Box flexDirection="column" padding={1} borderStyle="round" borderColor={THEME.border} backgroundColor={THEME.panel}>
252          {label('SESSION COST')}
253          <Text bold color={THEME.accent}>
254            {money(u?.usd ?? 0)}
255          </Text>
256          {u?.limits.map(l => (
257            <Text color={levelColor(l.percentUsed)}>
258              {l.kind} {l.percentUsed}%
259            </Text>
260          ))}
261        </Box>
262        <Box flexDirection="column" padding={1} borderStyle="round" borderColor={THEME.border} backgroundColor={THEME.panel}>
263          {label('CONTEXT')}
264          {gauge}
265          {u?.ctxTokens != null && (
266            <Text color={THEME.muted}>
267              {kilo(u.ctxTokens)} / {kilo(u.window)} tokens
268            </Text>
269          )}
270        </Box>
271        <Box flexDirection="column" padding={1} borderStyle="round" borderColor={THEME.border} backgroundColor={THEME.panel}>
272          {label('GIT')}
273          {g === null ? (
274            <Text color={THEME.muted}>not a git repo</Text>
275          ) : (
276            <>
277              <Text bold color={dirty(g) ? THEME.warn : THEME.ok}>
278                {g.branch}
279                {g.ahead > 0 && ` ↑${g.ahead}`}
280                {g.behind > 0 && ` ↓${g.behind}`}
281              </Text>
282              <Text color={THEME.text}>
283                +{g.staged} staged · ~{g.unstaged} changed · ?{g.untracked} new
284              </Text>
285              {g.last && <Text color={THEME.muted} wrap="truncate-end">{g.last}</Text>}
286            </>
287          )}
288        </Box>
289      </Box>
290    )
291  })
292}
293
hooks/git.ts 29 lines
1import type { Git } from '../types'
2
3type Run = (argv: readonly string[]) => Promise<{ exitCode: number; stdout: string }>
4
5export const parseStatus = (porcelain: string): Omit<Git, 'last'> => {
6  const git = { branch: '?', ahead: 0, behind: 0, staged: 0, unstaged: 0, untracked: 0 }
7  for (const line of porcelain.split('\n')) {
8    if (line.startsWith('# branch.head ')) git.branch = line.slice(14)
9    else if (line.startsWith('# branch.ab ')) {
10      const [, ahead, behind] = line.match(/\+(\d+) -(\d+)/) ?? []
11      git.ahead = Number(ahead ?? 0)
12      git.behind = Number(behind ?? 0)
13    } else if (line.startsWith('? ')) git.untracked += 1
14    else if (/^[12u] /.test(line)) {
15      const [x, y] = line.slice(2, 4)
16      if (x !== '.') git.staged += 1
17      if (y !== '.') git.unstaged += 1
18    }
19  }
20  return git
21}
22
23export const readGit = async (run: Run): Promise<Git | null> => {
24  const status = await run(['git', 'status', '--porcelain=v2', '--branch'])
25  if (status.exitCode !== 0) return null
26  const log = await run(['git', 'log', '-1', '--format=%h %s'])
27  return { ...parseStatus(status.stdout), last: log.exitCode === 0 ? log.stdout.trim() : '' }
28}
29
hooks/pr.ts 41 lines
1import type { Pr } from '../types'
2
3type Run = (argv: readonly string[]) => Promise<{ exitCode: number; stdout: string }>
4type Check = { conclusion?: string; status?: string; state?: string }
5
6const PASSED = ['SUCCESS', 'NEUTRAL', 'SKIPPED']
7const FAILED = ['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'ERROR', 'STARTUP_FAILURE']
8const CODEX = /codex/i
9const THREADS_QUERY =
10  'query($owner:String!,$name:String!,$n:Int!){repository(owner:$owner,name:$name){pullRequest(number:$n){reviewThreads(first:100){nodes{isResolved}}}}}'
11
12const outcome = (c: Check) => c.conclusion || c.state || ''
13
14const countUnresolved = async (run: Run, n: number) => {
15  const r = await run(['gh', 'api', 'graphql', '-F', 'owner={owner}', '-F', 'name={repo}', '-F', `n=${n}`, '-f', `query=${THREADS_QUERY}`])
16  if (r.exitCode !== 0) return 0
17  const nodes: { isResolved: boolean }[] =
18    JSON.parse(r.stdout).data?.repository?.pullRequest?.reviewThreads?.nodes ?? []
19  return nodes.filter(t => !t.isResolved).length
20}
21
22export const readPr = async (run: Run): Promise<Pr | null> => {
23  const r = await run(['gh', 'pr', 'view', '--json', 'number,baseRefName,isDraft,state,statusCheckRollup,reviews'])
24  if (r.exitCode !== 0) return null
25  const pr = JSON.parse(r.stdout)
26  if (pr.state !== 'OPEN') return null
27  const checks: Check[] = pr.statusCheckRollup ?? []
28  const pass = checks.filter(c => PASSED.includes(outcome(c))).length
29  const fail = checks.filter(c => FAILED.includes(outcome(c))).length
30  return {
31    number: pr.number,
32    base: pr.baseRefName,
33    isDraft: pr.isDraft,
34    pass,
35    fail,
36    pending: checks.length - pass - fail,
37    unresolved: await countUnresolved(run, pr.number),
38    hasCodex: (pr.reviews ?? []).some((v: { author?: { login?: string } }) => CODEX.test(v.author?.login ?? '')),
39  }
40}
41
hooks/theme.ts 19 lines
1// ember palette, used by every hud element
2export const THEME = {
3  bg: '#16110e',
4  panel: '#211915',
5  border: '#3a2c24',
6  text: '#f2e8e0',
7  muted: '#8a7a70',
8  accent: '#ff7a3d',
9  ok: '#7bd88f',
10  warn: '#ffcc66',
11  hot: '#ff5c5c',
12} as const
13
14const WARN_AT = 60
15const HOT_AT = 85
16
17export const levelColor = (percent: number) =>
18  percent >= HOT_AT ? THEME.hot : percent >= WARN_AT ? THEME.warn : THEME.ok
19
types/index.d.ts 51 lines
1export type Usage = {
2  usd: number
3  ctxPercent: number | null
4  ctxTokens: number | null
5  window: number
6  limits: { kind: string; percentUsed: number }[]
7}
8
9export type Git = {
10  branch: string
11  ahead: number
12  behind: number
13  staged: number
14  unstaged: number
15  untracked: number
16  last: string
17}
18
19export type Pr = {
20  number: number
21  base: string
22  isDraft: boolean
23  pass: number
24  fail: number
25  pending: number
26  unresolved: number
27  hasCodex: boolean
28}
29
30export type AgentRow = {
31  id: string
32  description: string
33  type: string
34  model: string
35  startedAt: number
36  status: string
37  tools: number
38}
39
40declare module 'claude-code' {
41  interface PluginState {
42    devhud: {
43      usage: Usage | null
44      git: Git | null
45      pr: Pr | null
46      agents: AgentRow[]
47      now: number
48    }
49  }
50}
51