A HUD under the Claude Code prompt: model, git, cost, context and rate-limit bars, tools, skills, MCP, agents and todos at a glance

English | 简体中文
A HUD under your Claude Code prompt, written as a Claude Code mod. One look tells you the model, the branch, what the session has cost, how full the context is, where your 5-hour and 7-day limits stand, and what Claude is running right now.
❯ █
◆ Opus 5.5 · main ↑1 ●3 $2.63 · ⏱ 16m
ctx █░░░░░░░░░ 14% · 145k/1M 5h █░░░░░░░░░ 8% · 2h21m 7d ░░░░░░░░░░ 2% · 6d21h
◐ Bash npm test ✓ Read×12 Edit×3 Bash×5
✦ plugin-authoring · simplify ⧉ Notion×2 Gmail×1
⚙ Explore find the config 2 agents
▸ 3/7 Writing the README
? for shortcuts
Requires Claude Code 2.1.289 or newer. Mods are an early-access API that may change between releases. If glance stops drawing after an update, please open an issue.
In Claude Code:
/plugin marketplace add VibeMage/claude-mod-glance
/plugin install glance@claude-mod-glance
Or from a shell:
claude plugin marketplace add VibeMage/claude-mod-glance
claude plugin install glance@claude-mod-glance
Start a new session (or run /reload-plugins) and glance appears under the prompt.
glance runs alongside a statusLine such as claude-hud. If you are switching from one, remove statusLine from ~/.claude/settings.json so you do not see the same figures twice.
| Row | Contents | Source |
|---|---|---|
| Header | Model (claude-opus-5-5 → Opus 5.5), git branch, ahead ↑ / behind ↓, changed files ●, session cost, session duration | $.session.model(), git status --porcelain=v2, $.session.usage() |
| Bars | Context fill with tokens used, then each rate-limit window (5h, 7d) with the time until it resets. Each bar has its own colour and turns red at the warning threshold | $.session.usage(), refreshed on session.measure |
| Tools | Tools running now (◐) and the session's most-used tools | tool.call |
| Skills and MCP | Skills loaded this session, latest first (✦); MCP calls running now (◐) and calls per server (⧉) | skill.prompt, tool.call |
| Agents | Subagents running (⚙) and how many the session has started | tool.call on Agent |
| Todos | Progress and the item in progress, from TodoWrite or the Task tools | tool.call |
When glance loads (a new session, --resume, or installing it mid-session), it reads the transcript, so the tools, MCP servers, skills and todos used before it loaded are counted too.
The cost is what /cost reports: an estimate at API list prices. On a Pro or Max subscription it is not what you are billed.
glance is a single TypeScript module (hooks/register.tsx) that Claude Code runs in its mod sandbox. It has no dependencies, downloads nothing and opens no network connections. Everything it reads stays on your machine.
git, as git status --porcelain=v2 --branch in the session's working directory, when the session starts, after each turn and every 30 seconds, for the branch, ahead/behind and changed-file count.$.session.usage()), the session's transcript ($.session.messages(), only the names and inputs of tool calls, to count tools, MCP servers, skills and todos), and your Claude Code theme setting ($.settings.read(), to pick light or dark colours).tool.call) and skill loads (skill.prompt) as they happen, to note the tool's name and a short label and the skill's name. Every call and every skill prompt passes through unchanged; glance never blocks, rewrites or adds to them.git, whose only input is the fixed argument list above.Privacy: PRIVACY.md.
Run /plugin configure glance@claude-mod-glance, or open /config and find the glance rows. Changes apply immediately.
| Option | Default | What it does |
|---|---|---|
palette | auto | auto: Cupertino (iOS system colours), light or dark following your Claude Code theme. catppuccin: Latte or Mocha, chosen the same way. mocha, macchiato, frappe, latte: always that Catppuccin flavor |
barWidth | 10 | Cells in each bar (4–30) |
warnAt | 85 | Percentage at which a bar and its figure turn red |
showGit | true | Branch, ahead/behind, changed files |
showCost | true | Session cost |
showDuration | true | Session duration |
showTools | true | Tools row |
showSkills | true | Skills and MCP row |
showAgents | true | Agents row |
showTodos | true | Todos row |
keepHint | true | Keep Claude Code's own hint line (? for shortcuts, esc to interrupt) under the HUD |
auto mode on pill) are lost. Turn the line off with keepHint: false./name is not in the transcript's tool calls, so it is only counted if glance was already loaded when you ran it.git clone https://github.com/VibeMage/claude-mod-glance
cd claude-mod-glance
claude --plugin-dir . # load it for one session; saving a file reloads it
claude plugin validate . # what the engine would refuse
claude plugin test . # tests/*.test.ts against the engine
The engine writes the API's types to .claude-plugin/types/ the first time it loads the mod, along with a tsconfig.json, so tsc -p . type-checks it. Both are build-specific and git-ignored.
hooks/register.tsx 746 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { HudActivity, HudGit, HudRun, HudTodo, HudUsage } from '../types'
5
6type PaletteKey =
7 | 'rosewater'
8 | 'pink'
9 | 'mauve'
10 | 'red'
11 | 'peach'
12 | 'yellow'
13 | 'green'
14 | 'teal'
15 | 'sky'
16 | 'sapphire'
17 | 'blue'
18 | 'lavender'
19 | 'text'
20 | 'subtext'
21 | 'overlay'
22 | 'surface2'
23 | 'surface1'
24 | 'surface0'
25 | 'crust'
26
27// A palette names a colour per role; `meters` gives each bar its own (ctx, 5h, 7d), else blue, mauve, teal
28type Palette = Record<PaletteKey, string> & { meters?: Record<string, string> }
29
30// Cupertino: the user's Apple-style design system (iOS system colours, HIG June 2025), text in
31// grays, colour kept for state and categories. Accent text uses HIG's increased-contrast values.
32const CUPERTINO: Record<'light' | 'dark', Palette> = {
33 light: {
34 rosewater: '#ac7f5e',
35 pink: '#d30f45',
36 mauve: '#6155f5',
37 red: '#d70015',
38 peach: '#c93400',
39 yellow: '#c93400',
40 green: '#248a3d',
41 teal: '#0071a4',
42 sky: '#0071a4',
43 sapphire: '#0063c7',
44 blue: '#0071e3',
45 lavender: '#6e6e73',
46 text: '#1d1d1f',
47 subtext: '#6e6e73',
48 overlay: '#8e8e93',
49 surface2: '#c7c7cc',
50 surface1: '#d1d1d6',
51 surface0: '#e5e5ea',
52 crust: '#ffffff',
53 meters: { ctx: '#0088ff', five_hour: '#6155f5', seven_day: '#00c3d0' },
54 },
55 dark: {
56 rosewater: '#b78a66',
57 pink: '#ff375f',
58 mauve: '#6d7cff',
59 red: '#ff666a',
60 peach: '#ffa056',
61 yellow: '#ffd600',
62 green: '#4ad968',
63 teal: '#00d2e0',
64 sky: '#3cd3fe',
65 sapphire: '#319dff',
66 blue: '#0091ff',
67 lavender: '#98989f',
68 text: '#f5f5f7',
69 subtext: '#98989f',
70 overlay: '#7c7c80',
71 surface2: '#48484a',
72 surface1: '#3a3a3c',
73 surface0: '#2c2c2e',
74 crust: '#000000',
75 meters: { ctx: '#0091ff', five_hour: '#6d7cff', seven_day: '#00d2e0' },
76 },
77}
78
79// Catppuccin palettes, from the Catppuccin project's published palette
80const FLAVORS: Record<'mocha' | 'macchiato' | 'frappe' | 'latte', Palette> = {
81 mocha: {
82 rosewater: '#f5e0dc',
83 pink: '#f5c2e7',
84 mauve: '#cba6f7',
85 red: '#f38ba8',
86 peach: '#fab387',
87 yellow: '#f9e2af',
88 green: '#a6e3a1',
89 teal: '#94e2d5',
90 sky: '#89dceb',
91 sapphire: '#74c7ec',
92 blue: '#89b4fa',
93 lavender: '#b4befe',
94 text: '#cdd6f4',
95 subtext: '#a6adc8',
96 overlay: '#6c7086',
97 surface2: '#585b70',
98 surface1: '#45475a',
99 surface0: '#313244',
100 crust: '#11111b',
101 },
102 macchiato: {
103 rosewater: '#f4dbd6',
104 pink: '#f5bde6',
105 mauve: '#c6a0f6',
106 red: '#ed8796',
107 peach: '#f5a97f',
108 yellow: '#eed49f',
109 green: '#a6da95',
110 teal: '#8bd5ca',
111 sky: '#91d7e3',
112 sapphire: '#7dc4e4',
113 blue: '#8aadf4',
114 lavender: '#b7bdf8',
115 text: '#cad3f5',
116 subtext: '#a5adcb',
117 overlay: '#6e738d',
118 surface2: '#5b6078',
119 surface1: '#494d64',
120 surface0: '#363a4f',
121 crust: '#181926',
122 },
123 frappe: {
124 rosewater: '#f2d5cf',
125 pink: '#f4b8e4',
126 mauve: '#ca9ee6',
127 red: '#e78284',
128 peach: '#ef9f76',
129 yellow: '#e5c890',
130 green: '#a6d189',
131 teal: '#81c8be',
132 sky: '#99d1db',
133 sapphire: '#85c1dc',
134 blue: '#8caaee',
135 lavender: '#babbf1',
136 text: '#c6d0f5',
137 subtext: '#a5adce',
138 overlay: '#737994',
139 surface2: '#626880',
140 surface1: '#51576d',
141 surface0: '#414559',
142 crust: '#232634',
143 },
144 latte: {
145 rosewater: '#dc8a78',
146 pink: '#ea76cb',
147 mauve: '#8839ef',
148 red: '#d20f39',
149 peach: '#fe640b',
150 yellow: '#df8e1d',
151 green: '#40a02b',
152 teal: '#179299',
153 sky: '#04a5e5',
154 sapphire: '#209fb5',
155 blue: '#1e66f5',
156 lavender: '#7287fd',
157 text: '#4c4f69',
158 subtext: '#6c6f85',
159 overlay: '#9ca0b0',
160 surface2: '#acb0be',
161 surface1: '#bcc0cc',
162 surface0: '#ccd0da',
163 crust: '#dce0e8',
164 },
165}
166
167type Flavor = keyof typeof FLAVORS
168
169const usage = atom({ plugin: 'glance', key: 'usage' } as const, null)
170const git = atom({ plugin: 'glance', key: 'git' } as const, null)
171const activity = atom({ plugin: 'glance', key: 'activity' } as const, {
172 running: [],
173 counts: {},
174 mcpCounts: {},
175 agentsDone: 0,
176})
177const todos = atom({ plugin: 'glance', key: 'todos' } as const, [])
178const model = atom({ plugin: 'glance', key: 'model' } as const, '')
179const now = atom({ plugin: 'glance', key: 'now' } as const, 0)
180
181const skills = atom({ plugin: 'glance', key: 'skills' } as const, [])
182
183const EMPTY_ACTIVITY: HudActivity = { running: [], counts: {}, mcpCounts: {}, agentsDone: 0 }
184
185// State outlives a reload, so a value an older version wrote may lack the newer fields
186function withDefaults(a: Partial<HudActivity> | null | undefined): HudActivity {
187 return { ...EMPTY_ACTIVITY, ...a, running: (a?.running ?? []).filter(r => r.kind !== undefined) }
188}
189
190function shortCount(n: number): string {
191 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
192 if (n >= 10_000) return `${Math.round(n / 1000)}k`
193 if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
194
195 return String(n)
196}
197
198function formatDuration(ms: number): string {
199 const minutes = Math.max(0, Math.floor(ms / 60_000))
200 if (minutes < 60) return `${minutes}m`
201 const hours = Math.floor(minutes / 60)
202 if (hours < 24) return `${hours}h${String(minutes % 60).padStart(2, '0')}m`
203
204 return `${Math.floor(hours / 24)}d${hours % 24}h`
205}
206
207// `claude-opus-5-5` → `Opus 5.5`; a name already spelled for people is kept
208export function prettyModel(id: string): string {
209 const m = id.match(/^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/)
210 if (!m) return id || 'Claude'
211 const family = m[1]!.charAt(0).toUpperCase() + m[1]!.slice(1)
212
213 return `${family} ${m[2]}${m[3] ? `.${m[3]}` : ''}${m[4] ? ' 1M' : ''}`
214}
215
216// Each bar keeps its own colour, turning red at the warning threshold
217function meterColor(p: Palette, kind: string, percent: number, warnAt: number): string {
218 if (percent >= warnAt) return p.red
219 const fallback: Record<string, string> = { ctx: p.blue, five_hour: p.mauve, seven_day: p.teal }
220
221 return p.meters?.[kind] ?? fallback[kind] ?? p.sapphire
222}
223
224function limitLabel(kind: string): string {
225 if (kind === 'five_hour') return '5h'
226 if (kind === 'seven_day') return '7d'
227 if (kind === 'spend_limit') return 'spend'
228
229 return kind.replace(/_/g, ' ')
230}
231
232// `claude_ai_Notion` → `Notion`, `claude-in-chrome` kept
233function serverName(id: string): string {
234 return id.replace(/^claude_ai_/, '').replace(/_/g, ' ')
235}
236
237// A tool named after its server drops the repeat: Notion's `notion-search` → `search`
238export function mcpToolLabel(server: string, tool: string): string {
239 const prefix = server.toLowerCase().replace(/\s+/g, '-')
240 for (const sep of ['-', '_']) {
241 if (tool.toLowerCase().startsWith(prefix + sep)) return tool.slice(prefix.length + 1)
242 }
243
244 return tool
245}
246
247function basename(path: string): string {
248 return path.split('/').filter(Boolean).pop() ?? path
249}
250
251function clip(text: string, max: number): string {
252 const line = text.replace(/\s+/g, ' ').trim()
253
254 return line.length > max ? `${line.slice(0, max - 1)}…` : line
255}
256
257function labelFor(tool: string, input: Record<string, unknown>): string {
258 const str = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : '')
259 if (tool === 'Bash') return clip(str('command'), 28)
260 if (tool === 'Agent') return clip(str('description'), 28)
261 if (str('file_path')) return basename(str('file_path'))
262 if (str('notebook_path')) return basename(str('notebook_path'))
263 if (str('pattern')) return clip(str('pattern'), 20)
264 if (str('url'))
265 return (
266 str('url')
267 .replace(/^https?:\/\//, '')
268 .split('/')[0] ?? ''
269 )
270 if (str('query')) return clip(str('query'), 20)
271
272 return ''
273}
274
275async function refreshUsage($: EngineInterface) {
276 const plain = await $.session.usage()
277 const next: HudUsage = {
278 startedAt: plain.startedAt,
279 tokens: plain.context.tokens,
280 window: plain.context.window,
281 percent: plain.context.percent,
282 limits: plain.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
283 costUsd: plain.cost?.usd,
284 }
285 await update($, usage, () => next)
286}
287
288// A named flavor; `auto` is Cupertino and `catppuccin` latte or mocha, each light or dark with the Claude Code theme
289async function palette($: EngineInterface, chosen: string): Promise<Palette> {
290 if (chosen in FLAVORS) return FLAVORS[chosen as Flavor]
291 let isLight = false
292 try {
293 isLight = String((await $.settings.read()).theme ?? 'dark').includes('light')
294 } catch {
295 // No settings to read: dark
296 }
297 if (chosen === 'catppuccin') return isLight ? FLAVORS.latte : FLAVORS.mocha
298
299 return isLight ? CUPERTINO.light : CUPERTINO.dark
300}
301
302// Recounts tools, MCP servers, agents, skills and todos from the transcript, so a fresh load (a new process, a
303// --resume, the mod installed mid-session) shows what ran before it; counts the mod kept live are left alone
304async function backfill($: EngineInterface) {
305 const counts: Record<string, number> = {}
306 const mcpCounts: Record<string, number> = {}
307 let agentsDone = 0
308 const used: string[] = []
309 let list: HudTodo[] = []
310 for (const message of await $.session.messages()) {
311 for (const call of message.toolUses ?? []) {
312 const tool = String(call.tool)
313 const input = call.input
314 const mcp = tool.match(/^mcp__(.+?)__(.+)$/)
315 if (tool === 'Skill' && typeof input.skill === 'string') used.unshift(input.skill)
316 else if (tool === 'Agent') agentsDone += 1
317 else if (mcp) {
318 const server = serverName(mcp[1]!)
319 mcpCounts[server] = (mcpCounts[server] ?? 0) + 1
320 } else counts[tool] = (counts[tool] ?? 0) + 1
321 list = replayTodo(list, tool, input, call.result)
322 }
323 }
324 await update($, activity, old => {
325 const a = withDefaults(old)
326 const isFresh = Object.keys(a.counts).length === 0 && Object.keys(a.mcpCounts).length === 0 && a.agentsDone === 0
327 return isFresh ? { ...a, counts, mcpCounts, agentsDone } : a
328 })
329 await update($, skills, old => [...new Set([...old, ...used])].slice(0, 6))
330 await update($, todos, old => (old.length === 0 ? list : old))
331}
332
333// Applies one TodoWrite, TaskCreate or TaskUpdate call from the transcript to the todo list
334export function replayTodo(list: HudTodo[], tool: string, input: Record<string, unknown>, result: unknown): HudTodo[] {
335 const str = (v: unknown) => (typeof v === 'string' ? v : undefined)
336 if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
337 return input.todos.map((t: Record<string, unknown>, i: number) => ({
338 id: String(i),
339 text: str(t.content) ?? '',
340 active: str(t.activeForm) ?? str(t.content) ?? '',
341 status: (t.status as HudTodo['status']) ?? 'pending',
342 }))
343 }
344 if (tool === 'TaskCreate') {
345 const id = str((result as { task?: { id?: unknown } } | undefined)?.task?.id)
346 const text = str(input.subject) ?? ''
347 return id ? [...list, { id, text, active: str(input.activeForm) ?? text, status: 'pending' }] : list
348 }
349 if (tool === 'TaskUpdate') {
350 const id = str(input.taskId)
351 const status = str(input.status)
352 if (status === 'deleted') return list.filter(t => t.id !== id)
353 return list.map(t =>
354 t.id === id
355 ? {
356 ...t,
357 text: str(input.subject) ?? t.text,
358 active: str(input.activeForm) ?? t.active,
359 status: (status as HudTodo['status'] | undefined) ?? t.status,
360 }
361 : t,
362 )
363 }
364
365 return list
366}
367
368async function tick($: EngineInterface) {
369 const t = await $.clock.now()
370 await update($, now, () => t)
371}
372
373async function refreshGit($: EngineInterface) {
374 try {
375 const { exitCode, stdout } = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], {
376 timeoutMs: 5000,
377 })
378 if (exitCode !== 0) {
379 await update($, git, () => null)
380 return
381 }
382 const info: HudGit = { branch: '', ahead: 0, behind: 0, dirty: 0 }
383 for (const line of stdout.split('\n')) {
384 if (line.startsWith('# branch.head ')) info.branch = line.slice(14)
385 else if (line.startsWith('# branch.ab ')) {
386 const [, a, b] = line.match(/\+(\d+) -(\d+)/) ?? []
387 info.ahead = Number(a ?? 0)
388 info.behind = Number(b ?? 0)
389 } else if (line && !line.startsWith('#')) info.dirty += 1
390 }
391 await update($, git, () => info)
392 } catch {
393 await update($, git, () => null)
394 }
395}
396
397type Config = {
398 palette: string
399 barWidth: number
400 warnAt: number
401 show: Record<'git' | 'cost' | 'duration' | 'tools' | 'skills' | 'agents' | 'todos' | 'hint', boolean>
402}
403
404// The manifest's userConfig, defaults filled in and numbers held to a sane range
405export function readConfig(options: Record<string, unknown>): Config {
406 const num = (key: string, fallback: number, min: number, max: number) => {
407 const n = Number(options[key])
408 return Number.isFinite(n) ? Math.min(max, Math.max(min, Math.round(n))) : fallback
409 }
410 const flag = (key: string) => options[key] !== false
411
412 return {
413 palette: String(options.palette ?? 'auto'),
414 barWidth: num('barWidth', 10, 4, 30),
415 warnAt: num('warnAt', 85, 1, 100),
416 show: {
417 git: flag('showGit'),
418 cost: flag('showCost'),
419 duration: flag('showDuration'),
420 tools: flag('showTools'),
421 skills: flag('showSkills'),
422 agents: flag('showAgents'),
423 todos: flag('showTodos'),
424 hint: flag('keepHint'),
425 },
426 }
427}
428
429export const register: Register = (on, options) => {
430 const cfg = readConfig(options as Record<string, unknown>)
431
432 on('session.start', async ($, e, next) => {
433 const started = await next(e)
434 void $.session.model().then(m => update($, model, () => m))
435 await tick($)
436 void refreshUsage($)
437 void refreshGit($)
438 void backfill($).catch(() => undefined)
439 $.clock.every(30_000, async () => {
440 await tick($)
441 await refreshGit($)
442 })
443
444 return started
445 })
446
447 on('session.measure', async ($, e, next) => {
448 const ran = await next(e)
449 await refreshUsage($)
450 const m = await $.session.model()
451 await update($, model, () => m)
452
453 return ran
454 })
455
456 on('turn.complete', async ($, e, next) => {
457 const ran = await next(e)
458 // A call abandoned mid-flight (an interrupt) may never reach its finally: nothing runs between turns
459 await update($, activity, old => ({ ...withDefaults(old), running: [] }))
460 await tick($)
461 await refreshGit($)
462
463 return ran
464 })
465
466 on('session.end', { reason: 'clear' }, async ($, e, next) => {
467 await update($, activity, () => EMPTY_ACTIVITY)
468 await update($, todos, () => [])
469 await update($, skills, () => [])
470 void refreshUsage($)
471
472 return next(e)
473 })
474
475 on('tool.call', async ($, e, next) => {
476 const tool = String(e.tool)
477 const input = e as unknown as Record<string, unknown>
478 // Skills are counted by skill.prompt, which also sees /name and preloads
479 if (tool === 'Skill') return next(e)
480 const mcp = tool.match(/^mcp__(.+?)__(.+)$/)
481 const kind = tool === 'Agent' ? 'agent' : mcp ? 'mcp' : 'tool'
482 const server = mcp ? serverName(mcp[1]!) : ''
483 const run: HudRun = {
484 id: e.tool_use_id,
485 tool: kind === 'agent' && typeof input.subagent_type === 'string' ? input.subagent_type : mcp ? server : tool,
486 label: mcp ? mcpToolLabel(server, mcp[2]!) : labelFor(tool, input),
487 kind,
488 }
489 await update($, activity, old => {
490 const a = withDefaults(old)
491 return { ...a, running: [...a.running, run] }
492 })
493 try {
494 return await next(e)
495 } finally {
496 await update($, activity, old => {
497 const a = withDefaults(old)
498 return {
499 running: a.running.filter(r => r.id !== run.id),
500 counts: kind === 'tool' ? { ...a.counts, [tool]: (a.counts[tool] ?? 0) + 1 } : a.counts,
501 mcpCounts: kind === 'mcp' ? { ...a.mcpCounts, [server]: (a.mcpCounts[server] ?? 0) + 1 } : a.mcpCounts,
502 agentsDone: a.agentsDone + (kind === 'agent' ? 1 : 0),
503 }
504 })
505 }
506 })
507
508 on('skill.prompt', async ($, e, next) => {
509 const ran = await next(e)
510 await update($, skills, list => [e.skill, ...list.filter(s => s !== e.skill)].slice(0, 6))
511
512 return ran
513 })
514
515 // Todo and task tools update the list as they land, the same way backfill replays them
516 on('tool.call', { tool: ['TodoWrite', 'TaskCreate', 'TaskUpdate'] }, async ($, e, next) => {
517 const ran = await next(e)
518 if (ran.deny === undefined && !ran.isError) {
519 const input = e as unknown as Record<string, unknown>
520 await update($, todos, list => replayTodo(list, String(e.tool), input, ran.result))
521 }
522
523 return ran
524 })
525
526 // Drawn in place of the hint line under the prompt, the engine's hint kept beneath the box
527 on('ui.render', { component: 'PromptHint' }, async ($, e) => {
528 const { Box, Text } = $.ui.resolve(e)
529 const p = await palette($, cfg.palette)
530 const u = await read($, usage)
531 const g = await read($, git)
532 const a = withDefaults(await read($, activity))
533 const list = await read($, todos)
534 const skillList = await read($, skills)
535 const modelName = await read($, model)
536 const at = await read($, now)
537
538 const dot = <Text color={p.surface2}> · </Text>
539
540 // Every bar is BAR cells, filled █ over a dim ░ track as claude-hud draws them; the three share one row
541 const BAR = cfg.barWidth
542 const label = (text: string) => <Text color={p.subtext}>{text} </Text>
543 const meter = (kind: string, name: string, percent: number, detail?: string) => {
544 const filled = Math.min(BAR, Math.round((percent / 100) * BAR))
545 const color = meterColor(p, kind, percent, cfg.warnAt)
546 return (
547 <Text>
548 {label(name)}
549 <Text color={color}>{'█'.repeat(filled)}</Text>
550 <Text color={p.surface2}>{'░'.repeat(BAR - filled)}</Text>
551 <Text color={percent >= cfg.warnAt ? p.red : p.text}> {percent}%</Text>
552 {detail && <Text color={p.overlay}> · {detail}</Text>}
553 </Text>
554 )
555 }
556
557 // Header: model and git on the left, cost and session time on the right
558 const header = (
559 <Box columnGap={3}>
560 <Text>
561 <Text color={p.mauve}>◆ </Text>
562 <Text color={p.text} bold>
563 {prettyModel(modelName)}
564 </Text>
565 {cfg.show.git && g && g.branch && (
566 <Text>
567 {dot}
568 <Text color={p.green}> {g.branch}</Text>
569 {g.ahead > 0 && <Text color={p.sky}> ↑{g.ahead}</Text>}
570 {g.behind > 0 && <Text color={p.peach}> ↓{g.behind}</Text>}
571 {g.dirty > 0 && <Text color={p.yellow}> ●{g.dirty}</Text>}
572 </Text>
573 )}
574 </Text>
575 <Text>
576 {cfg.show.cost && u?.costUsd !== undefined && (
577 <Text>
578 <Text color={p.yellow}>$</Text>
579 <Text color={p.text}>{u.costUsd.toFixed(2)}</Text>
580 </Text>
581 )}
582 {cfg.show.cost && cfg.show.duration && u?.costUsd !== undefined && u && at > 0 && dot}
583 {cfg.show.duration && u && at > 0 && (
584 <Text>
585 <Text color={p.lavender}>⏱ </Text>
586 <Text color={p.text}>{formatDuration(at - u.startedAt)}</Text>
587 </Text>
588 )}
589 </Text>
590 </Box>
591 )
592
593 // Context fill: one colour, the tokens beside it
594 const percent = u?.tokens !== undefined ? (u.percent ?? Math.round((u.tokens / u.window) * 100)) : undefined
595 const tokensText = u?.tokens !== undefined ? `${shortCount(u.tokens)}/${shortCount(u.window)}` : undefined
596 const contextRow =
597 percent !== undefined ? (
598 meter('ctx', 'ctx', percent, tokensText)
599 ) : (
600 <Text>
601 {label('ctx')}
602 <Text color={p.overlay}>waiting for the first response</Text>
603 </Text>
604 )
605
606 // Context, then each rate-limit window with the time to its reset, side by side
607 const meters = (
608 <Box flexWrap="wrap" columnGap={3}>
609 {contextRow}
610 {(u?.limits ?? []).map(l => {
611 const resetsIn = l.resetsAt && at > 0 ? Date.parse(l.resetsAt) - at : NaN
612 return meter(
613 l.kind,
614 limitLabel(l.kind),
615 l.percentUsed,
616 Number.isFinite(resetsIn) && resetsIn > 0 ? formatDuration(resetsIn) : undefined,
617 )
618 })}
619 </Box>
620 )
621
622 // Activity: what runs now, then what ran this session
623 const runningTools = a.running.filter(r => r.kind === 'tool')
624 const runningAgents = a.running.filter(r => r.kind === 'agent')
625 const runningMcp = a.running.filter(r => r.kind === 'mcp')
626 const top = Object.entries(a.counts)
627 .sort((x, y) => y[1] - x[1])
628 .slice(0, 5)
629 const hasActivity = cfg.show.tools && (runningTools.length > 0 || top.length > 0)
630 const activityRow = hasActivity ? (
631 <Box flexWrap="wrap" columnGap={2}>
632 {runningTools.slice(0, 2).map(r => (
633 <Text>
634 <Text color={p.yellow}>◐ </Text>
635 <Text color={p.text}>{r.tool}</Text>
636 {r.label && <Text color={p.overlay}> {r.label}</Text>}
637 </Text>
638 ))}
639 {top.length > 0 && (
640 <Text>
641 <Text color={p.green}>✓ </Text>
642 {top.map(([tool, n], i) => (
643 <Text>
644 {i > 0 && <Text color={p.surface2}> </Text>}
645 <Text color={p.subtext}>{tool}</Text>
646 <Text color={p.overlay}>×{n}</Text>
647 </Text>
648 ))}
649 </Text>
650 )}
651 </Box>
652 ) : null
653
654 // Skills loaded this session (latest first) and MCP servers called
655 const mcpUsed = Object.entries(a.mcpCounts).sort((x, y) => y[1] - x[1])
656 const skillRow =
657 cfg.show.skills && (skillList.length > 0 || runningMcp.length > 0 || mcpUsed.length > 0) ? (
658 <Box flexWrap="wrap" columnGap={3}>
659 {skillList.length > 0 && (
660 <Text>
661 <Text color={p.pink}>✦ </Text>
662 <Text color={p.text} bold>
663 {skillList[0]}
664 </Text>
665 {skillList.slice(1, 4).map(name => (
666 <Text>
667 {dot}
668 <Text color={p.subtext}>{name}</Text>
669 </Text>
670 ))}
671 </Text>
672 )}
673 {runningMcp.slice(0, 2).map(r => (
674 <Text>
675 <Text color={p.yellow}>◐ </Text>
676 <Text color={p.sapphire}>{r.tool}</Text>
677 <Text color={p.overlay}> {r.label}</Text>
678 </Text>
679 ))}
680 {mcpUsed.length > 0 && (
681 <Text>
682 <Text color={p.sapphire}>⧉ </Text>
683 {mcpUsed.slice(0, 4).map(([server, n], i) => (
684 <Text>
685 {i > 0 && <Text> </Text>}
686 <Text color={p.subtext}>{server}</Text>
687 <Text color={p.overlay}>×{n}</Text>
688 </Text>
689 ))}
690 </Text>
691 )}
692 </Box>
693 ) : null
694
695 const agentRow =
696 cfg.show.agents && (runningAgents.length > 0 || a.agentsDone > 0) ? (
697 <Box flexWrap="wrap" columnGap={2}>
698 {runningAgents.slice(0, 3).map(r => (
699 <Text>
700 <Text color={p.mauve}>⚙ </Text>
701 <Text color={p.text}>{r.tool}</Text>
702 {r.label && <Text color={p.overlay}> {r.label}</Text>}
703 </Text>
704 ))}
705 {runningAgents.length > 3 && <Text color={p.overlay}>+{runningAgents.length - 3} more</Text>}
706 {a.agentsDone > 0 && <Text color={p.overlay}>{a.agentsDone} agents</Text>}
707 </Box>
708 ) : null
709
710 const done = list.filter(t => t.status === 'completed').length
711 const current = list.find(t => t.status === 'in_progress')
712 const todoRow =
713 cfg.show.todos && list.length > 0 ? (
714 <Text wrap="truncate-end">
715 <Text color={done === list.length ? p.green : p.blue}>{done === list.length ? '✔ ' : '▸ '}</Text>
716 <Text color={p.text} bold>
717 {done}/{list.length}
718 </Text>
719 <Text color={p.subtext}>
720 {' '}
721 {current ? current.active || current.text : done === list.length ? 'all done' : 'todos'}
722 </Text>
723 </Text>
724 ) : null
725
726 const hud = (
727 <Box flexDirection="column" alignSelf="flex-start">
728 {header}
729 {meters}
730 {activityRow}
731 {skillRow}
732 {agentRow}
733 {todoRow}
734 </Box>
735 )
736 const hint = e.props.tail ? `${e.props.hint} ${e.props.tail}` : e.props.hint
737
738 return (
739 <Box flexDirection="column">
740 {hud}
741 {cfg.show.hint && hint && <Text dimColor>{hint}</Text>}
742 </Box>
743 )
744 })
745}
746types/index.d.ts 43 lines1export type HudLimit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type HudUsage = {
4 startedAt: number
5 tokens?: number
6 window: number
7 percent?: number
8 limits: HudLimit[]
9 costUsd?: number
10}
11
12export type HudGit = { branch: string; ahead: number; behind: number; dirty: number }
13
14export type HudRun = { id: string; tool: string; label: string; kind: 'tool' | 'agent' | 'mcp' }
15
16export type HudActivity = {
17 running: HudRun[]
18 counts: Record<string, number>
19 mcpCounts: Record<string, number>
20 agentsDone: number
21}
22
23export type HudTodo = {
24 id: string
25 text: string
26 active: string
27 status: 'pending' | 'in_progress' | 'completed'
28}
29
30declare module 'claude-code' {
31 interface PluginState {
32 'glance': {
33 usage: HudUsage | null
34 git: HudGit | null
35 activity: HudActivity
36 todos: HudTodo[]
37 skills: string[]
38 model: string
39 now: number
40 }
41 }
42}
43