A live HUD above the prompt: turn timer, active skill, subagent tree, git state, rtk token savings and one-click session checkpoints

A live HUD for Claude Code, drawn above the prompt. It shows what Claude is doing right now: how long the turn has run, which skill it loaded, which subagents are working, what changed in git, and how many tokens rtk saved you. One click saves a handoff doc of the session.
It's a mod: it runs inside Claude Code and draws in the terminal and in the Code tab of the Claude desktop app.
◴ 1m 04s 23 calls ◆ brainstorming ⑂ 2 running 1 done ⟲ checkpoint ⎇ main ±3 ↑1 ↯ 12.4k ▂▃▅█ 88%
AGENTS │ ◴ general-purpose research hud api 41s 9 calls Read
│ └ ◴ Explore find types 12s 3 calls Grep
SESSION │ up 1h 12m · 14 turns · 87 tool calls
| Segment | Shows | Hover (desktop) |
|---|---|---|
Turn ◴ 15s 3 calls | Live timer and tool-call count while Claude works; the last turn's numbers once it ends | Tool calls by tool (Bash ×5 · Read ×3) |
Skill ◆ brainstorming | The skill Claude loaded last | Its full name and the skills before it |
Agents ⑂ 2 running 1 done | Subagents running now, and recently finished | A tree of every agent: type, task, time, calls, current tool |
Checkpoint ⟲ checkpoint | A button that forks the session into a standalone handoff doc | Where it saved, or why it failed |
Git ⎇ main ±3 ↑1 | Branch, changed files, commits ahead/behind | The changed files |
rtk ↯ 12.4k ▂▃▅█ 88% | Tokens rtk saved in this session, per-turn trend | Top commands by tokens saved, all-time total |
On the desktop app a console row under the chips shows session uptime, turns and tool calls; hover a chip to swap in its detail.
The rtk segment needs rtk and sqlite3. Without them it hides itself and everything else works.
Requires Claude Code v2.1.287 or later.
/plugin marketplace add ezequielcutin/claude-hud-plugin
/plugin install neon-hud@claude-hud-plugin
Or from your shell:
claude plugin marketplace add ezequielcutin/claude-hud-plugin
claude plugin install neon-hud@claude-hud-plugin
Run /reload-plugins in any session that was already open.
/hud: list the segments and which are on/hud turn | skill | agents | git | rtk: show or hide a segment for this session/hud checkpoint: save a handoff doc (same as the button)In the terminal, clicks reach the HUD only in fullscreen mode. Elsewhere, press ctrl+x then tab to focus it and c to checkpoint.
A checkpoint forks the current conversation and asks the model for a handoff doc another agent can pick up cold, with Goal, Current state, Decisions, Key files, Next steps and Gotchas sections. It's saved to ~/.claude/checkpoints/<time>-<project>-<session>.md with a small header (session id, working directory, branch, model). It uses your plan's usage like any other model request.
Set these in /plugin (or /plugin configure neon-hud@claude-hud-plugin):
| Setting | Default | What it does |
|---|---|---|
hidden_segments | (none) | Comma-separated segments to hide at start, e.g. rtk, git |
timezone | local | Checkpoint timestamps: local, utc or us-eastern |
checkpoint_dir | .claude/checkpoints | Where checkpoints go, relative to your home directory |
rtk_path | (auto) | Path to rtk if it isn't in /opt/homebrew/bin, /usr/local/bin, /usr/bin, ~/.cargo/bin or ~/.local/bin |
rtk_db | (auto) | Path to rtk's history.db if it isn't in rtk's default location |
A mod runs with your permissions, so here is everything this one does (claude plugin validate plugins/neon-hud lists the same):
git status, rtk gain, sqlite3 read queries on rtk's history database, and /bin/dateHOME and XDG_DATA_HOME environment variables and checks whether those tools existgit clone https://github.com/ezequielcutin/claude-hud-plugin
claude --plugin-dir ./claude-hud-plugin/plugins/neon-hud
Saving a file reloads the mod in that session. Run the tests and checks with:
claude plugin test plugins/neon-hud
claude plugin validate .
MIT
hooks/register.tsx 843 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { HudAgent, HudCheckpoint, HudGit, HudRtk, HudSegment } from '../types'
5
6const SEGMENTS: HudSegment[] = ['rtk', 'turn', 'git', 'skill', 'agents']
7const EDITING_TOOLS = new Set(['Bash', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
8
9// Desktop: instrument cluster, graphite chips that sit on the host's grey, one hue per signal
10const DESK = {
11 chip: '#2a2a2a',
12 chipHover: '#353535',
13 text: '#ebe7e1',
14 dim: '#9a958e',
15 faint: '#5c5852',
16 rule: '#3d3a36',
17 live: '#e0895f',
18 skill: '#b9a3e3',
19 agents: '#7fb3d5',
20 amber: '#e2b261',
21 green: '#8fc79b',
22 red: '#e0727a',
23 mark: '#cdb47e',
24}
25// Terminal: neon on slate, each chip led by an accent edge
26const CLI: typeof DESK = {
27 chip: '#121826',
28 chipHover: '#1c2638',
29 text: '#e2e8f0',
30 dim: '#64748b',
31 faint: '#334155',
32 rule: '#334155',
33 live: '#22d3ee',
34 skill: '#a78bfa',
35 agents: '#60a5fa',
36 amber: '#fbbf24',
37 green: '#34d399',
38 red: '#f87171',
39 mark: '#f0abfc',
40}
41// A finished agent stays in the tree this long before it drops out
42const LINGER_MS = 60_000
43// An agent that just stopped may only be waiting on a background command, so it reads as
44// waiting this long before it reads as done (otherwise its glyph flips ✓ ↔ spinner)
45const SETTLE_MS = 8_000
46// How long the checkpoint button reports a result before it reads as ready again
47const CHECKPOINT_SHOW_MS = 30_000
48const CHECKPOINT_PROMPT = `Write a checkpoint of this session: a handoff document that another agent can pick up cold. That agent may be a different harness (Codex, Cursor, a fresh Claude Code session) with none of this conversation, so the document must stand alone: never refer to "above", "earlier" or "this conversation".
49
50Markdown, exactly these sections, each a few tight bullets (at most 5):
51# <a short title naming the work>
52## Goal
53What the person is trying to achieve, and why.
54## Current state
55What is built and verified, what is built but unverified, what is broken.
56## Decisions
57Choices made and the reason for each, including the person's stated preferences.
58## Key files
59\`path\`: its role, one line each.
60## Next steps
61Ordered, concrete, each one actionable.
62## Gotchas
63Environment quirks, things that failed and why, traps to avoid.
64
65Be concrete: real paths, commands, names, numbers. Leave out any path that exists only for this session (temp files, scratchpads, anything under /tmp or /private/tmp): the reader won't have it.
66
67This document is being written by a checkpoint run, and that run is itself proof of whatever it exercises: the checkpoint's trigger, its file naming and header, where it is saved, how its path is reported, and the prompt producing this text all work. Count them as verified, and never list writing, checking or confirming a checkpoint, or its output, as unverified or as a next step.
68
69Keep it under 500 words: a reader skims this. Output only the document.`
70const SPIN = ['◴', '◷', '◶', '◵']
71const BARS = '▁▂▃▄▅▆▇█'
72
73// Everything lives in $.state, so a reload keeps the session's numbers
74const rtk = atom({ plugin: 'neon-hud', key: 'rtk' } as const, { commands: 0, input: 0, saved: 0 })
75const rtkTrend = atom({ plugin: 'neon-hud', key: 'rtkTrend' } as const, { perTurn: [], savedAtTurnStart: 0, top: {} })
76const rtkAllTime = atom({ plugin: 'neon-hud', key: 'rtkAllTime' } as const, null)
77const turn = atom({ plugin: 'neon-hud', key: 'turn' } as const, { startedAt: null, tools: 0, lastMs: null, lastTools: 0 })
78const toolMix = atom({ plugin: 'neon-hud', key: 'toolMix' } as const, { current: {}, last: {} })
79const stats = atom({ plugin: 'neon-hud', key: 'stats' } as const, { turns: 0, tools: 0 })
80const git = atom({ plugin: 'neon-hud', key: 'git' } as const, null)
81const skill = atom({ plugin: 'neon-hud', key: 'skill' } as const, null)
82const skillHistory = atom({ plugin: 'neon-hud', key: 'skillHistory' } as const, [])
83const agents = atom({ plugin: 'neon-hud', key: 'agents' } as const, { running: 0, list: [] })
84const checkpoint = atom({ plugin: 'neon-hud', key: 'checkpoint' } as const, {
85 status: 'idle', startedAt: null, endedAt: null, path: null, error: null,
86} as HudCheckpoint)
87const hidden = atom({ plugin: 'neon-hud', key: 'hidden' } as const, [])
88
89// ---- settings and external tools ----
90
91type Timezone = 'local' | 'utc' | 'us-eastern'
92type Config = { defaultHidden: Set<HudSegment>; timezone: Timezone; checkpointDir: string; rtkPath: string; rtkDb: string }
93
94function readConfig(options: PluginOptions): Config {
95 const str = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
96 const tz = str(options.timezone)
97 return {
98 defaultHidden: new Set(
99 str(options.hidden_segments).split(',').map(x => x.trim()).filter((x): x is HudSegment => (SEGMENTS as string[]).includes(x)),
100 ),
101 timezone: tz === 'utc' || tz === 'us-eastern' ? tz : 'local',
102 checkpointDir: str(options.checkpoint_dir).replace(/^~\//, '').replace(/\/+$/, '') || '.claude/checkpoints',
103 rtkPath: str(options.rtk_path),
104 rtkDb: str(options.rtk_db),
105 }
106}
107
108let config: Config = readConfig({})
109
110// A segment is hidden when the settings hide it at start, flipped by each /hud <segment> toggle
111function hiddenSegments(toggled: readonly HudSegment[]): HudSegment[] {
112 const off = SEGMENTS.filter(s => config.defaultHidden.has(s) !== toggled.includes(s))
113 return isRtkReady() || off.includes('rtk') ? off : [...off, 'rtk']
114}
115
116// External tools, found once per load. A desktop app's PATH often lacks Homebrew's bin, so the
117// usual install locations are checked first and a bare name on PATH is the fallback.
118type Tools = { git: string; sqlite: string | null; rtk: string | null; rtkDb: string | null }
119let tools: Tools = { git: 'git', sqlite: null, rtk: null, rtkDb: null }
120const BIN_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin']
121
122async function isFile($: EngineInterface, path: string): Promise<boolean> {
123 try {
124 return (await $.fs.stat(path)).kind === 'file'
125 } catch {
126 return false
127 }
128}
129
130async function firstFile($: EngineInterface, paths: (string | null | undefined)[]): Promise<string | null> {
131 for (const path of paths) if (path && (await isFile($, path))) return path
132 return null
133}
134
135async function resolveTools($: EngineInterface): Promise<Tools> {
136 const home = await $.env.get('HOME')
137 const xdgData = await $.env.get('XDG_DATA_HOME')
138 const expand = (path: string) => (home ? path.replace(/^~(?=\/|$)/, home) : path)
139 const dirs = [...BIN_DIRS, home && `${home}/.cargo/bin`, home && `${home}/.local/bin`].filter((d): d is string => !!d)
140 const bin = (name: string, override = '') => firstFile($, [override && expand(override), ...dirs.map(d => `${d}/${name}`)])
141 const [git, sqlite, rtk, rtkDb] = await Promise.all([
142 bin('git'),
143 bin('sqlite3'),
144 bin('rtk', config.rtkPath),
145 firstFile($, [
146 config.rtkDb && expand(config.rtkDb),
147 home && `${home}/Library/Application Support/rtk/history.db`,
148 (xdgData || (home && `${home}/.local/share`)) && `${xdgData || `${home}/.local/share`}/rtk/history.db`,
149 ]),
150 ])
151 return { git: git ?? 'git', sqlite, rtk, rtkDb }
152}
153
154function isRtkReady(): boolean {
155 return tools.rtk !== null && tools.sqlite !== null && tools.rtkDb !== null
156}
157
158// ---- data sources ----
159
160type RtkRow = { id: number; original_cmd: string; input_tokens: number; saved_tokens: number }
161
162async function rtkQuery($: EngineInterface, sql: string): Promise<RtkRow[]> {
163 if (!isRtkReady()) return []
164 try {
165 const { exitCode, stdout } = await $.process.run([tools.sqlite as string, '-json', tools.rtkDb as string, sql], { timeoutMs: 5_000 })
166 return exitCode === 0 && stdout.trim() ? JSON.parse(stdout) : []
167 } catch {
168 return []
169 }
170}
171
172async function rtkMaxId($: EngineInterface): Promise<number | null> {
173 const [row] = await rtkQuery($, 'select coalesce(max(id), 0) as id from commands')
174 return row ? row.id : null
175}
176
177async function refreshRtkAllTime($: EngineInterface) {
178 if (!isRtkReady()) return
179 try {
180 const { exitCode, stdout } = await $.process.run([tools.rtk as string, 'gain', '--format', 'json'], { timeoutMs: 10_000 })
181 if (exitCode === 0) await update($, rtkAllTime, () => JSON.parse(stdout).summary.total_saved)
182 } catch {}
183}
184
185let isGitRefreshing = false
186
187async function refreshGit($: EngineInterface) {
188 if (isGitRefreshing) return
189 isGitRefreshing = true
190 try {
191 const { exitCode, stdout } = await $.process.run([tools.git, 'status', '--porcelain=v1', '--branch'], { timeoutMs: 5_000 })
192 if (exitCode !== 0) {
193 await update($, git, () => null)
194 return
195 }
196 const [header = '', ...files] = stdout.split('\n').filter(Boolean)
197 const info: HudGit = {
198 branch: (header.replace(/^## /, '').split('...')[0] ?? '').replace(/^No commits yet on /, ''),
199 changed: files.length,
200 ahead: Number(/ahead (\d+)/.exec(header)?.[1] ?? 0),
201 behind: Number(/behind (\d+)/.exec(header)?.[1] ?? 0),
202 files: files.slice(0, 6).map(f => `${f.slice(0, 2).trim() || '?'} ${f.slice(3).split('/').pop()}`),
203 }
204 await update($, git, () => info)
205 } catch {
206 await update($, git, () => null)
207 } finally {
208 isGitRefreshing = false
209 }
210}
211
212async function refreshAgents($: EngineInterface) {
213 try {
214 const [now, listed, prev] = await Promise.all([$.clock.now(), $.agent.list(), read($, agents)])
215 const known = new Map((prev.list ?? []).filter(x => x.id !== undefined).map(x => [x.id, x]))
216 const list = listed.map((a): HudAgent => {
217 const was = known.get(a.id)
218 const isRunning = a.status === 'running'
219 return {
220 id: a.id,
221 type: a.type,
222 description: a.description,
223 status: a.status,
224 parentId: a.parentId ?? null,
225 seenAt: was?.seenAt ?? now,
226 // First seen already stopped: never watched it run, so it's settled, not waiting
227 endedAt: isRunning ? null : was ? (was.endedAt ?? now) : now - SETTLE_MS,
228 tools: was?.tools ?? 0,
229 lastTool: was?.lastTool ?? null,
230 }
231 })
232 // A write redraws the band, so only write what moved
233 const changed =
234 list.length !== (prev.list ?? []).length ||
235 list.some(a => {
236 const was = known.get(a.id)
237 return !was || was.status !== a.status || was.parentId !== a.parentId || was.description !== a.description
238 })
239 if (changed) await update($, agents, () => ({ running: list.filter(x => x.status === 'running').length, list }))
240 await syncHeartbeat($)
241 } catch {}
242}
243
244// Running or recently finished agents, plus the ancestors that hold them in the tree
245function visibleAgents(list: HudAgent[], now: number): HudAgent[] {
246 const byId = new Map(list.map(a => [a.id, a]))
247 const keep = new Set<string>()
248 for (const a of list) {
249 if (a.endedAt !== null && now - a.endedAt > LINGER_MS) continue
250 for (let x: HudAgent | undefined = a; x && !keep.has(x.id); x = x.parentId ? byId.get(x.parentId) : undefined) keep.add(x.id)
251 }
252 return list.filter(a => keep.has(a.id))
253}
254
255// One heartbeat drives every clock on the band: it runs while a turn is live or an agent is
256// on the tree, redraws once a second, and re-reads agent statuses every third beat.
257// (Two timers out of phase redrew twice a second, which flickered.)
258let heartbeat: { cancel: () => void } | null = null
259let beats = 0
260
261async function syncHeartbeat($: EngineInterface) {
262 const [now, t, a, cp] = await Promise.all([$.clock.now(), read($, turn), read($, agents), read($, checkpoint)])
263 const hasAgents = visibleAgents(a.list ?? [], now).length > 0
264 const isCheckpointing = cp.status === 'running' || (cp.endedAt !== null && now - cp.endedAt < CHECKPOINT_SHOW_MS)
265 const isNeeded = t.startedAt !== null || hasAgents || isCheckpointing
266 if (isNeeded && heartbeat === null) {
267 beats = 0
268 heartbeat = $.clock.every(1000, async () => {
269 beats++
270 if (beats % 3 === 0 && (await hasVisibleAgents($))) await refreshAgents($)
271 else if (beats % 3 === 0) await syncHeartbeat($)
272 $.ui.invalidate('ui.render')
273 })
274 } else if (!isNeeded && heartbeat !== null) {
275 heartbeat.cancel()
276 heartbeat = null
277 $.ui.invalidate('ui.render')
278 }
279}
280
281async function hasVisibleAgents($: EngineInterface) {
282 const [now, a] = await Promise.all([$.clock.now(), read($, agents)])
283 return visibleAgents(a.list ?? [], now).length > 0
284}
285
286// Fork the session's own transcript into a standalone handoff doc under ~/<checkpoint dir>/.
287// A fork, not a spawned agent: it answers over the full conversation (served from the prompt
288// cache), where a fresh agent would start blank.
289async function runCheckpoint($: EngineInterface) {
290 if ((await read($, checkpoint)).status === 'running') return
291 const startedAt = await $.clock.now()
292 await update($, checkpoint, (): HudCheckpoint => ({ status: 'running', startedAt, endedAt: null, path: null, error: null }))
293 await syncHeartbeat($)
294 try {
295 const [created, home, cwd, id, model, g] = await Promise.all([
296 timestamp($, startedAt),
297 $.env.get('HOME'),
298 $.session.cwd(),
299 $.session.id(),
300 $.session.model().catch(() => 'unknown'),
301 read($, git),
302 ])
303 const reply = await $.model.fork({ prompt: CHECKPOINT_PROMPT })
304 if (!reply.isAnswered) throw new Error(reply.reason)
305 const project = (cwd.split('/').filter(Boolean).pop() ?? 'session').replace(/[^\w.-]+/g, '-')
306 const file = `${created.stamp}-${project}-${id.slice(0, 8)}.md`
307 const path = `${home ?? '~'}/${config.checkpointDir}/${file}`
308 const header = [
309 '---',
310 `created: ${created.iso}`,
311 `session: ${id}`,
312 `cwd: ${cwd}`,
313 g ? `branch: ${g.branch}` : null,
314 `model: ${model}`,
315 '---',
316 '',
317 ].filter(x => x !== null).join('\n')
318 await $.fs.write(path, header + reply.text.trim() + '\n')
319 const endedAt = await $.clock.now()
320 await update($, checkpoint, (): HudCheckpoint => ({ status: 'saved', startedAt, endedAt, path, error: null }))
321 $.ui.toast(`checkpoint saved: ${path}`)
322 $.ui.log(`checkpoint saved → ${path}`, { to: 'transcript' })
323 } catch (err) {
324 const endedAt = await $.clock.now()
325 const error = err instanceof Error ? err.message : String(err)
326 await update($, checkpoint, (): HudCheckpoint => ({ status: 'failed', startedAt, endedAt, path: null, error }))
327 $.ui.toast(`checkpoint failed: ${error}`)
328 $.ui.log(`checkpoint failed: ${error}`, { to: 'transcript' })
329 }
330 await syncHeartbeat($)
331}
332
333const unlisted = new Set<string>()
334
335// Credit a subagent's tool call to its row; an id the list hasn't shown yet is looked up once
336// (the engine's own forks carry ids no list names)
337async function noteAgentTool($: EngineInterface, id: string, tool: string) {
338 const list = (await read($, agents)).list ?? []
339 if (list.some(x => x.id === id)) {
340 await update($, agents, a => ({
341 ...a,
342 list: (a.list ?? []).map(x => (x.id === id ? { ...x, tools: x.tools + 1, lastTool: tool } : x)),
343 }))
344 } else if (!unlisted.has(id)) {
345 unlisted.add(id)
346 await refreshAgents($)
347 }
348}
349
350
351// ---- formatting ----
352
353// A checkpoint's file-name stamp and header time, in the zone the settings name.
354// Local time comes from the system's date, so it follows the machine's own zone rules.
355async function timestamp($: EngineInterface, ms: number): Promise<{ stamp: string; iso: string }> {
356 if (config.timezone === 'us-eastern') return eastern(ms)
357 if (config.timezone === 'local') {
358 try {
359 const { exitCode, stdout } = await $.process.run(['/bin/date', '+%Y-%m-%dT%H:%M:%S%z'], { timeoutMs: 2_000 })
360 const m = /^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)([+-]\d\d)(\d\d)$/.exec(stdout.trim())
361 if (exitCode === 0 && m && m[1] && m[2] && m[3]) {
362 return { stamp: m[1].slice(0, 16).replace(/[T:]/g, '-'), iso: `${m[1]}${m[2]}:${m[3]}` }
363 }
364 } catch {}
365 }
366 const wall = new Date(ms).toISOString().slice(0, 19)
367 return { stamp: wall.slice(0, 16).replace(/[T:]/g, '-'), iso: `${wall}Z` }
368}
369
370// US Eastern wall-clock time (EDT from the second Sunday of March, EST from the first Sunday
371// of November), computed by rule so it doesn't lean on the runtime's time-zone data
372function eastern(ms: number): { stamp: string; iso: string } {
373 const year = new Date(ms).getUTCFullYear()
374 const nthSunday = (month: number, n: number) => {
375 const firstDay = new Date(Date.UTC(year, month, 1)).getUTCDay()
376 return 1 + ((7 - firstDay) % 7) + (n - 1) * 7
377 }
378 const dstStart = Date.UTC(year, 2, nthSunday(2, 2), 7) // 2:00 EST
379 const dstEnd = Date.UTC(year, 10, nthSunday(10, 1), 6) // 2:00 EDT
380 const offset = ms >= dstStart && ms < dstEnd ? -4 : -5
381 const wall = new Date(ms + offset * 3_600_000).toISOString().slice(0, 19)
382 return {
383 stamp: wall.slice(0, 16).replace(/[T:]/g, '-'),
384 iso: `${wall}-0${-offset}:00`,
385 }
386}
387
388function tokens(n: number): string {
389 if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'
390 if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
391 if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'
392 return String(n)
393}
394
395function duration(ms: number): string {
396 const s = Math.floor(ms / 1000)
397 if (s < 60) return `${s}s`
398 if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
399 return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
400}
401
402function shortSkill(name: string): string {
403 return name.includes(':') ? name.slice(name.indexOf(':') + 1) : name
404}
405
406function topEntries(map: Record<string, number>, n: number): [string, number][] {
407 return Object.entries(map).sort((a, b) => b[1] - a[1]).slice(0, n)
408}
409
410function barsText(values: number[]): string {
411 const max = Math.max(1, ...values)
412 return values.map(v => BARS[Math.min(7, Math.round((v / max) * 7))]).join('')
413}
414
415function plural(n: number, word: string): string {
416 return `${n} ${word}${n === 1 ? '' : 's'}`
417}
418
419// mcp__server_name__tool → tool
420function shortTool(name: string): string {
421 return name.replace(/^mcp__.+?__/, '')
422}
423
424export const register: Register = (on, options) => {
425 config = readConfig(options)
426 // rtk rows already counted, so parallel Bash calls don't claim the same row twice
427 const claimed = new Set<number>()
428
429 on('session.start', async ($, e, next) => {
430 $.command.register({ name: 'hud', description: 'Save a session handoff doc: /hud checkpoint. Toggle a segment: /hud rtk | turn | git | skill | agents' })
431 // A failed lookup leaves the tools unfound: their segments hide and the rest still draws
432 tools = await resolveTools($).catch(() => tools)
433 await Promise.all([refreshRtkAllTime($), refreshGit($), refreshAgents($)])
434 return next(e)
435 })
436
437 on('turn.start', async ($, e, next) => {
438 const now = await $.clock.now()
439 const saved = (await read($, rtk)).saved
440 await update($, turn, t => ({ ...t, startedAt: now, tools: 0 }))
441 await update($, toolMix, m => ({ ...m, current: {} }))
442 await update($, rtkTrend, r => ({ ...r, savedAtTurnStart: saved }))
443 await update($, stats, s => ({ ...s, turns: s.turns + 1 }))
444 await syncHeartbeat($)
445 return next(e)
446 })
447
448 on('tool.call', async ($, e, next) => {
449 await update($, turn, t => ({ ...t, tools: t.tools + 1 }))
450 await update($, toolMix, m => ({ ...m, current: { ...m.current, [e.tool]: (m.current[e.tool] ?? 0) + 1 } }))
451 await update($, stats, s => ({ ...s, tools: s.tools + 1 }))
452 if (e.tool === 'Skill') {
453 const name = e.skill
454 await update($, skill, () => name)
455 await update($, skillHistory, h => [name, ...h.filter(n => n !== name)].slice(0, 5))
456 }
457 if (e.agentId !== undefined) void noteAgentTool($, e.agentId, e.tool)
458 const before = e.tool === 'Bash' ? await rtkMaxId($) : null
459 const result = await next(e)
460
461 // A background agent returns at once, so read statuses rather than count calls
462 if (e.tool === 'Agent') void refreshAgents($)
463
464 if (e.tool === 'Bash' && before !== null) {
465 const rows = await rtkQuery(
466 $,
467 `select id, original_cmd, input_tokens, saved_tokens from commands where id > ${before} order by id`,
468 )
469 // Rows written during this call whose command appears in what Claude ran are this session's
470 const mine = rows.filter(r => {
471 const head = r.original_cmd.trim().split(/\s+/)[0]
472 return !claimed.has(r.id) && head !== undefined && e.command.includes(head)
473 })
474 if (mine.length > 0) {
475 mine.forEach(r => claimed.add(r.id))
476 await update($, rtk, (s: HudRtk) => ({
477 commands: s.commands + mine.length,
478 input: s.input + mine.reduce((n, r) => n + r.input_tokens, 0),
479 saved: s.saved + mine.reduce((n, r) => n + r.saved_tokens, 0),
480 }))
481 await update($, rtkTrend, t => {
482 const top = { ...t.top }
483 for (const r of mine) {
484 const head = r.original_cmd.trim().split(/\s+/)[0] ?? '?'
485 top[head] = (top[head] ?? 0) + r.saved_tokens
486 }
487 return { ...t, top }
488 })
489 }
490 }
491 if (EDITING_TOOLS.has(e.tool)) void refreshGit($)
492 return result
493 })
494
495 on('turn.complete', async ($, e, next) => {
496 // A subagent's turn ending means one may have finished
497 if (e.agentId !== undefined) {
498 void refreshAgents($)
499 return next(e)
500 }
501 const saved = (await read($, rtk)).saved
502 await update($, turn, t => ({ startedAt: null, tools: 0, lastMs: e.durationMs, lastTools: t.tools }))
503 await update($, toolMix, m => ({ current: {}, last: m.current }))
504 await update($, rtkTrend, t => ({ ...t, perTurn: [...(t.perTurn ?? []), saved - t.savedAtTurnStart].slice(-16) }))
505 await Promise.all([refreshRtkAllTime($), refreshGit($), refreshAgents($)])
506 return next(e)
507 })
508
509 on('command.run', { command: 'hud' }, async ($, e) => {
510 if (e.args.trim() === 'checkpoint') {
511 void runCheckpoint($)
512 return { text: `checkpointing this session into ~/${config.checkpointDir}/` }
513 }
514 const arg = e.args.trim() as HudSegment
515 const off = hiddenSegments(await read($, hidden))
516 const describe = (s: HudSegment) => (s === 'rtk' && !isRtkReady() ? 'rtk (rtk not found)' : off.includes(s) ? `${s} (off)` : s)
517 if (!SEGMENTS.includes(arg)) {
518 return { text: `hud segments: ${SEGMENTS.map(describe).join(', ')}. Toggle with /hud <segment>.` }
519 }
520 if (arg === 'rtk' && !isRtkReady()) {
521 return { text: 'rtk not found: install rtk, or set its path with /plugin configure neon-hud' }
522 }
523 const isOff = off.includes(arg)
524 await update($, hidden, h => (h.includes(arg) ? h.filter(s => s !== arg) : [...h, arg]))
525 return { text: `hud ${arg} ${isOff ? 'shown' : 'hidden'}` }
526 })
527
528 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
529 if (e.props.hasSurvey) return next(e)
530
531 const isDesktop = e.surface === 'desktop'
532 const C = isDesktop ? DESK : CLI
533 const off = hiddenSegments(await read($, hidden))
534 const { Box, Text, Button } = $.ui.resolve(e)
535 const now = await $.clock.now()
536
537 // A chip. Desktop: graphite, flush with the host surface. Terminal: slate, led by an accent edge.
538 const pill = (id: string, accent: string, isLit: boolean, body: JSX.Element) => (
539 <Box
540 key={`pill-${id}`}
541 backgroundColor={C.chip}
542 paddingLeft={isDesktop ? 1 : 0}
543 paddingRight={1}
544 hover={{ scope: `hud-${id}`, backgroundColor: C.chipHover }}
545 >
546 {!isDesktop && <Text color={isLit ? accent : C.faint}>▌</Text>}
547 {body}
548 </Box>
549 )
550 // A console line: a fixed-width label column, a rule, then the line.
551 // Every line shares the column, so swapping one in on hover doesn't shift the text.
552 const consoleLine = (label: string, accent: string, body: JSX.Element) => (
553 <Box flexGrow={1}>
554 <Box width={8} flexShrink={0}>
555 <Text color={accent} bold>{label}</Text>
556 </Box>
557 <Text color={C.rule}>{'│ '}</Text>
558 {body}
559 </Box>
560 )
561 const detail = (id: string, label: string, accent: string, body: JSX.Element) => (
562 <Box
563 key={`detail-${id}`}
564 position="absolute"
565 top={0}
566 left={0}
567 right={0}
568 display="none"
569 backgroundColor={C.chip}
570 paddingLeft={1}
571 hover={{ scope: `hud-${id}`, display: 'flex' }}
572 >
573 {consoleLine(label, accent, body)}
574 </Box>
575 )
576
577 const left: JSX.Element[] = []
578 const right: JSX.Element[] = []
579 const details: JSX.Element[] = []
580 let tree: JSX.Element[] = []
581
582 // ── turn ──
583 if (!off.includes('turn')) {
584 const t = await read($, turn)
585 const mix = await read($, toolMix)
586 const isWorking = t.startedAt !== null
587 if (isWorking || t.lastMs !== null) {
588 const elapsed = isWorking ? now - (t.startedAt as number) : (t.lastMs as number)
589 const calls = isWorking ? t.tools : t.lastTools
590 left.push(
591 pill('turn', C.live, isWorking,
592 <Box>
593 <Text color={isWorking ? C.live : C.faint}>{isWorking ? SPIN[Math.floor(elapsed / 1000) % 4] : '◷'}</Text>
594 <Text color={isWorking ? C.text : C.dim} bold={isWorking}>{` ${duration(elapsed)}`}</Text>
595 <Text color={C.dim}>{` ${plural(calls, 'call')}`}</Text>
596 </Box>,
597 ),
598 )
599 const breakdown = topEntries(isWorking ? mix.current : mix.last, 6)
600 details.push(
601 detail('turn', isWorking ? 'LIVE' : 'LAST', isWorking ? C.live : C.dim,
602 <Text color={C.text} wrap="truncate-end">
603 {breakdown.length === 0 ? 'no tool calls' : breakdown.map(([k, v]) => `${shortTool(k)} ×${v}`).join(' · ')}
604 </Text>,
605 ),
606 )
607 }
608 }
609
610 // ── skill ──
611 if (!off.includes('skill')) {
612 const name = await read($, skill)
613 if (name !== null) {
614 const history = await read($, skillHistory)
615 left.push(
616 pill('skill', C.skill, true,
617 <Box>
618 <Text color={C.skill}>◆ </Text>
619 <Text color={C.text}>{shortSkill(name)}</Text>
620 </Box>,
621 ),
622 )
623 details.push(
624 detail('skill', 'SKILL', C.skill,
625 <Text wrap="truncate-end">
626 <Text color={C.text}>{name}</Text>
627 {history.length > 1 && <Text color={C.dim}>{` prev ${history.slice(1).map(shortSkill).join(', ')}`}</Text>}
628 </Text>,
629 ),
630 )
631 }
632 }
633
634 // ── agents ──
635 if (!off.includes('agents')) {
636 const a = await read($, agents)
637 const visible = visibleAgents((a.list ?? []).filter(x => x.id !== undefined), now)
638 const isSettled = (x: HudAgent) => x.status !== 'running' && x.endedAt !== null && now - x.endedAt >= SETTLE_MS
639 const done = visible.filter(isSettled).length
640 const active = visible.length - done
641 if (visible.length > 0) {
642 left.push(
643 pill('agents', C.agents, active > 0,
644 <Box>
645 <Text color={active > 0 ? C.agents : C.faint}>⑂ </Text>
646 <Text color={C.text} bold={active > 0}>{String(active)}</Text>
647 <Text color={C.dim}>{' running'}</Text>
648 {done > 0 && <Text color={C.dim}>{` ${done} done`}</Text>}
649 </Box>,
650 ),
651 )
652
653 // The fleet as a tree, one line per agent, children indented under the agent that spawned them
654 {
655 const ids = new Set(visible.map(x => x.id))
656 const childrenOf = (parent: string | null) =>
657 visible.filter(x => (x.parentId !== null && ids.has(x.parentId) ? x.parentId : null) === parent)
658 const agentLine = (x: HudAgent, depth: number, isFirst: boolean) => {
659 const isRunning = x.status === 'running'
660 const isSettling = !isRunning && x.endedAt !== null && now - x.endedAt < SETTLE_MS
661 const isFailed = !isRunning && !isSettling && x.status !== 'completed'
662 const glyph = isRunning ? SPIN[Math.floor(now / 1000) % 4] : isSettling ? '◌' : isFailed ? '✗' : '✓'
663 const glyphColor = isRunning ? C.agents : isSettling ? C.dim : isFailed ? C.red : C.green
664 const state = isRunning ? (x.lastTool ? shortTool(x.lastTool) : '') : isSettling ? 'waiting' : x.status
665 // Fixed-width, right-aligned stat columns: a number growing a digit can't shove the line
666 const stat = (key: string, width: number, text: string, color: string) => (
667 <Box key={key} width={width} flexShrink={0} justifyContent="flex-end">
668 <Text color={color} wrap="truncate-end">{text}</Text>
669 </Box>
670 )
671 return (
672 <Box key={`agent-${x.id}`} paddingLeft={1}>
673 {consoleLine(isFirst ? 'AGENTS' : '', C.agents,
674 <Box flexGrow={1}>
675 <Box flexGrow={1} flexShrink={1}>
676 <Text wrap="truncate-end">
677 {depth > 0 && <Text color={C.faint}>{`${' '.repeat(depth - 1)} └ `}</Text>}
678 <Text color={glyphColor}>{`${glyph} `}</Text>
679 <Text color={isRunning ? C.text : C.dim} bold={isRunning}>{x.type}</Text>
680 <Text color={C.dim}>{` ${x.description}`}</Text>
681 </Text>
682 </Box>
683 {stat('t', 9, duration((x.endedAt ?? now) - x.seenAt), C.faint)}
684 {stat('c', 10, plural(x.tools, 'call'), C.faint)}
685 {stat('s', 14, state, isRunning ? C.dim : C.faint)}
686 </Box>,
687 )}
688 </Box>
689 )
690 }
691 const lines: JSX.Element[] = []
692 const walk = (parent: string | null, depth: number) => {
693 for (const x of childrenOf(parent)) {
694 lines.push(agentLine(x, depth, lines.length === 0))
695 walk(x.id, depth + 1)
696 }
697 }
698 walk(null, 0)
699 tree = lines
700 }
701 }
702 }
703
704 // ── checkpoint ──
705 {
706 const cp = await read($, checkpoint)
707 const isRunning = cp.status === 'running'
708 const isReporting = !isRunning && cp.endedAt !== null && now - cp.endedAt < CHECKPOINT_SHOW_MS
709 const shown = isRunning ? 'running' : isReporting ? cp.status : 'idle'
710 const label =
711 shown === 'running' ? `${SPIN[Math.floor(now / 1000) % 4]} checkpointing ${duration(now - (cp.startedAt ?? now))}`
712 : shown === 'saved' ? '✓ checkpoint saved'
713 : shown === 'failed' ? '✗ checkpoint failed'
714 : '⟲ checkpoint'
715 const accent = shown === 'failed' ? C.red : shown === 'saved' ? C.green : C.mark
716 right.push(
717 pill('checkpoint', accent, shown !== 'idle',
718 // The terminal reports clicks only in fullscreen; elsewhere ctrl+x tab focuses the band,
719 // then c (drawn as "c: ⟲ checkpoint") or Enter presses it
720 <Button
721 key="checkpoint"
722 plain
723 hotkey="c"
724 label={label}
725 dimColor={shown === 'idle'}
726 hover={{ scope: 'hud-checkpoint', color: accent }}
727 onPress={() => void runCheckpoint($)}
728 />,
729 ),
730 )
731 details.push(
732 detail('checkpoint', 'CHKPT', accent,
733 <Text color={C.text} wrap="truncate-end">
734 {shown === 'running' ? 'forking the session into a handoff doc…'
735 : shown === 'saved' && cp.path ? cp.path
736 : shown === 'failed' ? `failed: ${cp.error ?? 'unknown error'}`
737 : `fork this session into a standalone handoff doc → ~/${config.checkpointDir}/ (ctrl+x tab, then c)`}
738 </Text>,
739 ),
740 )
741 }
742
743 // ── git ──
744 if (!off.includes('git')) {
745 const g = await read($, git)
746 if (g !== null) {
747 const isDirty = g.changed > 0
748 right.push(
749 pill('git', isDirty ? C.amber : C.green, true,
750 <Box>
751 <Text color={isDesktop ? (isDirty ? C.amber : C.green) : C.dim}>⎇ </Text>
752 <Text color={C.text}>{g.branch}</Text>
753 <Text color={isDirty ? C.amber : isDesktop ? C.faint : C.dim}>{isDirty ? ` ±${g.changed}` : ' ✓'}</Text>
754 {g.ahead > 0 && <Text color={C.green}>{` ↑${g.ahead}`}</Text>}
755 {g.behind > 0 && <Text color={C.red}>{` ↓${g.behind}`}</Text>}
756 </Box>,
757 ),
758 )
759 const files = g.files ?? []
760 details.push(
761 detail('git', 'GIT', isDirty ? C.amber : C.green,
762 <Text color={C.text} wrap="truncate-end">
763 {files.length === 0 ? 'working tree clean' : files.join(' · ') + (g.changed > files.length ? ` · +${g.changed - files.length} more` : '')}
764 </Text>,
765 ),
766 )
767 }
768 }
769
770 // ── rtk ──
771 if (!off.includes('rtk')) {
772 const s = await read($, rtk)
773 const trend = await read($, rtkTrend)
774 const allTime = await read($, rtkAllTime)
775 const pct = s.input > 0 ? Math.round((s.saved / s.input) * 100) : 0
776 const series = (trend.perTurn ?? []).slice(-8)
777 const isActive = s.commands > 0
778 right.push(
779 pill('rtk', C.green, isActive,
780 <Box>
781 <Text color={isActive || !isDesktop ? C.green : C.faint}>↯ </Text>
782 <Text color={isActive || !isDesktop ? C.text : C.dim} bold={isActive || !isDesktop}>{tokens(s.saved)}</Text>
783 {series.length >= 2 && <Text color={C.green}>{` ${barsText(series)}`}</Text>}
784 {(isActive || !isDesktop) && <Text color={C.dim}>{` ${pct}%`}</Text>}
785 </Box>,
786 ),
787 )
788 const top = topEntries(trend.top ?? {}, 3)
789 details.push(
790 detail('rtk', 'RTK', C.green,
791 <Text wrap="truncate-end">
792 <Text color={C.text}>{`${tokens(s.saved)} saved of ${tokens(s.input)} over ${plural(s.commands, 'cmd')}`}</Text>
793 {top.length > 0 && <Text color={C.dim}>{` top ${top.map(([k, v]) => `${k} ${tokens(v)}`).join(' · ')}`}</Text>}
794 {allTime !== null && <Text color={C.dim}>{` all-time ${tokens(allTime)}`}</Text>}
795 </Text>,
796 ),
797 )
798 }
799
800 if (left.length + right.length === 0) return next(e)
801
802 const row = (
803 <Box key="row" justifyContent="space-between" flexWrap="wrap" columnGap={2}>
804 <Box columnGap={1} flexWrap="wrap">{left}</Box>
805 <Box columnGap={1} flexWrap="wrap">{right}</Box>
806 </Box>
807 )
808 if (!isDesktop) {
809 return tree.length > 0 ? (
810 <Box flexDirection="column">
811 {row}
812 <Box key="tree" flexDirection="column">{tree}</Box>
813 </Box>
814 ) : (
815 row
816 )
817 }
818
819 // Desktop: the agent tree, then a console row; at rest it reads the session, a hovered chip swaps in its detail
820 const st = await read($, stats)
821 const usage = await $.session.usage().catch(() => null)
822 const uptime = usage ? duration(now - usage.startedAt) : null
823 return (
824 <Box flexDirection="column">
825 {row}
826 {tree.length > 0 && (
827 <Box key="tree" flexDirection="column">
828 {tree}
829 </Box>
830 )}
831 <Box key="console" position="relative" height={1} overflow="hidden" paddingLeft={1}>
832 {consoleLine('SESSION', C.faint,
833 <Text color={C.dim} wrap="truncate-end">
834 {[uptime && `up ${uptime}`, plural(st.turns, 'turn'), plural(st.tools, 'tool call')].filter(Boolean).join(' · ')}
835 </Text>,
836 )}
837 {details}
838 </Box>
839 </Box>
840 )
841 })
842}
843types/index.d.ts 46 lines1export type HudSegment = 'rtk' | 'turn' | 'git' | 'skill' | 'agents'
2export type HudRtk = { commands: number; input: number; saved: number }
3export type HudRtkTrend = { perTurn: number[]; savedAtTurnStart: number; top: Record<string, number> }
4export type HudTurn = { startedAt: number | null; tools: number; lastMs: number | null; lastTools: number }
5export type HudToolMix = { current: Record<string, number>; last: Record<string, number> }
6export type HudSessionStats = { turns: number; tools: number }
7export type HudGit = { branch: string; changed: number; ahead: number; behind: number; files?: string[] }
8export type HudAgent = {
9 id: string
10 type: string
11 description: string
12 status: string
13 parentId: string | null
14 seenAt: number
15 endedAt: number | null
16 tools: number
17 lastTool: string | null
18}
19export type HudCheckpoint = {
20 status: 'idle' | 'running' | 'saved' | 'failed'
21 startedAt: number | null
22 endedAt: number | null
23 path: string | null
24 error: string | null
25}
26export type HudAgents = { running: number; list?: HudAgent[] }
27
28declare module 'claude-code' {
29 interface PluginState {
30 'neon-hud': {
31 rtk: HudRtk
32 rtkTrend: HudRtkTrend
33 rtkAllTime: number | null
34 turn: HudTurn
35 toolMix: HudToolMix
36 stats: HudSessionStats
37 git: HudGit | null
38 skill: string | null
39 skillHistory: string[]
40 agents: HudAgents
41 checkpoint: HudCheckpoint
42 hidden: HudSegment[]
43 }
44 }
45}
46