SLOPSHOPPER

usage-hud

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

newbandguardcommandtoaststatus
v1.0.0MITupdated 2026-10-08Jang-seungminn/usage-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-hud
› 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 › /hud ⎿ usage-hud: 마스코트 퇴근. /hud 로 다시 부르기 5h ██░░░░░░ 31% ctx ████░░░░ 49% 97k/200k last 0s · tools 9 Opus 5.5 $0.42 ⎇ ? ~1 ?1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h ██░░░░░░ 31% ctx ████░░░░ 49% 97k/200k last 0s · tools 9 Opus 5.5 $0.42 ⎇ ? ~1 ?1
README

usage-hud

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 후 소진
ctxcontext window 사용량
턴진행 중에는 경과 시간과 도구 호출 수, 끝난 뒤에는 그 턴의 비용과 입출력 토큰
cache마지막 응답의 prompt cache 적중률
세션모델 · effort, 권한 모드 배지, 누적 비용
git브랜치, ahead/behind, staged/수정/추적 안 된 파일 수
agents / bg실행 중인 서브에이전트와 백그라운드 작업 수

알림(toast):

  • context가 80%를 넘거나 사용 한도가 90%를 넘는 순간
  • 50% 넘게 쓴 한도가 리셋될 때

강아지

  • 5h 강아지(전신): 5시간 한도와 context를 지켜봅니다.
  • 7d 강아지(얼굴): 7일 한도를 지켜봅니다.

수치에 따라 상태가 바뀝니다.

상태조건
여유기본
작업 중Claude가 일하는 동안
완료턴이 끝나고 20초 동안
진땀50% 이상
패닉80% 이상
소진100%
수면10분 넘게 쉬는 중

/hud를 입력하면 강아지를 끄고 켭니다. 강아지는 band가 5줄을 쓸 수 있을 때만 나오고, 터미널이 130칸 이상이면 모든 정보가 한 줄로 나란히 배치됩니다.

참고

  • 사용 한도(5h/7d)는 Claude 구독 계정에서만 표시됩니다.
  • 권한 모드 배지는 다음 프롬프트나 도구 실행 때 갱신됩니다.

개발

claude --plugin-dir ./usage-hud      # 이 폴더에서 바로 로드
claude plugin validate .
claude plugin test .

License

MIT

Source 3 files
hooks/register.tsx 457 lines
1import { 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}
457
hooks/lib.ts 216 lines
1// 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
216
types/index.d.ts 50 lines
1export 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