Above-prompt HUD for Claude Code: context fill, 5h/7d rate limits with burn ETA, turn cost, cache hit, git, model and two animated dogs

A Claude Code mod that draws a usage HUD above the prompt. Two ASCII dogs keep an eye on your limits.
Claude Code 프롬프트 위에 사용량 HUD를 그려주는 mod입니다. 강아지 두 마리가 한도를 지켜봅니다.
__ 5h ████░░░░ 13% / \__ 7d █████░░░ 64% ctx ██░░░░░░ 23% 226k/1.0M
o-''|\_____/) ↻ 40m ( @\___ ↻ 17h40m last 1m54s · tools 13 +$0.80 2.2M↑ 10k↓
\_/|_) ) “공 물어올게” / O “장부 정리 중” cache 99%
\ __ / / (_____/ Opus 5.5 · medium AUTO $7.06 ⎇ main ✓
(_/ (_/ /_____/ U
Claude Code 터미널 세션의 프롬프트에서 다음을 입력합니다.
/plugin install usage-hud --marketplace Jang-seungminn/usage-hud
Add marketplace?에 y를 입력하고, scope를 고릅니다. 모든 세션에서 쓰려면 user scope를 선택합니다. 설치한 세션에서 바로 동작합니다.
| 항목 | 내용 |
|---|---|
| 5h / 7d | 사용 한도 게이지, 리셋까지 남은 시간, 리셋 전에 한도가 바닥날 것 같으면 ⚡N 후 소진 |
| ctx | context window 사용량 |
| 턴 | 진행 중에는 경과 시간과 도구 호출 수, 끝난 뒤에는 그 턴의 비용과 입출력 토큰 |
| cache | 마지막 응답의 prompt cache 적중률 |
| 세션 | 모델 · effort, 권한 모드 배지, 누적 비용 |
| git | 브랜치, ahead/behind, staged/수정/추적 안 된 파일 수 |
| agents / bg | 실행 중인 서브에이전트와 백그라운드 작업 수 |
알림(toast):
수치에 따라 상태가 바뀝니다.
| 상태 | 조건 |
|---|---|
| 여유 | 기본 |
| 작업 중 | Claude가 일하는 동안 |
| 완료 | 턴이 끝나고 20초 동안 |
| 진땀 | 50% 이상 |
| 패닉 | 80% 이상 |
| 소진 | 100% |
| 수면 | 10분 넘게 쉬는 중 |
/hud를 입력하면 강아지를 끄고 켭니다. 강아지는 band가 5줄을 쓸 수 있을 때만 나오고, 터미널이 130칸 이상이면 모든 정보가 한 줄로 나란히 배치됩니다.
claude --plugin-dir ./usage-hud # 이 폴더에서 바로 로드
claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 457 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Figures, HudGit, HudTurn, HudWork } from '../types'
5import {
6 MODE_BADGE, SPINNER, burnEta, cacheHit, effortColor, formatClock, formatDuration,
7 formatRemaining, formatTokens, gauge, hasReset, labelFor, levelOf, moodColor, moodOf,
8 parseGitStatus, quip, shortModel, sortLimits, sprite, SPRITE_ROWS, SPRITE_WIDTH,
9} from './lib'
10import type { Dog, Mood } from './lib'
11
12const figures = atom({ plugin: 'usage-hud', key: 'figures' } as const, { rateLimits: [] } as Figures)
13const now = atom({ plugin: 'usage-hud', key: 'now' } as const, 0)
14const frame = atom({ plugin: 'usage-hud', key: 'frame' } as const, 0)
15const turn = atom({ plugin: 'usage-hud', key: 'turn' } as const, {
16 isRunning: false, startedAt: 0, endedAt: 0, tools: 0, seed: 0,
17} as HudTurn)
18const git = atom({ plugin: 'usage-hud', key: 'git' } as const, null as HudGit | null)
19const gitError = atom({ plugin: 'usage-hud', key: 'gitError' } as const, null as string | null)
20const work = atom({ plugin: 'usage-hud', key: 'work' } as const, { subagents: 0, background: 0 } as HudWork)
21const mode = atom({ plugin: 'usage-hud', key: 'mode' } as const, null as string | null)
22const fired = atom({ plugin: 'usage-hud', key: 'fired' } as const, [] as string[])
23const isFun = atom({ plugin: 'usage-hud', key: 'isFun' } as const, true)
24
25const CTX_ALERT = 80
26const LIMIT_ALERT = 90
27const SLEEP_AFTER_MS = 10 * 60_000
28const CHEER_MS = 20_000
29// Columns: the widest quip and live counter, so neighbours never shift.
30const INFO_WIDTH = 24
31const BLOCK_WIDTH = SPRITE_WIDTH + INFO_WIDTH
32const GAP = 2
33const SESSION_MIN = 44
34const LIMIT_BAR = 8
35const CTX_BAR = 8
36const LIVE_WIDTH = 24
37
38let minuteTick: { cancel: () => void } | undefined
39let animTick: { cancel: () => void } | undefined
40let isGitRunning = false
41
42// Keep only the figures the band draws; the breakdown never rides along.
43function strip(c: Figures['context']): Figures['context'] {
44 return c && { tokens: c.tokens, window: c.window, percent: c.percent }
45}
46
47async function tick($: EngineInterface): Promise<void> {
48 const at = await $.clock.now()
49 await update($, now, () => at)
50 await update($, frame, f => f + 1)
51}
52
53async function refreshGit($: EngineInterface): Promise<void> {
54 if (isGitRunning) return
55 isGitRunning = true
56 let summary: HudGit | null = null
57 let error: string | null = null
58 try {
59 const repo = await $.session.repo()
60 if (repo) {
61 const r = await $.process.run(['git', 'status', '--porcelain=v1', '-b'], { cwd: repo.root, timeoutMs: 5000 })
62 if (r.exitCode === 0) summary = parseGitStatus(r.stdout)
63 else error = `exit ${r.exitCode}: ${r.stderr.trim().slice(0, 60)}`
64 }
65 } catch (err) {
66 error = String(err instanceof Error ? err.message : err).slice(0, 80)
67 } finally {
68 isGitRunning = false
69 }
70 await update($, git, () => summary)
71 await update($, gitError, () => error)
72}
73
74// Toast once when a gauge crosses its line; re-arm once it falls 5 points under.
75async function checkAlerts($: EngineInterface): Promise<void> {
76 const f = await read($, figures)
77 const at = await $.clock.now()
78 const done = new Set(await read($, fired))
79 const toasts: string[] = []
80
81 const gate = (key: string, pct: number, line: number, text: string) => {
82 if (pct >= line && !done.has(key)) {
83 done.add(key)
84 toasts.push(text)
85 } else if (pct < line - 5) {
86 done.delete(key)
87 }
88 }
89
90 const ctx = f.context?.percent
91 if (ctx !== undefined) gate('ctx', ctx, CTX_ALERT, `(°□°;) context ${ctx}% — /compact 할 타이밍!`)
92
93 for (const r of f.rateLimits) {
94 if (hasReset(r, at)) {
95 const key = `reset:${r.kind}:${r.resetsAt}`
96 if (r.percentUsed >= 50 && !done.has(key)) {
97 done.add(key)
98 toasts.push(`ヽ(•‿•)ノ ${labelFor(r.kind)} 한도 리셋! 다시 달려보자`)
99 }
100 continue
101 }
102 const left = formatRemaining(r.resetsAt, at)
103 gate(r.kind, r.percentUsed, LIMIT_ALERT, `(;°□°) ${labelFor(r.kind)} 한도 ${r.percentUsed}%${left ? ` — 리셋까지 ${left}` : ''}`)
104 }
105
106 await update($, fired, () => [...done].slice(-50))
107 for (const text of toasts) $.ui.toast(text, { timeoutMs: 6000 })
108}
109
110async function takeMode($: EngineInterface, permissionMode: string | undefined): Promise<void> {
111 if (permissionMode) await update($, mode, m => (m === permissionMode ? m : permissionMode))
112}
113
114export const register: Register = on => {
115 on('session.start', async ($, e, next) => {
116 const result = await next(e)
117 await $.command.register({ name: 'hud', description: 'usage-hud: 마스코트 켜기/끄기' })
118 const u = await $.session.usage()
119 const model = await $.session.model()
120 await update($, figures, f => ({ ...f, context: strip(u.context), rateLimits: u.rateLimits, cost: u.cost, model }))
121 // The first version wrote the status line; clear what it left.
122 $.ui.status(undefined)
123 await tick($)
124 await refreshGit($)
125 await checkAlerts($)
126 // One frame a second animates the cat and the live turn clock.
127 animTick?.cancel()
128 animTick = $.clock.every(1000, () => tick($))
129 minuteTick?.cancel()
130 minuteTick = $.clock.every(60_000, async () => {
131 await tick($)
132 await refreshGit($)
133 await checkAlerts($)
134 })
135 return result
136 })
137
138 on('command.run', { command: 'hud' }, async $ => {
139 const isOn = !(await read($, isFun))
140 await update($, isFun, () => isOn)
141 return { text: isOn ? '(•‿•) 마스코트 등장!' : '마스코트 퇴근. /hud 로 다시 부르기' }
142 })
143
144 on('session.measure', async ($, e, next) => {
145 // Measured after every turn, so a /model switch shows by the next one.
146 const model = await $.session.model()
147 await update($, figures, f => ({ ...f, context: strip(e.context), rateLimits: e.rateLimits, cost: e.cost, model }))
148 await checkAlerts($)
149 return next(e)
150 })
151
152 // Subagents raise no turn.start: this is the main loop's turn.
153 on('turn.start', async ($, e, next) => {
154 const started = await $.clock.now()
155 const cost = (await read($, figures)).cost?.usd
156 await update($, turn, () => ({
157 isRunning: true, startedAt: started, endedAt: 0, tools: 0, costAtStart: cost, seed: started / 1000,
158 }))
159 return next(e)
160 })
161
162 on('turn.step', async function* ($, e, next) {
163 const isMain = e.agentId === undefined
164 if (isMain) {
165 const effort = e.effort === undefined ? undefined : String(e.effort)
166 await update($, figures, f => (f.effort === effort ? f : { ...f, effort }))
167 }
168 const r = yield* next(e)
169 if (isMain && r && r.usage) {
170 const hit = cacheHit(r.usage)
171 await update($, turn, t => ({ ...t, cacheHit: hit }))
172 }
173 return r
174 })
175
176 on('tool.call', async ($, e, next) => {
177 await update($, turn, t => (t.isRunning ? { ...t, tools: t.tools + 1 } : t))
178 return next(e)
179 })
180
181 on('turn.complete', async ($, e, next) => {
182 const result = await next(e)
183 if (e.agentId !== undefined) return result
184 const ended = await $.clock.now()
185 const u = e.usage
186 await update($, turn, t => ({
187 ...t,
188 isRunning: false,
189 endedAt: ended,
190 tokensIn: u ? u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : t.tokensIn,
191 tokensOut: u ? u.output_tokens : t.tokensOut,
192 }))
193 await tick($)
194 await refreshGit($)
195 return result
196 })
197
198 on('classic.SubagentStart', async ($, e, next) => {
199 await update($, work, w => ({ ...w, subagents: w.subagents + 1 }))
200 return next(e)
201 })
202
203 on('classic.SubagentStop', async ($, e, next) => {
204 await update($, work, w => ({ ...w, subagents: Math.max(0, w.subagents - 1) }))
205 return next(e)
206 })
207
208 // The Stop snapshot is the truth: it reconciles any start/stop we missed.
209 on('classic.Stop', async ($, e, next) => {
210 const tasks = e.background_tasks ?? []
211 const subagents = tasks.filter(t => t.type === 'subagent').length
212 await update($, work, () => ({ subagents, background: tasks.length - subagents }))
213 await takeMode($, e.permission_mode)
214 return next(e)
215 })
216
217 on('classic.UserPromptSubmit', async ($, e, next) => {
218 await takeMode($, e.permission_mode)
219 return next(e)
220 })
221
222 on('classic.PostToolUse', async ($, e, next) => {
223 await takeMode($, e.permission_mode)
224 return next(e)
225 })
226
227 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
228 if (e.props.hasSurvey) return next(e)
229
230 const f = await read($, figures)
231 if (!f.context && f.rateLimits.length === 0 && !f.cost) return next(e)
232
233 const at = (await read($, now)) || Date.now()
234 const fr = await read($, frame)
235 const t = await read($, turn)
236 const g = await read($, git)
237 const gErr = await read($, gitError)
238 const w = await read($, work)
239 const m = await read($, mode)
240 const fun = await read($, isFun)
241 const isWorking = e.props.isWorking || t.isRunning
242
243 const { Box, Text } = $.ui.resolve(e)
244
245 const bar = (pct: number, width: number) => {
246 const level = levelOf(pct)
247 const [filled, empty] = gauge(pct, width)
248 return (
249 <Text>
250 <Text color={level}>{'█'.repeat(filled)}</Text>
251 <Text color="subtle">{'░'.repeat(empty)}</Text>
252 <Text color={level} bold> {pct}%</Text>
253 </Text>
254 )
255 }
256
257 const sinceEnd = !isWorking && t.endedAt ? at - t.endedAt : undefined
258 const moodFor = (pct: number): Mood =>
259 sinceEnd !== undefined && sinceEnd > SLEEP_AFTER_MS && pct < 50
260 ? 'sleep'
261 : sinceEnd !== undefined && sinceEnd < CHEER_MS && pct < 80
262 ? 'cheer'
263 : moodOf(pct, isWorking)
264
265 const room = Math.max(1, e.props.maxRows)
266 const cols = e.viewport?.columns ?? 120
267 const showDogs = fun && room >= SPRITE_ROWS && cols >= 2 * BLOCK_WIDTH + GAP
268 // Wide enough, the session column sits beside the dogs; otherwise below them.
269 const isOneRow = cols >= 2 * BLOCK_WIDTH + 2 * GAP + SESSION_MIN
270 const ctxPct = f.context?.percent
271
272 // ── one block per rate-limit window: its dog, its gauge, its countdown, its quip ──
273 const blocks = []
274 for (const r of sortLimits(f.rateLimits)) {
275 const label = labelFor(r.kind)
276 const isReset = hasReset(r, at)
277 const dog: Dog | undefined = r.kind === 'five_hour' ? 'puppy' : r.kind === 'seven_day' ? 'senior' : undefined
278 // The puppy also carries the context window: both run out within the session.
279 const pct = isReset ? 0 : dog === 'puppy' ? Math.max(r.percentUsed, ctxPct ?? 0) : r.percentUsed
280 const mood = moodFor(pct)
281 const color = moodColor(mood, dog === 'senior' ? 'suggestion' : 'claude')
282 const left = formatRemaining(r.resetsAt, at)
283 const eta = isReset ? undefined : burnEta(r, at)
284
285 const info = (
286 <Box flexDirection="column" width={INFO_WIDTH} flexShrink={0}>
287 <Text wrap="truncate">
288 <Text bold color={dog ? color : undefined}>{label.padEnd(3)}</Text>
289 {isReset ? <Text color="success">{'░'.repeat(LIMIT_BAR)} 리셋됨</Text> : bar(r.percentUsed, LIMIT_BAR)}
290 </Text>
291 <Text wrap="truncate">
292 {left ? <Text dimColor>↻ {left}</Text> : null}
293 {eta === undefined ? null : <Text color="error" bold> ⚡{formatDuration(eta)} 후 소진</Text>}
294 </Text>
295 {showDogs && dog ? <Text color={color} wrap="truncate">“{quip(dog, mood, t.seed)}”</Text> : null}
296 </Box>
297 )
298
299 blocks.push(
300 <Box key={r.kind} flexDirection="row" width={showDogs && dog ? BLOCK_WIDTH : INFO_WIDTH} flexShrink={0}>
301 {showDogs && dog ? (
302 <Box flexDirection="column" width={SPRITE_WIDTH} flexShrink={0}>
303 {sprite(dog, mood, fr).map((line, i) => (
304 <Text key={`l${i}`} color={color} bold>{line}</Text>
305 ))}
306 </Box>
307 ) : null}
308 {info}
309 </Box>,
310 )
311 }
312
313 // ── the session column: context, this turn, where we are ──
314 const live = []
315 const where = []
316
317 const ctxLine = f.context ? (
318 ctxPct === undefined || f.context.tokens === undefined ? (
319 <Text key="ctx" dimColor>ctx –/{formatTokens(f.context.window)}</Text>
320 ) : (
321 <Text key="ctx">
322 <Text dimColor>ctx </Text>
323 {bar(ctxPct, CTX_BAR)}
324 <Text dimColor> {formatTokens(f.context.tokens)}/{formatTokens(f.context.window)}</Text>
325 </Text>
326 )
327 ) : null
328
329 if (t.isRunning) {
330 live.push(
331 <Box key="live" width={LIVE_WIDTH} flexShrink={0}>
332 <Text>
333 <Text color="claude">{SPINNER[fr % SPINNER.length]} </Text>
334 <Text bold>{formatClock(at - t.startedAt)}</Text>
335 <Text dimColor> · tools </Text>
336 <Text bold>{t.tools}</Text>
337 </Text>
338 </Box>,
339 )
340 } else if (t.endedAt) {
341 live.push(
342 <Box key="live" width={LIVE_WIDTH} flexShrink={0}>
343 <Text dimColor>last {formatClock(t.endedAt - t.startedAt)} · tools {t.tools}</Text>
344 </Box>,
345 )
346 }
347
348 const turnCost = f.cost && t.costAtStart !== undefined ? f.cost.usd - t.costAtStart : undefined
349 if (turnCost !== undefined && turnCost > 0.0049) {
350 live.push(
351 <Text key="turn">
352 <Text color="claude">+${turnCost.toFixed(2)}</Text>
353 {t.tokensIn !== undefined && t.tokensOut !== undefined ? (
354 <Text dimColor> {formatTokens(t.tokensIn)}↑ {formatTokens(t.tokensOut)}↓</Text>
355 ) : null}
356 </Text>,
357 )
358 }
359
360 if (t.cacheHit !== undefined) {
361 live.push(
362 <Text key="cache">
363 <Text dimColor>cache </Text>
364 <Text color={t.cacheHit >= 70 ? 'success' : t.cacheHit >= 30 ? 'warning' : 'error'}>{t.cacheHit}%</Text>
365 </Text>,
366 )
367 }
368
369 if (f.model) {
370 where.push(
371 <Text key="model">
372 <Text color="suggestion" bold>{shortModel(f.model)}</Text>
373 {f.effort ? <Text color={effortColor(f.effort)}> · {f.effort}</Text> : null}
374 </Text>,
375 )
376 }
377
378 const badge = m ? MODE_BADGE[m] : undefined
379 if (badge) {
380 where.push(
381 <Text key="mode" color="inverseText" backgroundColor={badge.color} bold> {badge.label} </Text>,
382 )
383 }
384
385 if (f.cost) {
386 where.push(
387 <Text key="cost">
388 <Text color="claude" bold>${f.cost.usd.toFixed(2)}</Text>
389 </Text>,
390 )
391 }
392
393 if (g) {
394 where.push(
395 <Text key="git">
396 <Text color="suggestion">⎇ {g.branch}</Text>
397 {g.ahead ? <Text color="success"> ↑{g.ahead}</Text> : null}
398 {g.behind ? <Text color="warning"> ↓{g.behind}</Text> : null}
399 {g.staged ? <Text color="success"> +{g.staged}</Text> : null}
400 {g.modified ? <Text color="warning"> ~{g.modified}</Text> : null}
401 {g.untracked ? <Text dimColor> ?{g.untracked}</Text> : null}
402 {!g.staged && !g.modified && !g.untracked ? <Text color="success"> ✓</Text> : null}
403 </Text>,
404 )
405 } else if (gErr) {
406 where.push(<Text key="git" dimColor>⎇ git ✗ {gErr}</Text>)
407 }
408
409 if (w.subagents) {
410 where.push(
411 <Text key="agents">
412 <Text dimColor>agents </Text>
413 <Text color="claude" bold>{w.subagents}</Text>
414 </Text>,
415 )
416 }
417
418 if (w.background) {
419 where.push(
420 <Text key="bg">
421 <Text dimColor>bg </Text>
422 <Text color="warning" bold>{w.background}</Text>
423 </Text>,
424 )
425 }
426
427 // Short on room, context goes first, then the turn, then the workspace.
428 const rows = [ctxLine ? [ctxLine] : [], live, where].filter(r => r.length > 0).slice(0, room)
429 const session = (
430 <Box key="session" flexDirection="column" flexGrow={1} flexShrink={1}>
431 {rows.map((items, i) => (
432 <Box key={`row${i}`} flexDirection="row" flexWrap="wrap" columnGap={2}>
433 {items}
434 </Box>
435 ))}
436 </Box>
437 )
438
439 if (isOneRow) {
440 return (
441 <Box flexDirection="row" columnGap={GAP}>
442 {blocks}
443 {session}
444 </Box>
445 )
446 }
447 return (
448 <Box flexDirection="column">
449 <Box flexDirection="row" columnGap={GAP}>
450 {blocks}
451 </Box>
452 {session}
453 </Box>
454 )
455 })
456}
457hooks/lib.ts 216 lines1// Pure helpers for the HUD: no `$`, so they are unit-tested directly.
2
3export type Level = 'success' | 'warning' | 'error'
4export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
5
6const HOUR = 3_600_000
7export const WINDOW_MS: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 7 * 24 * HOUR }
8const BAR_WIDTH = 10
9
10export const levelOf = (pct: number): Level => (pct >= 80 ? 'error' : pct >= 50 ? 'warning' : 'success')
11
12export const gauge = (pct: number, width = BAR_WIDTH): [number, number] => {
13 const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
14 return [filled, width - filled]
15}
16
17export const formatTokens = (n: number): string =>
18 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
19
20export const formatDuration = (ms: number): string => {
21 const mins = Math.floor(ms / 60_000)
22 const d = Math.floor(mins / 1440)
23 const h = Math.floor((mins % 1440) / 60)
24 const m = mins % 60
25 return d > 0 ? `${d}d${h}h` : h > 0 ? `${h}h${m}m` : `${m}m`
26}
27
28export const formatClock = (ms: number): string => {
29 const s = Math.max(0, Math.floor(ms / 1000))
30 return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
31}
32
33export const formatRemaining = (resetsAt: string | undefined, now: number): string => {
34 if (!resetsAt) return ''
35 const ms = Date.parse(resetsAt) - now
36 return Number.isFinite(ms) && ms > 0 ? formatDuration(ms) : ''
37}
38
39export const hasReset = (limit: Limit, now: number): boolean =>
40 limit.resetsAt !== undefined && Date.parse(limit.resetsAt) <= now
41
42// five_hour → 5h, seven_day → 7d, seven_day_fable → 7d fable, spend_limit → spend
43export const labelFor = (kind: string): string =>
44 kind
45 .replace(/^five_hour/, '5h')
46 .replace(/^seven_day/, '7d')
47 .replace(/^spend_limit/, 'spend')
48 .replace(/_/g, ' ')
49
50const ORDER = ['five_hour', 'seven_day']
51const rank = (kind: string): number => {
52 const i = ORDER.indexOf(kind)
53 return i === -1 ? ORDER.length : i
54}
55export const sortLimits = <T extends Limit>(limits: readonly T[]): T[] =>
56 [...limits].sort((a, b) => rank(a.kind) - rank(b.kind))
57
58/**
59 * When the window runs dry at its average pace so far, in ms from `now`;
60 * undefined when it resets first, or there is too little to judge.
61 */
62export const burnEta = (limit: Limit, now: number): number | undefined => {
63 const span = WINDOW_MS[limit.kind.replace(/^(five_hour|seven_day).*/, '$1')]
64 if (!span || !limit.resetsAt || limit.percentUsed <= 0) return undefined
65 const resetAt = Date.parse(limit.resetsAt)
66 const elapsed = now - (resetAt - span)
67 if (!Number.isFinite(resetAt) || elapsed < 5 * 60_000) return undefined
68 if (limit.percentUsed >= 100) return 0
69 const eta = ((100 - limit.percentUsed) / limit.percentUsed) * elapsed
70 return eta < resetAt - now ? eta : undefined
71}
72
73export const cacheHit = (u: {
74 input_tokens: number
75 cache_read_input_tokens: number
76 cache_creation_input_tokens: number
77}): number | undefined => {
78 const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
79 return total > 0 ? Math.round((u.cache_read_input_tokens / total) * 100) : undefined
80}
81
82export type GitSummary = {
83 branch: string
84 ahead: number
85 behind: number
86 staged: number
87 modified: number
88 untracked: number
89}
90
91// `git status --porcelain=v1 -b`
92export const parseGitStatus = (out: string): GitSummary => {
93 const g: GitSummary = { branch: '?', ahead: 0, behind: 0, staged: 0, modified: 0, untracked: 0 }
94 for (const line of out.split(/\r?\n/)) {
95 if (line.startsWith('## ')) {
96 const head = line.slice(3)
97 g.branch = head.startsWith('No commits yet on ') ? head.slice(18) : head.split('...')[0]!.split(' ')[0]!
98 g.ahead = Number(/ahead (\d+)/.exec(head)?.[1] ?? 0)
99 g.behind = Number(/behind (\d+)/.exec(head)?.[1] ?? 0)
100 } else if (line.startsWith('??')) {
101 g.untracked++
102 } else if (line.length >= 2) {
103 if (line[0] !== ' ') g.staged++
104 if (line[1] !== ' ') g.modified++
105 }
106 }
107 return g
108}
109
110// claude-opus-5-5 → Opus 5.5, claude-fable-5-1[1m] → Fable 5.1
111export const shortModel = (id: string): string => {
112 const m = /claude-([a-z]+)-(\d+)-(\d+)/.exec(id)
113 if (!m || !m[1]) return id
114 return `${m[1][0]!.toUpperCase()}${m[1].slice(1)} ${m[2]}.${m[3]}`
115}
116
117export const effortColor = (effort: string): 'subtle' | 'text' | 'warning' | 'claude' =>
118 effort === 'low' ? 'subtle' : effort === 'xhigh' ? 'warning' : effort === 'max' ? 'claude' : 'text'
119
120// Modes worth a badge; `default` gets none.
121export const MODE_BADGE: Record<string, { label: string; color: 'planMode' | 'autoAccept' | 'warning' | 'error' }> = {
122 plan: { label: 'PLAN', color: 'planMode' },
123 acceptEdits: { label: 'EDITS OK', color: 'autoAccept' },
124 auto: { label: 'AUTO', color: 'warning' },
125 bypassPermissions: { label: 'YOLO', color: 'error' },
126 dontAsk: { label: 'NO ASK', color: 'error' },
127}
128
129// ── the fun part ─────────────────────────────────────────────
130
131export type Mood = 'sleep' | 'chill' | 'busy' | 'cheer' | 'sweat' | 'panic' | 'dead'
132export type Dog = 'puppy' | 'senior'
133
134export const moodOf = (pct: number, isWorking: boolean): Mood =>
135 pct >= 100 ? 'dead' : pct >= 80 ? 'panic' : pct >= 50 ? 'sweat' : isWorking ? 'busy' : 'chill'
136
137// Five-line dogs in plain ASCII, so every terminal gives them the same width.
138// The puppy (5h) is the classic standing dog; the senior (7d) the classic
139// big-head profile, panting, who moves at half the pace. A mood's frames say the
140// eyes, the tongue or legs, the tail and what floats beside the head.
141export const SPRITE_WIDTH = 17
142export const SPRITE_ROWS = 5
143type Sprite = readonly string[]
144type Frame = { eye: string; alt: boolean; tail: boolean; aside: string }
145
146const f = (eye: string, alt: boolean, tail: boolean, aside = ''): Frame => ({ eye, alt, tail, aside })
147
148// `alt` is the second beat: the puppy's legs apart, the senior's tongue out.
149const FRAMES: Record<Mood, readonly Frame[]> = {
150 sleep: [f('-', false, false, ' z'), f('-', false, false, ' Z'), f('-', false, false, ' zZ')],
151 chill: [f('@', false, false), f('@', true, true), f('-', false, false), f('@', true, true)],
152 busy: [f('@', true, true, ' .'), f('@', false, false, ' ..'), f('@', true, true, ' ...'), f('@', false, false)],
153 cheer: [f('^', true, true, ' *'), f('^', false, false, ' *'), f('^', true, true, ' * *'), f('^', false, true)],
154 sweat: [f('@', true, false, ' ;'), f('@', false, false, ' ;'), f('@', true, false, ' ;;'), f('@', false, false)],
155 panic: [f('@', true, true, ' !!'), f('@', false, false, ' !'), f('@', true, true, ' !!'), f('@', false, false, '!')],
156 dead: [f('x', false, false, ' ~o'), f('x', false, false, ' o~')],
157}
158
159const bigHead = ({ eye, alt, aside }: Frame): Sprite => [
160 ` / \\__${aside}`,
161 ` ( ${eye}\\___`,
162 ` / O`,
163 ` / (_____/`,
164 ` /_____/ ${alt ? 'U' : ' '}`,
165]
166
167const standing = ({ eye, alt, tail, aside }: Frame): Sprite => {
168 const eyes = eye === '@' ? "''" : eye.repeat(2)
169 return [
170 ` __${aside}`,
171 ` o-${eyes}|\\_____/${tail ? '~' : ')'}`,
172 ` \\_/|_) )`,
173 ` \\ __ /`,
174 eye === '-' || eye === 'x' ? ` (__ (__` : alt ? ` (_/ (_/` : ` (_/ (_/`,
175 ]
176}
177
178export const sprite = (dog: Dog, mood: Mood, frame: number): Sprite => {
179 const frames = FRAMES[mood]
180 const pace = dog === 'senior' ? Math.floor(frame / 2) : frame
181 const fr = frames[pace % frames.length]!
182 return (dog === 'puppy' ? standing(fr) : bigHead(fr)).map(line => line.padEnd(SPRITE_WIDTH))
183}
184
185// The puppy frets about the next few hours, the senior about the week.
186const QUIPS: Record<Dog, Record<Mood, readonly string[]>> = {
187 puppy: {
188 sleep: ['쿨쿨…', '불러주면 일어남'],
189 chill: ['산책 가고 싶다', '꼬리 살랑', '토큰 넉넉'],
190 busy: ['열일 중', '토큰 냠냠', '타닥타닥', '공 물어올게', '머리 굴리는 중'],
191 cheer: ['끝! 간식 줘', '칭찬해줘', '왈!'],
192 sweat: ['슬슬 아껴 쓰자', '헥헥…', '좀 빡빡한데'],
193 panic: ['5h 한도 코앞!', '깨갱!', '아껴! 아껴!'],
194 dead: ['방전… 리셋 대기', '낑…'],
195 },
196 senior: {
197 sleep: ['…zzz', '주말 모드'],
198 chill: ['이번 주 여유', '느긋하게', '지켜보는 중'],
199 busy: ['같이 일하는 중', '장부 정리 중', '주간 예산 체크'],
200 cheer: ['잘했다', '흠, 괜찮군'],
201 sweat: ['주말까지 버티자', '이번 주 빡빡', '페이스 조절'],
202 panic: ['이번 주 거의 끝', '7d 위험!', '진정해…'],
203 dead: ['이번 주 끝…', '리셋까지 존버'],
204 },
205}
206
207export const quip = (dog: Dog, mood: Mood, seed: number): string => {
208 const list = QUIPS[dog][mood]
209 return list[Math.abs(Math.floor(seed)) % list.length]!
210}
211
212export const moodColor = (mood: Mood, calm: string): string =>
213 mood === 'panic' || mood === 'dead' ? 'error' : mood === 'sweat' ? 'warning' : calm
214
215export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
216types/index.d.ts 50 lines1export type HudRateLimit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Figures = {
4 context?: { tokens?: number; window: number; percent?: number }
5 rateLimits: HudRateLimit[]
6 cost?: { usd: number }
7 model?: string
8 effort?: string
9}
10
11export type HudTurn = {
12 isRunning: boolean
13 startedAt: number
14 endedAt: number
15 tools: number
16 costAtStart?: number
17 tokensIn?: number
18 tokensOut?: number
19 cacheHit?: number
20 seed: number
21}
22
23export type HudGit = {
24 branch: string
25 ahead: number
26 behind: number
27 staged: number
28 modified: number
29 untracked: number
30}
31
32export type HudWork = { subagents: number; background: number }
33
34declare module 'claude-code' {
35 interface PluginState {
36 'usage-hud': {
37 figures: Figures
38 now: number
39 frame: number
40 turn: HudTurn
41 git: HudGit | null
42 gitError: string | null
43 work: HudWork
44 mode: string | null
45 fired: string[]
46 isFun: boolean
47 }
48 }
49}
50