Dot-matrix HUD for Claude Code: chat tokens by model, 5-hour limit use, cache timer and code stats. Local only, no model calls.

Glyph HUD is a plugin for Claude Code. It shows the token use of the current chat in a dot-matrix pane. The design follows the monochrome dot-matrix style of Nothing devices.
The plugin makes no model calls. It reads local files and the figures that Claude Code already has. It sends no data to any server.
<img src="docs/pane.png" alt="The Glyph pane in the Claude Code desktop app" width="320">
The pane opens at the start of each session. The /glyph command opens it again.
The status line under the prompt shows a short summary:
◉ 5.14M tok · $2.95 · cache 52:10
cache 52:10 is the time until the prompt cache expires. The value cache ● warm shows during a turn. The value cache ○ cold shows after the cache expires.✎ and the approximate size of the draft. The plugin uses about 4 characters for each token. It is not an exact count.The desktop app shows the pane as an image. The terminal shows the same data as text, with ● and · characters for the dots.
/glyph command to open the pane.The plugin reads the transcript file of the chat and the transcript files of its subagents. Each response row records the model and the token use. The plugin counts each response one time.
The count includes the full chat, also the part before the plugin loaded. Requests that the transcript does not record are not in the count. An example is a compaction summary.
Each request resets the lifetime of the prompt cache. The plugin reads the time of the last response and the cache lifetime from the transcript. The lifetime is 1 hour or 5 minutes.
| Setting | Default | Effect |
|---|---|---|
blockAttribution | false | Denies git commit commands that contain a Co-Authored-By trailer. |
Change the setting in the /config menu.
PATH for transcripts larger than 4 MB. The plugin reads smaller transcripts without Python. /plugin marketplace add MridulNegi2005/glyph-hud
/plugin install glyph-hud@glyph-hud
To load the plugin from a local folder, start Claude Code with this flag:
claude --plugin-dir path/to/glyph-hud
MIT. See LICENSE.
hooks/register.tsx 674 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ChatUsage, GlyphLifetime, GlyphStats, ModelTally } from '../types'
5
6const PANE = 'glyph-hud'
7const RED = '#D71921'
8const LIT = '#F2F2F2'
9const OFF = '#1F1F1F'
10const MUTED = '#8A8A8A'
11const MAX_READ = 4_000_000
12
13const EMPTY: GlyphStats = {
14 turns: 0,
15 tools: {},
16 toolCalls: 0,
17 edits: 0,
18 files: [],
19 added: 0,
20 removed: 0,
21 bashRuns: 0,
22 bashFails: 0,
23 streak: 0,
24 bestStreak: 0,
25 longestTurnMs: 0,
26 blocked: 0,
27}
28
29const NO_LIFETIME: GlyphLifetime = {
30 sessions: 0,
31 toolCalls: 0,
32 added: 0,
33 removed: 0,
34 edits: 0,
35 bestStreak: 0,
36}
37
38const NO_USAGE: ChatUsage = { models: [], scannedAt: 0 }
39
40const stats = atom({ plugin: 'glyph-hud', key: 'stats' } as const, EMPTY)
41const lifetime = atom({ plugin: 'glyph-hud', key: 'lifetime' } as const, NO_LIFETIME)
42const usage = atom({ plugin: 'glyph-hud', key: 'usage' } as const, NO_USAGE)
43const isLive = atom({ plugin: 'glyph-hud', key: 'isLive' } as const, false)
44
45// 5x7 dot-matrix glyphs, one string per row.
46const FONT: Record<string, string[]> = {
47 '0': ['01110', '10001', '10011', '10101', '11001', '10001', '01110'],
48 '1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
49 '2': ['01110', '10001', '00001', '00010', '00100', '01000', '11111'],
50 '3': ['11111', '00010', '00100', '00010', '00001', '10001', '01110'],
51 '4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'],
52 '5': ['11111', '10000', '11110', '00001', '00001', '10001', '01110'],
53 '6': ['00110', '01000', '10000', '11110', '10001', '10001', '01110'],
54 '7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
55 '8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'],
56 '9': ['01110', '10001', '10001', '01111', '00001', '00010', '01100'],
57 '.': ['00000', '00000', '00000', '00000', '00000', '01100', '01100'],
58 K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
59 M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
60}
61
62// A real `git commit` invocation at the start of a command or after ; && || |, not the words in prose.
63const GIT_COMMIT = /(^|[;&|]\s*)git(\s+-\S+(\s+\S+)?)*\s+commit\b/m
64
65const lineCount = (s: string | undefined) => (s ? s.split('\n').length : 0)
66
67const shortTool = (name: string) => {
68 const parts = name.split('__')
69 return parts.length >= 3 ? parts.slice(2).join('__') : name
70}
71
72const shortModel = (m: string) => m.replace(/^claude-/, '').replace(/-\d{8}$/, '')
73
74const compact = (n: number) =>
75 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(2)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n)
76
77const esc = (s: string) =>
78 s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
79
80const totalOf = (t: ModelTally) => t.input + t.cacheWrite + t.cacheRead + t.output
81
82const sumUsage = (u: ChatUsage) =>
83 u.models.reduce(
84 (a, t) => ({
85 requests: a.requests + t.requests,
86 input: a.input + t.input,
87 cacheWrite: a.cacheWrite + t.cacheWrite,
88 cacheRead: a.cacheRead + t.cacheRead,
89 output: a.output + t.output,
90 }),
91 { requests: 0, input: 0, cacheWrite: 0, cacheRead: 0, output: 0 },
92 )
93
94const topTools = (s: GlyphStats, n: number) =>
95 Object.entries(s.tools)
96 .sort((a, b) => b[1] - a[1])
97 .slice(0, n)
98
99const codeTiles = (s: GlyphStats, life: GlyphLifetime): [string, string, boolean?][] => [
100 ['TOOL CALLS', String(s.toolCalls)],
101 ['TURNS', String(s.turns)],
102 ['EDITS', String(s.edits)],
103 ['FILES', String(s.files.length)],
104 ['LINES', `+${compact(s.added)} -${compact(s.removed)}`],
105 ['BASH ERR', `${s.bashFails}/${s.bashRuns}`, s.bashFails > 0],
106 ['STREAK', `${s.streak} / ${s.bestStreak}`],
107 ['LONGEST', `${Math.round(s.longestTurnMs / 1000)}s`],
108 ['ALL-TIME', `${compact(life.toolCalls + s.toolCalls)} calls`],
109]
110
111// --- transcript scan -------------------------------------------------------
112
113let transcriptPath: string | undefined
114let isScanning = false
115let lastSignature = ''
116let cacheAt = 0
117let cacheTtlMs = 60 * 60 * 1000
118let isTurnRunning = false
119let baseStatus: string | undefined
120let draftStatus: string | undefined
121
122async function findTranscript($: EngineInterface) {
123 if (transcriptPath) return transcriptPath
124 const configDir =
125 (await $.env.get('CLAUDE_CONFIG_DIR')) ??
126 `${(await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''}/.claude`
127 const id = await $.session.id()
128 for (const dir of [await $.session.root(), await $.session.cwd()]) {
129 const candidate = `${configDir}/projects/${dir.replace(/[^a-zA-Z0-9]/g, '-')}/${id}.jsonl`
130 try {
131 if ((await $.fs.stat(candidate)).kind === 'file') return (transcriptPath = candidate)
132 } catch {}
133 }
134 return undefined
135}
136
137async function transcriptFiles($: EngineInterface, main: string) {
138 const files = [main]
139 const subDir = `${main.replace(/\.jsonl$/, '')}/subagents`
140 try {
141 for (const entry of await $.fs.list(subDir)) {
142 if (entry.kind === 'file' && entry.name.endsWith('.jsonl')) files.push(`${subDir}/${entry.name}`)
143 }
144 } catch {}
145 return files
146}
147
148async function linesOf($: EngineInterface, path: string, size: number) {
149 if (size <= MAX_READ) return (await $.fs.read(path)).split('\n')
150 const script = `${$.plugin.root}/scripts/usage_lines.py`
151 const out = await $.process.run(['python', script, path])
152 return out.stdout.split('\n')
153}
154
155async function scanUsage($: EngineInterface, force = false) {
156 if (isScanning) return
157 isScanning = true
158 try {
159 const main = await findTranscript($)
160 const session = await $.session.usage()
161 const costUsd = session.cost?.usd
162 const contextPct = session.context.percent
163 const fiveHourPct = session.rateLimits.find(r => r.kind === 'five_hour')?.percentUsed
164
165 if (!main) {
166 await update($, usage, u => ({ ...u, costUsd, contextPct, fiveHourPct, error: 'transcript not found', scannedAt: Date.now() }))
167 return
168 }
169
170 const files = await transcriptFiles($, main)
171 const sizes = await Promise.all(files.map(f => $.fs.stat(f).then(s => s.size)))
172 const signature = files.map((f, i) => `${f}:${sizes[i]}`).join('|')
173 if (!force && signature === lastSignature) {
174 await update($, usage, u => ({ ...u, costUsd, contextPct, fiveHourPct }))
175 return
176 }
177
178 const seen = new Set<string>()
179 const byModel = new Map<string, ModelTally>()
180 for (let i = 0; i < files.length; i++) {
181 for (const line of await linesOf($, files[i]!, sizes[i]!)) {
182 if (!line.includes('"usage"') || !line.includes('"assistant"')) continue
183 let row: {
184 message?: { id?: string; model?: string; usage?: Record<string, number> & { cache_creation?: Record<string, number> } }
185 requestId?: string
186 timestamp?: string
187 }
188 try {
189 row = JSON.parse(line)
190 } catch {
191 continue
192 }
193 const m = row.message
194 const u = m?.usage
195 if (!m?.model || !u || m.model === '<synthetic>') continue
196 // One response is split across several rows that repeat the same usage.
197 const key = m.id ?? row.requestId
198 if (key) {
199 if (seen.has(key)) continue
200 seen.add(key)
201 }
202 const t = byModel.get(m.model) ?? { model: m.model, requests: 0, input: 0, cacheWrite: 0, cacheRead: 0, output: 0 }
203 t.requests += 1
204 t.input += u.input_tokens ?? 0
205 t.cacheWrite += u.cache_creation_input_tokens ?? 0
206 t.cacheRead += u.cache_read_input_tokens ?? 0
207 t.output += u.output_tokens ?? 0
208 byModel.set(m.model, t)
209
210 if (i === 0 && row.timestamp) {
211 const at = Date.parse(row.timestamp)
212 if (at > cacheAt) {
213 cacheAt = at
214 const cc = u.cache_creation
215 if ((cc?.ephemeral_1h_input_tokens ?? 0) > 0) cacheTtlMs = 60 * 60 * 1000
216 else if ((cc?.ephemeral_5m_input_tokens ?? 0) > 0) cacheTtlMs = 5 * 60 * 1000
217 }
218 }
219 }
220 }
221
222 lastSignature = signature
223 const models = [...byModel.values()].sort((a, b) => totalOf(b) - totalOf(a))
224 await update($, usage, () => ({ models, costUsd, contextPct, fiveHourPct, scannedAt: Date.now() }))
225 } catch (err) {
226 await update($, usage, u => ({ ...u, error: String(err).slice(0, 80) }))
227 } finally {
228 isScanning = false
229 await refreshStatus($)
230 }
231}
232
233// --- drawing ---------------------------------------------------------------
234
235function dotText(text: string, x: number, y: number, pitch: number, r: number, color = LIT) {
236 const out: string[] = []
237 let cx = x
238 for (const ch of text) {
239 const rows = FONT[ch] ?? FONT['0']!
240 rows.forEach((row, ri) => {
241 ;[...row].forEach((bit, ci) => {
242 out.push(`<circle cx="${cx + ci * pitch}" cy="${y + ri * pitch}" r="${r}" fill="${bit === '1' ? color : OFF}"/>`)
243 })
244 })
245 cx += 6 * pitch
246 }
247 return out.join('')
248}
249
250function dotBar(x: number, y: number, count: number, lit: number, gap: number, r: number, color: string) {
251 let s = ''
252 for (let d = 0; d < count; d++) s += `<circle cx="${x + d * gap}" cy="${y}" r="${r}" fill="${d < lit ? color : OFF}"/>`
253 return s
254}
255
256function svgHud(s: GlyphStats, life: GlyphLifetime, u: ChatUsage, live: boolean) {
257 const W = 400
258 const P = 20
259 const font = `font-family="ui-monospace, 'JetBrains Mono', Consolas, monospace"`
260 const label = (x: number, y: number, t: string, color = MUTED) =>
261 `<text x="${x}" y="${y}" ${font} font-size="10" letter-spacing="2" fill="${color}">${esc(t)}</text>`
262 const value = (x: number, y: number, t: string, color = LIT, size = 17) =>
263 `<text x="${x}" y="${y}" ${font} font-size="${size}" font-weight="700" fill="${color}">${esc(t)}</text>`
264 const out: string[] = []
265 const sum = sumUsage(u)
266 const total = sum.input + sum.cacheWrite + sum.cacheRead + sum.output
267
268 out.push(label(P, 32, 'GLYPH / HUD'))
269 out.push(`<circle cx="${W - P - 4}" cy="28" r="6" fill="${live ? RED : OFF}"/>`)
270
271 let y = 70
272 out.push(label(P, y, 'THIS CHAT · TOKENS'))
273 y += 22
274 const big = compact(total)
275 const pitch = Math.min(12, Math.floor((W - 2 * P) / (big.length * 6)))
276 out.push(dotText(big, P + pitch / 2, y, pitch, pitch * 0.36))
277 y += 7 * pitch + 22
278
279 const col3 = (W - 2 * P) / 3
280 out.push(label(P, y, 'COST'))
281 out.push(label(P + col3, y, 'CONTEXT'))
282 out.push(label(P + col3 * 2, y, 'REQUESTS'))
283 out.push(value(P, y + 24, u.costUsd === undefined ? '—' : `$${u.costUsd.toFixed(2)}`, RED))
284 out.push(value(P + col3, y + 24, u.contextPct === undefined ? '—' : `${u.contextPct}%`))
285 out.push(value(P + col3 * 2, y + 24, String(sum.requests)))
286 y += 44
287 const ctxDots = 36
288 out.push(dotBar(P + 4, y, ctxDots, Math.round(((u.contextPct ?? 0) / 100) * ctxDots), (W - 2 * P - 8) / (ctxDots - 1), 3, LIT))
289 y += 34
290
291 out.push(label(P, y, '5-HOUR LIMIT USED'))
292 out.push(
293 `<text x="${W - P}" y="${y}" text-anchor="end" ${font} font-size="14" font-weight="700" fill="${LIT}">${
294 u.fiveHourPct === undefined ? '—' : `${u.fiveHourPct}%`
295 }</text>`,
296 )
297 y += 16
298 out.push(dotBar(P + 4, y, ctxDots, Math.round(((u.fiveHourPct ?? 0) / 100) * ctxDots), (W - 2 * P - 8) / (ctxDots - 1), 3, RED))
299 y += 34
300
301 const col4 = (W - 2 * P) / 4
302 ;(
303 [
304 ['INPUT', sum.input],
305 ['CACHE W', sum.cacheWrite],
306 ['CACHE R', sum.cacheRead],
307 ['OUTPUT', sum.output],
308 ] as const
309 ).forEach(([name, n], i) => {
310 out.push(label(P + i * col4, y, name))
311 out.push(value(P + i * col4, y + 22, compact(n), LIT, 15))
312 })
313 y += 56
314
315 out.push(`<line x1="${P}" x2="${W - P}" y1="${y}" y2="${y}" stroke="${OFF}" stroke-width="2"/>`)
316 y += 30
317 out.push(label(P, y, 'SPREAD BY MODEL'))
318 y += 26
319 if (u.models.length === 0) {
320 out.push(label(P, y, u.error ?? 'no responses counted yet'))
321 y += 24
322 }
323 u.models.forEach((t, i) => {
324 const tt = totalOf(t)
325 const share = total > 0 ? tt / total : 0
326 const color = i === 0 ? RED : LIT
327 out.push(value(P, y, shortModel(t.model), color, 14))
328 out.push(
329 `<text x="${W - P}" y="${y}" text-anchor="end" ${font} font-size="14" font-weight="700" fill="${LIT}">${esc(
330 `${compact(tt)} · ${Math.round(share * 100)}%`,
331 )}</text>`,
332 )
333 y += 14
334 out.push(dotBar(P + 4, y, 36, Math.max(1, Math.round(share * 36)), (W - 2 * P - 8) / 35, 3, color))
335 y += 18
336 out.push(
337 label(
338 P,
339 y,
340 `in ${compact(t.input)} cw ${compact(t.cacheWrite)} cr ${compact(t.cacheRead)} out ${compact(t.output)} ${t.requests} req`,
341 ).replace('letter-spacing="2"', 'letter-spacing="0.5"'),
342 )
343 y += 32
344 })
345
346 out.push(`<line x1="${P}" x2="${W - P}" y1="${y}" y2="${y}" stroke="${OFF}" stroke-width="2"/>`)
347 y += 30
348 out.push(label(P, y, 'CODE · THIS SESSION'))
349 y += 24
350 const top = topTools(s, 4)
351 const max = top[0]?.[1] ?? 1
352 top.forEach(([name, count], i) => {
353 out.push(`<text x="${P}" y="${y + 4}" ${font} font-size="11" fill="${LIT}">${esc(shortTool(name).slice(0, 13))}</text>`)
354 out.push(dotBar(132, y, 22, Math.max(1, Math.round((count / max) * 22)), 10, 2.8, i === 0 ? RED : LIT))
355 out.push(`<text x="${W - P}" y="${y + 4}" text-anchor="end" ${font} font-size="11" fill="${MUTED}">${count}</text>`)
356 y += 18
357 })
358 if (top.length === 0) {
359 out.push(label(P, y, 'no tool calls yet'))
360 y += 18
361 }
362 y += 16
363
364 const grid = codeTiles(s, life)
365 grid.forEach(([name, v, isAlert], i) => {
366 const tx = P + (i % 3) * col3
367 const ty = y + Math.floor(i / 3) * 50
368 out.push(label(tx, ty, name))
369 out.push(value(tx, ty + 24, v, isAlert ? RED : LIT, 15))
370 })
371 y += Math.ceil(grid.length / 3) * 50
372
373 if (s.blocked > 0) {
374 out.push(label(P, y + 6, `● ${s.blocked} AI-ATTRIBUTION COMMIT${s.blocked === 1 ? '' : 'S'} BLOCKED`, RED))
375 y += 24
376 }
377
378 const H = y + 12
379 return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="${H * 3}" viewBox="0 0 ${W} ${H}"><rect width="${W}" height="${H}" rx="16" fill="#000"/>${out.join('')}</svg>`
380}
381
382function textDots(text: string) {
383 return Array.from({ length: 7 }, (_, r) =>
384 [...text].map(ch => (FONT[ch] ?? FONT['0']!)[r]!.replace(/1/g, '●').replace(/0/g, ' ')).join(' '),
385 )
386}
387
388// --- state helpers ---------------------------------------------------------
389
390async function refreshStatus($: EngineInterface) {
391 const u = await read($, usage)
392 const sum = sumUsage(u)
393 const total = sum.input + sum.cacheWrite + sum.cacheRead + sum.output
394 const parts = [`◉ ${compact(total)} tok`]
395 if (u.costUsd !== undefined) parts.push(`$${u.costUsd.toFixed(2)}`)
396 baseStatus = total === 0 ? undefined : parts.join(' · ')
397 paintStatus($)
398}
399
400function cacheLabel(now: number) {
401 if (isTurnRunning) return 'cache ● warm'
402 if (cacheAt === 0) return undefined
403 const left = cacheAt + cacheTtlMs - now
404 if (left <= 0) return 'cache ○ cold'
405 const sec = Math.floor(left / 1000)
406 return `cache ${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`
407}
408
409// ~4 characters per token for English and code; a rough count, not the tokenizer's.
410function estimateDraft($: EngineInterface, draft: string) {
411 if (!draft.trim()) {
412 draftStatus = undefined
413 paintStatus($)
414 return
415 }
416 draftStatus = `✎ ~${compact(Math.ceil(draft.length / 4))} tok`
417 paintStatus($)
418}
419
420function paintStatus($: EngineInterface) {
421 const cache = cacheLabel(Date.now())
422 const parts = [draftStatus, baseStatus, cache].filter(Boolean)
423 $.ui.status(parts.length ? parts.join(' · ') : undefined)
424}
425
426async function bump($: EngineInterface, fn: (s: GlyphStats) => GlyphStats) {
427 await update($, stats, s => fn(s ?? EMPTY))
428 await refreshStatus($)
429}
430
431async function flushLifetime($: EngineInterface) {
432 const [s, life] = await Promise.all([read($, stats), read($, lifetime)])
433 await $.store.set('lifetime', {
434 ...life,
435 toolCalls: life.toolCalls + s.toolCalls,
436 added: life.added + s.added,
437 removed: life.removed + s.removed,
438 edits: life.edits + s.edits,
439 bestStreak: Math.max(life.bestStreak, s.bestStreak),
440 } satisfies GlyphLifetime)
441}
442
443const touch = (s: GlyphStats, file: string, added: number, removed: number): GlyphStats => ({
444 ...s,
445 edits: s.edits + 1,
446 added: s.added + added,
447 removed: s.removed + removed,
448 files: s.files.includes(file) ? s.files : [...s.files, file],
449})
450
451// --- hooks -----------------------------------------------------------------
452
453export const register: Register = (on, options) => {
454 const blockAttribution = options.blockAttribution === true
455
456 on('session.start', async ($, e, next) => {
457 await $.command.register({ name: 'glyph', description: 'Open the Glyph HUD: chat tokens by model and code stats' })
458
459 const life = await read($, lifetime)
460 if (life.sessions === 0) {
461 const stored = ((await $.store.get('lifetime')) as GlyphLifetime | undefined) ?? NO_LIFETIME
462 const loaded = { ...NO_LIFETIME, ...stored, sessions: stored.sessions + 1 }
463 await update($, lifetime, () => loaded)
464 await $.store.set('lifetime', loaded)
465 }
466
467 void $.ui.open({ id: PANE, title: 'Glyph' })
468 void scanUsage($, true)
469 $.clock.every(1000, () => paintStatus($))
470 return next(e)
471 })
472
473 on('command.run', { command: 'glyph' }, async $ => {
474 await $.ui.open({ id: PANE, title: 'Glyph' })
475 void scanUsage($, true)
476 return {}
477 })
478
479 on('classic.Stop', ($, e, next) => {
480 if (e.transcript_path) transcriptPath = e.transcript_path
481 return next(e)
482 })
483
484 on('session.measure', async ($, e, next) => {
485 void scanUsage($)
486 return next(e)
487 })
488
489 on('prompt.edit', async ($, e, next) => {
490 const box = await next(e)
491 estimateDraft($, box.text)
492 return box
493 })
494
495 on('prompt.submit', ($, e, next) => {
496 draftStatus = undefined
497 paintStatus($)
498 return next(e)
499 })
500
501 on('turn.start', async ($, e, next) => {
502 isTurnRunning = true
503 await update($, isLive, () => true)
504 paintStatus($)
505 return next(e)
506 })
507
508 on('turn.complete', async ($, e, next) => {
509 if (e.agentId === undefined) {
510 isTurnRunning = false
511 cacheAt = Math.max(cacheAt, Date.now())
512 await update($, isLive, () => false)
513 await bump($, s => ({ ...s, turns: s.turns + 1, longestTurnMs: Math.max(s.longestTurnMs, e.durationMs) }))
514 await flushLifetime($)
515 void scanUsage($)
516 }
517 return next(e)
518 })
519
520 on('tool.call', async ($, e, next) => {
521 const ran = await next(e)
522 if (ran.deny !== undefined) return ran
523 const isError = ran.isError === true
524 await bump($, s => {
525 const streak = isError ? 0 : s.streak + 1
526 return {
527 ...s,
528 toolCalls: s.toolCalls + 1,
529 tools: { ...s.tools, [e.tool]: (s.tools[e.tool] ?? 0) + 1 },
530 streak,
531 bestStreak: Math.max(s.bestStreak, streak),
532 }
533 })
534 return ran
535 })
536
537 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
538 if (blockAttribution && GIT_COMMIT.test(e.command) && /co-authored-by/i.test(e.command)) {
539 await bump($, s => ({ ...s, blocked: s.blocked + 1 }))
540 return { deny: 'glyph-hud: Co-Authored-By trailers are blocked by your settings. Remove the trailer and commit again.' }
541 }
542 const ran = await next(e)
543 if (ran.deny === undefined) {
544 const hasFailed = ran.isError === true
545 await bump($, s => ({ ...s, bashRuns: s.bashRuns + 1, bashFails: s.bashFails + (hasFailed ? 1 : 0) }))
546 }
547 return ran
548 })
549
550 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
551 const ran = await next(e)
552 if (ran.deny === undefined && ran.isError !== true) {
553 await bump($, s => touch(s, e.file_path, lineCount(e.new_string), lineCount(e.old_string)))
554 }
555 return ran
556 })
557
558 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
559 const ran = await next(e)
560 if (ran.deny === undefined && ran.isError !== true) {
561 await bump($, s => touch(s, e.file_path, lineCount(e.content), 0))
562 }
563 return ran
564 })
565
566 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
567 const [s, life, u, live] = await Promise.all([read($, stats), read($, lifetime), read($, usage), read($, isLive)])
568
569 if (e.surface !== 'terminal') {
570 const { Box, Svg, Button } = $.ui.resolve(e)
571 const sum = sumUsage(u)
572 return (
573 <Box flexDirection="column" gap={1}>
574 <Svg
575 source={svgHud(s, life, u, live)}
576 alt={`This chat used ${compact(sum.input + sum.cacheWrite + sum.cacheRead + sum.output)} tokens across ${u.models.length} models; ${s.toolCalls} tool calls, ${s.edits} edits`}
577 />
578 <Box flexDirection="row" gap={1}>
579 <Button key="rescan" label="Rescan" onPress={() => scanUsage($, true)} />
580 <Button key="reset" label="Reset code stats" onPress={() => bump($, () => EMPTY)} />
581 <Button key="close" label="Close" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
582 </Box>
583 </Box>
584 )
585 }
586
587 const { Box, Text, Button } = $.ui.resolve(e)
588 const width = Math.max(24, (e.props.bodyColumns ?? 40) - 2)
589 const barRoom = Math.max(6, Math.min(30, width - 22))
590 const sum = sumUsage(u)
591 const total = sum.input + sum.cacheWrite + sum.cacheRead + sum.output
592 const top = topTools(s, 4)
593 const max = top[0]?.[1] ?? 1
594
595 return (
596 <Box flexDirection="column">
597 <Box flexDirection="row" justifyContent="space-between">
598 <Text dimColor>G L Y P H / H U D</Text>
599 <Text color={live ? RED : MUTED}>{live ? '● LIVE' : '○ IDLE'}</Text>
600 </Box>
601 <Text dimColor>THIS CHAT · TOKENS</Text>
602 {textDots(compact(total)).map(row => (
603 <Text>{row}</Text>
604 ))}
605 <Text>
606 <Text color={RED}>{u.costUsd === undefined ? '—' : `$${u.costUsd.toFixed(2)}`}</Text>
607 <Text dimColor> ctx </Text>
608 {u.contextPct === undefined ? '—' : `${u.contextPct}%`}
609 <Text dimColor> req </Text>
610 {sum.requests}
611 </Text>
612 <Text>
613 <Text dimColor>5h limit used </Text>
614 {u.fiveHourPct === undefined ? '—' : `${u.fiveHourPct}%`}
615 </Text>
616 <Text dimColor>
617 in {compact(sum.input)} · cw {compact(sum.cacheWrite)} · cr {compact(sum.cacheRead)} · out {compact(sum.output)}
618 </Text>
619 <Text> </Text>
620 <Text dimColor>SPREAD BY MODEL</Text>
621 {u.models.length === 0 && <Text dimColor>{u.error ?? 'no responses counted yet'}</Text>}
622 {u.models.map((t, i) => {
623 const share = total > 0 ? totalOf(t) / total : 0
624 const lit = Math.max(1, Math.round(share * barRoom))
625 return (
626 <Box flexDirection="column">
627 <Box flexDirection="row">
628 <Text bold color={i === 0 ? RED : undefined}>
629 {shortModel(t.model).slice(0, 16).padEnd(17)}
630 </Text>
631 <Text color={i === 0 ? RED : undefined}>{'●'.repeat(lit)}</Text>
632 <Text dimColor>
633 {'·'.repeat(barRoom - lit)} {compact(totalOf(t))} {Math.round(share * 100)}%
634 </Text>
635 </Box>
636 <Text dimColor>
637 {' '}in {compact(t.input)} cw {compact(t.cacheWrite)} cr {compact(t.cacheRead)} out {compact(t.output)} · {t.requests} req
638 </Text>
639 </Box>
640 )
641 })}
642 <Text> </Text>
643 <Text dimColor>CODE · THIS SESSION</Text>
644 {top.map(([name, count], i) => {
645 const lit = Math.max(1, Math.round((count / max) * barRoom))
646 return (
647 <Box flexDirection="row">
648 <Text>{shortTool(name).slice(0, 16).padEnd(17)}</Text>
649 <Text color={i === 0 ? RED : undefined}>{'●'.repeat(lit)}</Text>
650 <Text dimColor>
651 {'·'.repeat(barRoom - lit)} {count}
652 </Text>
653 </Box>
654 )
655 })}
656 {codeTiles(s, life).map(([name, v, isAlert]) => (
657 <Box flexDirection="row">
658 <Text dimColor>{name.padEnd(12)}</Text>
659 <Text bold color={isAlert ? RED : undefined}>
660 {v}
661 </Text>
662 </Box>
663 ))}
664 {s.blocked > 0 && <Text color={RED}>● {s.blocked} AI-attribution commit(s) blocked</Text>}
665 <Box flexDirection="row" gap={1}>
666 <Button key="rescan" label="rescan" onPress={() => scanUsage($, true)} />
667 <Button key="reset" label="reset" onPress={() => bump($, () => EMPTY)} />
668 <Button key="close" label="close" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
669 </Box>
670 </Box>
671 )
672 })
673}
674types/index.d.ts 54 lines1export type GlyphStats = {
2 turns: number
3 tools: Record<string, number>
4 toolCalls: number
5 edits: number
6 files: string[]
7 added: number
8 removed: number
9 bashRuns: number
10 bashFails: number
11 streak: number
12 bestStreak: number
13 longestTurnMs: number
14 blocked: number
15}
16
17export type GlyphLifetime = {
18 sessions: number
19 toolCalls: number
20 added: number
21 removed: number
22 edits: number
23 bestStreak: number
24}
25
26export type ModelTally = {
27 model: string
28 requests: number
29 input: number
30 cacheWrite: number
31 cacheRead: number
32 output: number
33}
34
35export type ChatUsage = {
36 models: ModelTally[]
37 costUsd?: number
38 contextPct?: number
39 fiveHourPct?: number
40 error?: string
41 scannedAt: number
42}
43
44declare module 'claude-code' {
45 interface PluginState {
46 'glyph-hud': {
47 stats: GlyphStats
48 lifetime: GlyphLifetime
49 usage: ChatUsage
50 isLive: boolean
51 }
52 }
53}
54