cost, context and git hud with an ember theme

hooks/register.tsx 293 lines1import { 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}
293hooks/git.ts 29 lines1import 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}
29hooks/pr.ts 41 lines1import 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}
41hooks/theme.ts 19 lines1// 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
19types/index.d.ts 51 lines1export 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