SLOPSHOPPER

neon-hud

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

newbandguardcommandtoastmodel
★ 1v0.1.0MITupdated 2026-10-06ezequielcutin/claude-hud-plugin/plugins/neon-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · neon-hud
› 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 › /hud ⎿ neon-hud: hud segments: rtk (rtk not found), turn, git, skill, agents. Toggle with /hud <segment>. ▌◷ 42s 9 calls ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▌◷ 42s 9 calls
README

neon-hud

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

What it shows

SegmentShowsHover (desktop)
Turn ◴ 15s 3 callsLive timer and tool-call count while Claude works; the last turn's numbers once it endsTool calls by tool (Bash ×5 · Read ×3)
Skill ◆ brainstormingThe skill Claude loaded lastIts full name and the skills before it
Agents ⑂ 2 running 1 doneSubagents running now, and recently finishedA tree of every agent: type, task, time, calls, current tool
Checkpoint ⟲ checkpointA button that forks the session into a standalone handoff docWhere it saved, or why it failed
Git ⎇ main ±3 ↑1Branch, changed files, commits ahead/behindThe changed files
rtk ↯ 12.4k ▂▃▅█ 88%Tokens rtk saved in this session, per-turn trendTop 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.

Install

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.

Commands

  • /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.

Checkpoints

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.

Settings

Set these in /plugin (or /plugin configure neon-hud@claude-hud-plugin):

SettingDefaultWhat it does
hidden_segments(none)Comma-separated segments to hide at start, e.g. rtk, git
timezonelocalCheckpoint timestamps: local, utc or us-eastern
checkpoint_dir.claude/checkpointsWhere 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

What it can access

A mod runs with your permissions, so here is everything this one does (claude plugin validate plugins/neon-hud lists the same):

  • Runs git status, rtk gain, sqlite3 read queries on rtk's history database, and /bin/date
  • Reads the HOME and XDG_DATA_HOME environment variables and checks whether those tools exist
  • Writes one file per checkpoint, in your checkpoint folder, and nothing else
  • Calls the model only when you press checkpoint
  • Never blocks, rewrites or approves a tool call, and makes no network requests of its own

Develop

git 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 .

License

MIT

Source 2 files
hooks/register.tsx 843 lines
1import { 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}
843
types/index.d.ts 46 lines
1export 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