SLOPSHOPPER

glance

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

newspinnerguardpromptprocesstimer
v0.1.0MITupdated 2026-10-05VibeMage/claude-mod-glance
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · glance
› 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ◆ Opus 5.5 $0.42 · ⏱ 30m ctx █████░░░░░ 49% · 97k/200k 5h ███░░░░░░░ 31% ✓ Bash×4 Write×2 undefined×1 Read×1 Grep×1 ? for shortcuts

Draws

Prompt hint
◆ Opus 5.5 $0.42 · ⏱ 30m ctx █████░░░░░ 49% · 97k/200k 5h ███░░░░░░░ 31% ✓ Bash×4 Write×2 undefined×1 Read×1 Grep×1 ? for shortcuts
README

English | 简体中文

glance

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.

Install

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.

What it shows

RowContentsSource
HeaderModel (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()
BarsContext 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
ToolsTools running now (◐) and the session's most-used toolstool.call
Skills and MCPSkills loaded this session, latest first (✦); MCP calls running now (◐) and calls per server (⧉)skill.prompt, tool.call
AgentsSubagents running (⚙) and how many the session has startedtool.call on Agent
TodosProgress and the item in progress, from TodoWrite or the Task toolstool.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.

What glance runs and reads

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.

  • Runs one program, 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.
  • Reads, through the mod API: the session's model, context fill, rate-limit windows and cost ($.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).
  • Observes tool calls (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.
  • Keeps its figures in the session's mod state only. It writes no files and stores nothing across sessions.
  • Sends nothing anywhere: it makes no network calls and passes no data to git, whose only input is the fixed argument list above.

Privacy: PRIVACY.md.

Configure

Run /plugin configure glance@claude-mod-glance, or open /config and find the glance rows. Changes apply immediately.

OptionDefaultWhat it does
paletteautoauto: 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
barWidth10Cells in each bar (4–30)
warnAt85Percentage at which a bar and its figure turn red
showGittrueBranch, ahead/behind, changed files
showCosttrueSession cost
showDurationtrueSession duration
showToolstrueTools row
showSkillstrueSkills and MCP row
showAgentstrueAgents row
showTodostrueTodos row
keepHinttrueKeep Claude Code's own hint line (? for shortcuts, esc to interrupt) under the HUD

Known limitations

  • glance draws in place of the hint line under the prompt and redraws that hint as plain dim text, so the hint's own colours (for example the auto mode on pill) are lost. Turn the line off with keepHint: false.
  • A skill you invoke as /name is not in the transcript's tool calls, so it is only counted if glance was already loaded when you ran it.
  • Rate-limit bars appear only on a subscription, after the first response of the session reports them.

Develop

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.

License

MIT

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