SLOPSHOPPER

glyph-hud

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

newpaneguardcommandstatusprompt
v0.1.0MITupdated 2026-10-09MridulNegi2005/glyph-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · glyph-hud
│ ┃ Glyph ✕ › fix the failing auth test and add an audit log call │ ┃ G L Y P H / H U D ○ IDLE │ ┃ THIS CHAT · TOKENS ⏺ 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. │ ┃ $0.42 ctx 49% req 0 │ ┃ 5h limit used 31% ✻ Worked for 42s · done 4:20 PM │ ┃ in 0 · cw 0 · cr 0 · out 0 │ ┃ › /glyph │ ┃ SPREAD BY MODEL │ ┃ transcript not found │ ┃ │ ┃ CODE · THIS SESSION │ ┃ Bash ●●●●●●●●●●●●●●●●●●●●●●●●●●● │ ┃ Write ●●●●●●●●●●●●●●●············ │ ┃ Read ●●●●●●●●··················· │ ┃ Grep ●●●●●●●●··················· │ ┃ TOOL CALLS 9 │ ┃ TURNS 1 │ ┃ EDITS 3 │ ┃ FILES 3 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ glyph-hud: cache 59:59

Draws

Pane · Glyph
G L Y P H / H U D ○ IDLE THIS CHAT · TOKENS ●●● ● ● ● ●● ● ● ● ●● ● ● ● ●●● $0.42 ctx 49% req 0 5h limit used 31% in 0 · cw 0 · cr 0 · out 0 SPREAD BY MODEL transcript not found CODE · THIS SESSION Bash ●●●●●●●●●●●●●●●●●●●●●●●●●●●●●● 4 Write ●●●●●●●●●●●●●●●··············· 2 Read ●●●●●●●●······················ 1 Grep ●●●●●●●●······················ 1 TOOL CALLS 9 TURNS 1 EDITS 3 FILES 3 LINES +16 -1 BASH ERR 1/4 STREAK 3 / 5 LONGEST 42s ALL-TIME 9 calls [ rescan ] [ reset ] [ close ]
README

Glyph HUD

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">

What the plugin shows

The pane

The pane opens at the start of each session. The /glyph command opens it again.

  • Chat tokens. The total tokens of the current chat, in large dot-matrix digits.
  • Cost and context. The cost in US dollars, the context fill as a percentage, and the number of requests.
  • Token types. Input, cache-write, cache-read and output tokens.
  • Tokens by model. One row for each model in the chat. Each row shows the total, the share as a dot bar, and the four token types.
  • 5-hour limit. The part of the 5-hour usage limit that is used, as Claude Code reports it. All chats share this limit.
  • Code stats. Tool calls, the most frequent tools, edits, files, changed lines, Bash errors and the longest turn.

The status line

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.
  • While you type a message, the line starts with ✎ and the approximate size of the draft. The plugin uses about 4 characters for each token. It is not an exact count.

In the terminal

The desktop app shows the pane as an image. The terminal shows the same data as text, with ● and · characters for the dots.

  • In the fullscreen layout, the pane opens beside the transcript at the start of a session. The terminal must be 144 columns or wider.
  • In a narrower terminal, use the /glyph command to open the pane.
  • The status line is the same in the terminal and in the desktop app.

How the plugin counts

Chat tokens

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.

The cache timer

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.

Settings

SettingDefaultEffect
blockAttributionfalseDenies git commit commands that contain a Co-Authored-By trailer.

Change the setting in the /config menu.

Requirements

  • Claude Code 2.1.286 or newer. The plugin API is early access. A later release can change it.
  • Python 3 on the PATH for transcripts larger than 4 MB. The plugin reads smaller transcripts without Python.

Install

  1. Add the marketplace in Claude Code:
   /plugin marketplace add MridulNegi2005/glyph-hud
  1. Install the plugin:
   /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

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 674 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
674
types/index.d.ts 54 lines
1export 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