SLOPSHOPPER

usage-meter

프롬프트 위에 컨텍스트 사용량, 프롬프트 캐시, 세션(5시간) 한도, 주간(7일) 한도, 세션 비용을 표시

newbandcommandtoasttimer
v0.2.0no licenseupdated 2026-10-06ban-dal/claude-mods/plugins/usage-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-meter
› 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 › /usage-meter ⎿ usage-meter: 사용량 미터를 숨겼습니다. 다시 보려면 /usage-meter ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Claude Code mod 마켓플레이스.

설치

claude plugin marketplace add ban-dal/claude-mods
claude plugin install usage-meter@claude-mods
claude plugin install codex-board@claude-mods

Mods

usage-meter

프롬프트 위에 컨텍스트 사용량, 프롬프트 캐시, 세션(5시간) 한도, 주간(7일) 한도, 세션 비용을 한 줄로 표시한다.

<img src="docs/usage-meter.png" alt="usage-meter 미터 줄" width="640">

  • Desktop은 항목마다 항목색 배경의 칩으로 표시 (terminal은 ◔ ↯ ◷ ▦ $ 글자)
  • 세션/주간 막대의 세로선은 구간 경과 위치: 막대가 이를 앞서면 시간보다 빨리 쓰는 중
  • 리셋 전에 한도가 소진될 속도면 남은 시간 대신 소진 예상 시각을 표시
  • 캐시: 직전 응답의 적중률과 만료(TTL 1시간)까지 남은 시간, 5분 안으로 남으면 경고색
  • 상세: 상세 패널. 탭으로 컨텍스트 구성(항목별 토큰), 캐시 상세(직전 응답의 읽기·쓰기·미캐시 토큰, 세션 누적), 세션/주간 사용 추이 그래프와 리셋 시 예상 사용률을 전환
  • 한도 80%·90%, 컨텍스트 90% 도달 시 토스트
  • /usage-meter: 표시/숨기기

codex-board

Codex 플러그인으로 요청한 리뷰·작업의 진행 상황을 우측 패널에 표시한다.

<img src="docs/codex-board.png" alt="codex-board 패널" width="420">

  • codex-companion이 남기는 상태 파일(~/.claude/plugins/data/codex-*/state)을 읽어 단계, 경과 시간, 최근 로그를 갱신
  • 리뷰가 끝나면 판정과 심각도별 지적 수를 표시하고, 결과로 지적 목록이나 원문을 펼침
  • 작업별 model·effort 표시: 작업 파일의 요청 값 → 요청 명령의 --model·--effort → ~/.codex/config.toml 순으로 확인
  • 로그가 30초 넘게 멈추면 마지막 활동 시각을 경고색으로 표시하고, 10분 넘게 멈추면 이 세션의 작업을 codex-companion cancel로 자동 종료
  • 이 세션에서 Codex 작업이 시작되면 패널을 자동으로 열고, 끝나면 토스트
  • 범위: 이 세션(현재 레포 포함) / 전체
  • /codex-board: 패널 열기/닫기 (열어 두면 다음 세션에도 열림)

개발

claude plugin validate plugins/usage-meter
claude plugin test plugins/usage-meter
claude plugin validate plugins/codex-board
claude plugin test plugins/codex-board
Source 4 files
hooks/register.tsx 519 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, ModelUsage, Register, RenderSurface, SessionContextUsage, SessionRateLimit } from 'claude-code'
3
4import type { Breakdown, CacheTokens, Meter, Slice, Snapshot } from '../types'
5import { ACCENT, barSvg, CHIP_HEIGHT, chipSvg, formatClock, formatRemaining, formatTokens, ICON_GLYPH, PALETTE, spacerSvg, textBar, toneOf } from './format'
6import type { ChipPart, Tone } from './format'
7import { describeForecast, describeRate, isHistory, projectionOf, recordSamples, sparkline, trendSvg } from './trend'
8import type { Projection } from './trend'
9
10const snapshot = atom({ plugin: 'usage-meter', key: 'snapshot' } as const, null)
11const breakdown = atom({ plugin: 'usage-meter', key: 'breakdown' } as const, null)
12const history = atom({ plugin: 'usage-meter', key: 'history' } as const, {})
13const isDark = atom({ plugin: 'usage-meter', key: 'isDark' } as const, true)
14const isHidden = atom({ plugin: 'usage-meter', key: 'isHidden' } as const, false)
15const openPanel = atom({ plugin: 'usage-meter', key: 'openPanel' } as const, null)
16const alerted = atom({ plugin: 'usage-meter', key: 'alerted' } as const, [])
17const cache = atom({ plugin: 'usage-meter', key: 'cache' } as const, { last: null, total: { input: 0, read: 0, write: 0 } })
18
19const COMMAND = 'usage-meter'
20const HOUR = 3_600_000
21
22const WINDOWS: Record<string, { label: string; short: string; windowMs: number }> = {
23  five_hour: { label: '세션', short: '5h', windowMs: 5 * HOUR },
24  seven_day: { label: '주간', short: '7d', windowMs: 7 * 24 * HOUR },
25}
26const ORDER = ['context', 'five_hour', 'seven_day']
27const TAB_LABEL: Record<string, string> = { context: '컨텍스트', cache: '캐시', five_hour: '세션', seven_day: '주간' }
28// 칩 배경: 항목색을 이 불투명도로 깐다
29const CHIP_TINT = { dark: 0.16, light: 0.12 }
30// 프롬프트 캐시 TTL (설정값 1h 고정)
31const CACHE_TTL = HOUR
32// 만료가 이만큼 남으면 경고색으로 표시한다
33const CACHE_WARN_MS = 5 * 60_000
34
35const SLICE_LABEL: Record<string, string> = {
36  'System prompt': '시스템 프롬프트',
37  'System tools': '시스템 도구',
38  'MCP tools': 'MCP 도구',
39  'Custom agents': '커스텀 에이전트',
40  'Memory files': '메모리 파일',
41  Skills: '스킬',
42  Messages: '대화 메시지',
43}
44
45const LIMIT_THRESHOLDS = [90, 80]
46const CONTEXT_THRESHOLD = 90
47const PANEL_ROWS = 8
48const PANEL_WIDTH = 46
49const NAME_WIDTH = 16
50// 패널과 미터 줄 사이 간격 (Desktop, px)
51const PANEL_GAP = 8
52// 구간이 이만큼 지나기 전에는 소진 예상을 띄우지 않는다
53const FORECAST_MIN_ELAPSED = 0.1
54
55function toSnapshot(context: SessionContextUsage, limits: SessionRateLimit[], costUsd: number | undefined): Snapshot {
56  const meters: Meter[] = [
57    { id: 'context', label: '컨텍스트', percent: context.percent, tokens: context.tokens, capacity: context.window },
58    ...limits.map(limit => ({
59      id: limit.kind,
60      label: WINDOWS[limit.kind]?.label ?? (limit.kind === 'spend_limit' ? '지출 한도' : limit.kind),
61      percent: limit.percentUsed,
62      resetsAt: limit.resetsAt,
63      windowMs: WINDOWS[limit.kind]?.windowMs,
64    })),
65  ]
66  const rank = (id: string) => (ORDER.includes(id) ? ORDER.indexOf(id) : ORDER.length)
67  return { meters: meters.sort((a, b) => rank(a.id) - rank(b.id)), costUsd }
68}
69
70function toCacheTokens(usage: ModelUsage): CacheTokens {
71  return { input: usage.input_tokens, read: usage.cache_read_input_tokens, write: usage.cache_creation_input_tokens }
72}
73
74function hitRateOf(tokens: CacheTokens): number | undefined {
75  const sum = tokens.input + tokens.read + tokens.write
76  return sum === 0 ? undefined : (tokens.read / sum) * 100
77}
78
79function toBreakdown(context: SessionContextUsage): Breakdown | null {
80  const categories = context.breakdown?.categories
81  if (categories === undefined) return null
82  const sum = (kind: string) => categories.filter(c => c.kind === kind).reduce((total, c) => total + c.tokens, 0)
83  return {
84    used: categories
85      .filter(c => c.kind === 'used' && c.tokens > 0)
86      .map(c => ({ name: SLICE_LABEL[c.name] ?? c.name, tokens: c.tokens, color: c.color }))
87      .sort((a, b) => b.tokens - a.tokens),
88    freeTokens: sum('free'),
89    bufferTokens: sum('buffer'),
90  }
91}
92
93type Reading = {
94  percent?: number
95  tone: Tone
96  remainingMs?: number
97  projection?: Projection
98  // 미터 줄에 띄울 소진 예상 시각
99  exhaustsAt?: number
100}
101
102function readingOf(meter: Meter, now: number): Reading {
103  const reading: Reading = { percent: meter.percent, tone: toneOf(meter.percent) }
104  const resetsAt = meter.resetsAt === undefined ? NaN : Date.parse(meter.resetsAt)
105  if (Number.isNaN(resetsAt)) return reading
106
107  reading.remainingMs = Math.max(0, resetsAt - now)
108  if (meter.percent === undefined || meter.windowMs === undefined) return reading
109
110  reading.projection = projectionOf(meter.percent, resetsAt, meter.windowMs, now)
111  const elapsed = (meter.windowMs - reading.remainingMs) / meter.windowMs
112  if (elapsed >= FORECAST_MIN_ELAPSED) reading.exhaustsAt = reading.projection.exhaustsAt
113  return reading
114}
115
116async function refreshTheme($: EngineInterface) {
117  const rows = await $.config.list().catch(() => [])
118  const theme = rows.find(row => row.key === 'theme')?.value
119  if (typeof theme === 'string') await update($, isDark, () => !theme.startsWith('light'))
120}
121
122async function refreshDetail($: EngineInterface, context: SessionContextUsage) {
123  await update($, breakdown, () => toBreakdown(context))
124  const apiUsage = context.breakdown?.apiUsage
125  if (apiUsage !== undefined) await update($, cache, current => ({ ...current, last: apiUsage === null ? null : toCacheTokens(apiUsage) }))
126}
127
128async function recordHistory($: EngineInterface, limits: SessionRateLimit[]) {
129  if (limits.length === 0) return
130  const now = await $.clock.now()
131  const saved = await update($, history, current => recordSamples(current, limits, now, WINDOWS))
132  await $.store.set('history', saved).catch(() => undefined)
133}
134
135export const register: Register = on => {
136  on('session.start', async ($, e, next) => {
137    await $.command.register({ name: COMMAND, description: '사용량 미터 표시/숨기기' })
138    if ((await $.store.get('isHidden')) === true) await update($, isHidden, () => true)
139    const stored = await $.store.get('history')
140    if (isHistory(stored)) await update($, history, () => stored)
141    await refreshTheme($)
142
143    const usage = await $.session.usage({ breakdown: 'summary' }).catch(() => $.session.usage())
144    await update($, snapshot, () => toSnapshot(usage.context, usage.rateLimits, usage.cost?.usd))
145    await refreshDetail($, usage.context)
146    await recordHistory($, usage.rateLimits)
147
148    // 리셋까지 남은 시간, 소진 예상, 캐시 만료 표시를 갱신한다
149    $.clock.every(30_000, () => $.ui.invalidate('ui.render'))
150    return next(e)
151  })
152
153  on('config.set', { key: 'theme' }, async ($, e, next) => {
154    const result = await next(e)
155    await refreshTheme($)
156    return result
157  })
158
159  on('command.run', { command: COMMAND }, async $ => {
160    const hidden = await update($, isHidden, value => !value)
161    await $.store.set('isHidden', hidden)
162    return { text: hidden ? '사용량 미터를 숨겼습니다. 다시 보려면 /usage-meter' : '사용량 미터를 표시합니다.' }
163  })
164
165  on('turn.complete', async ($, e, next) => {
166    const result = await next(e)
167    const usage = e.usage
168    if (usage === undefined) return result
169    const now = await $.clock.now()
170    const tokens = toCacheTokens(usage)
171    await update($, cache, current => ({
172      ...current,
173      total: { input: current.total.input + tokens.input, read: current.total.read + tokens.read, write: current.total.write + tokens.write },
174      // 서브에이전트 응답은 메인 스레드 캐시를 갱신하지 않는다
175      respondedAt: e.agentId === undefined ? now : current.respondedAt,
176    }))
177    return result
178  })
179
180  on('session.measure', async ($, e, next) => {
181    await update($, snapshot, () => toSnapshot(e.context, e.rateLimits, e.cost?.usd))
182    if (e.changed.includes('context')) {
183      const detailed = await $.session.usage({ breakdown: 'summary' }).catch(() => null)
184      if (detailed) await refreshDetail($, detailed.context)
185    }
186    if (e.changed.includes('rateLimits')) await recordHistory($, e.rateLimits)
187
188    const sent = await read($, alerted)
189    const now = await $.clock.now()
190    const fresh: string[] = []
191
192    for (const limit of e.rateLimits) {
193      const threshold = LIMIT_THRESHOLDS.find(t => limit.percentUsed >= t)
194      if (threshold === undefined) continue
195      const id = `${limit.kind}:${limit.resetsAt ?? ''}:${threshold}`
196      if (sent.includes(id)) continue
197      fresh.push(id)
198      const label = WINDOWS[limit.kind]?.label ?? limit.kind
199      const resetsAt = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
200      const suffix = Number.isNaN(resetsAt) ? '' : ` · ${formatRemaining(resetsAt - now, false)} 후 리셋`
201      $.ui.toast(`${label} 한도 ${limit.percentUsed}% 사용${suffix}`)
202    }
203
204    // 컨텍스트 경고는 compact/clear로 내려가면 다시 받을 수 있게 지운다
205    const contextPercent = e.context.percent ?? 0
206    const kept = contextPercent < CONTEXT_THRESHOLD - 20 ? sent.filter(id => !id.startsWith('context:')) : sent
207    if (contextPercent >= CONTEXT_THRESHOLD && !kept.includes('context:90')) {
208      fresh.push('context:90')
209      $.ui.toast(`컨텍스트 ${contextPercent}% 사용 · /compact 를 고려하세요`)
210    }
211
212    if (fresh.length > 0 || kept.length !== sent.length) await update($, alerted, () => [...kept, ...fresh])
213    return next(e)
214  })
215
216  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
217    const current = await read($, snapshot)
218    if (e.props.hasSurvey || current === null || (await read($, isHidden))) return next(e)
219
220    const table: ElementTable<RenderSurface> = $.ui.resolve(e)
221    const { Box, Text, Button } = table
222    const Svg = e.surface !== 'terminal' && 'Svg' in table ? table.Svg : undefined
223    const theme = (await read($, isDark)) ? 'dark' : 'light'
224    const palette = PALETTE[theme]
225    const accent = ACCENT[theme]
226    const slices = await read($, breakdown)
227    const records = await read($, history)
228    const opened = await read($, openPanel)
229    const cached = await read($, cache)
230    const now = await $.clock.now()
231    const isNarrow = e.props.bodyColumns < 90
232
233    const close = () => update($, openPanel, () => null)
234
235    const readings = current.meters.map(meter => ({ meter, reading: readingOf(meter, now) }))
236    const canOpen = (meter: Meter, reading: Reading) =>
237      meter.id === 'context' ? slices !== null && meter.capacity !== undefined && meter.tokens !== undefined : reading.projection !== undefined
238    // 컨텍스트, 캐시, 한도 순으로 열 수 있는 패널
239    const tabs = [
240      ...readings.filter(({ meter, reading }) => meter.id === 'context' && canOpen(meter, reading)).map(({ meter }) => meter.id),
241      ...(cached.last === null ? [] : ['cache']),
242      ...readings.filter(({ meter, reading }) => meter.id !== 'context' && canOpen(meter, reading)).map(({ meter }) => meter.id),
243    ]
244
245    const bar = (key: string, percent: number | undefined, color: string, width: number, cells: number) => {
246      if (percent === undefined) return null
247      if (Svg) {
248        return <Svg key={key} source={barSvg(percent, width, color, palette.track)} alt={`${Math.round(percent)}%`} width={width} height={6} />
249      }
250      return (
251        <Text key={key} color={color}>
252          {textBar(percent, cells)}
253        </Text>
254      )
255    }
256
257    const frame = (title: string, summary: string, body: ReturnType<typeof Box>) => (
258      <Box
259        key="panel"
260        flexDirection="column"
261        width={PANEL_WIDTH}
262        borderStyle="round"
263        borderColor={palette.border}
264        backgroundColor={palette.surface}
265        paddingX={1}
266      >
267        <Box flexDirection="row" justifyContent="space-between" alignItems="center">
268          <Box flexDirection="row" alignItems="center" columnGap={1}>
269            {tabs.map(id => (
270              <Button
271                key={`tab-${id}`}
272                label={TAB_LABEL[id] ?? id}
273                plain
274                dimColor={opened !== id ? true : undefined}
275                onPress={() => update($, openPanel, () => id)}
276              />
277            ))}
278          </Box>
279          <Button key="panel-close" label="✕" plain role="dismiss" onPress={close} />
280        </Box>
281        <Box flexDirection="row" justifyContent="space-between" alignItems="center">
282          <Text bold color={palette.value}>
283            {title}
284          </Text>
285          <Text color={palette.label}>{summary}</Text>
286        </Box>
287        {body}
288      </Box>
289    )
290
291    const contextPanel = (meter: Meter) => {
292      if (slices === null || meter.capacity === undefined || meter.tokens === undefined) return null
293      const capacity = meter.capacity
294      const shown = slices.used.slice(0, PANEL_ROWS)
295      const rest = slices.used.slice(PANEL_ROWS).reduce((total, slice) => total + slice.tokens, 0)
296      const rows: Slice[] = rest > 0 ? [...shown, { name: '기타', tokens: rest, color: 'inactive' }] : shown
297
298      return frame(
299        '컨텍스트 구성',
300        `${formatTokens(meter.tokens)} / ${formatTokens(capacity)}`,
301        <Box flexDirection="column">
302          {rows.map(slice => (
303            <Box key={slice.name} flexDirection="row" alignItems="center" columnGap={1}>
304              <Text color={slice.color}>■</Text>
305              <Box width={NAME_WIDTH}>
306                <Text color={palette.value} wrap="truncate-end">
307                  {slice.name}
308                </Text>
309              </Box>
310              <Box flexGrow={1}>{bar(`bar-${slice.name}`, (slice.tokens / capacity) * 100, palette.ok, 120, 12)}</Box>
311              <Box width={5} justifyContent="flex-end">
312                <Text color={palette.label}>{formatTokens(slice.tokens)}</Text>
313              </Box>
314            </Box>
315          ))}
316          <Text color={palette.label}>
317            남은 공간 {formatTokens(slices.freeTokens)}
318            {slices.bufferTokens > 0 ? ` · 자동 compact 예비 ${formatTokens(slices.bufferTokens)}` : ''}
319          </Text>
320        </Box>,
321      )
322    }
323
324    const trendPanel = (meter: Meter, reading: Reading) => {
325      const projection = reading.projection
326      if (projection === undefined || meter.windowMs === undefined || reading.percent === undefined) return null
327      const windowMs = meter.windowMs
328      const entry = records[meter.id]
329      const tone = palette[reading.tone]
330      const remaining = reading.remainingMs === undefined ? '' : ` (${formatRemaining(reading.remainingMs, false)} 후)`
331      const forecast = describeForecast(projection)
332
333      return frame(
334        `${meter.label} 사용 추이`,
335        `${Math.round(reading.percent)}%`,
336        <Box flexDirection="column">
337          {Svg ? (
338            <Svg
339              key="trend"
340              source={trendSvg(entry, projection, windowMs, now, palette, tone)}
341              alt={`${meter.label} 사용률 ${Math.round(reading.percent)}%, ${forecast}`}
342              width={372}
343              height={96}
344            />
345          ) : (
346            <Text color={tone}>{sparkline(entry, projection, now, PANEL_WIDTH - 4)}</Text>
347          )}
348          <Text color={palette.label}>
349            리셋 {formatClock(projection.resetsAt)}
350            {remaining}
351          </Text>
352          <Text color={projection.exhaustsAt !== undefined ? palette.warn : palette.label}>
353            {describeRate(projection, windowMs)} · {forecast}
354          </Text>
355        </Box>,
356      )
357    }
358
359    const cacheExpiresAt = cached.respondedAt === undefined ? undefined : cached.respondedAt + CACHE_TTL
360    const cacheRemainingMs = cacheExpiresAt === undefined ? undefined : cacheExpiresAt - now
361    const isCacheExpiring = cacheRemainingMs !== undefined && cacheRemainingMs > 0 && cacheRemainingMs < CACHE_WARN_MS
362
363    const cachePanel = () => {
364      const last = cached.last
365      if (last === null) return null
366      const sum = last.input + last.read + last.write
367      const rows = [
368        { name: '캐시 읽기', tokens: last.read, color: accent.cache ?? palette.ok },
369        { name: '캐시 쓰기', tokens: last.write, color: palette.warn },
370        { name: '미캐시 입력', tokens: last.input, color: palette.idle },
371      ]
372      const totalRate = hitRateOf(cached.total)
373      const hitRate = hitRateOf(last)
374
375      return frame(
376        '프롬프트 캐시',
377        hitRate === undefined ? '' : `적중 ${Math.round(hitRate)}%`,
378        <Box flexDirection="column">
379          <Text color={palette.label}>직전 응답 입력 {formatTokens(sum)}</Text>
380          {rows.map(row => (
381            <Box key={row.name} flexDirection="row" alignItems="center" columnGap={1}>
382              <Text color={row.color}>■</Text>
383              <Box width={NAME_WIDTH}>
384                <Text color={palette.value}>{row.name}</Text>
385              </Box>
386              <Box flexGrow={1}>{bar(`bar-${row.name}`, sum === 0 ? 0 : (row.tokens / sum) * 100, row.color, 120, 12)}</Box>
387              <Box width={5} justifyContent="flex-end">
388                <Text color={palette.label}>{formatTokens(row.tokens)}</Text>
389              </Box>
390            </Box>
391          ))}
392          {totalRate !== undefined && (
393            <Text color={palette.label}>
394              세션 누적 읽기 {formatTokens(cached.total.read)} · 쓰기 {formatTokens(cached.total.write)} · 적중 {Math.round(totalRate)}%
395            </Text>
396          )}
397          <Text color={isCacheExpiring ? palette.warn : palette.label}>
398            {cacheExpiresAt === undefined || cacheRemainingMs === undefined
399              ? 'TTL 1시간'
400              : cacheRemainingMs <= 0
401                ? '만료됨 · 다음 요청에서 다시 캐시'
402                : `만료 ${formatClock(cacheExpiresAt)} (${formatRemaining(cacheRemainingMs, false)} 후) · TTL 1시간`}
403          </Text>
404        </Box>,
405      )
406    }
407
408    // 막대는 항목 고유색, 경고·위험이면 그 색. 숫자는 기본 글자색, 경고·위험이면 그 색
409    const barColor = (id: string, tone: Tone) => (tone === 'ok' ? (accent[id] ?? palette.ok) : palette[tone])
410    const valueColor = (tone: Tone) => (tone === 'ok' ? palette.value : palette[tone])
411    const iconPart = (id: string): ChipPart => ({ kind: 'icon', id, color: accent[id] ?? palette.label })
412    const textPart = (text: string, color: string, isBold?: boolean): ChipPart => ({ kind: 'text', text, color, isBold })
413
414    type Item = { id: string; alt: string; parts: ChipPart[] }
415
416    const meterItem = ({ meter, reading }: { meter: Meter; reading: Reading }): Item => {
417      const value = reading.percent === undefined ? '—' : `${Math.round(reading.percent)}%`
418      const short = WINDOWS[meter.id]?.short
419      // 경과 비율 마커: 막대가 이걸 앞서면 시간보다 빨리 쓰는 중
420      const marker =
421        meter.windowMs === undefined || reading.remainingMs === undefined ? undefined : 1 - reading.remainingMs / meter.windowMs
422
423      let detail: { text: string; color: string } | undefined
424      if (reading.exhaustsAt !== undefined) {
425        detail = { text: `${formatClock(reading.exhaustsAt)} 소진`, color: palette.warn }
426      } else if (reading.remainingMs !== undefined) {
427        detail = { text: formatRemaining(reading.remainingMs, true), color: palette.label }
428      }
429
430      const parts: ChipPart[] = [iconPart(meter.id)]
431      if (short) parts.push(textPart(short, palette.label))
432      if (!isNarrow && reading.percent !== undefined) parts.push({ kind: 'bar', percent: reading.percent, color: barColor(meter.id, reading.tone), marker })
433      parts.push(textPart(value, valueColor(reading.tone), true))
434      if (detail) parts.push({ kind: 'divider' }, iconPart('history'), textPart(detail.text, detail.color))
435      return { id: meter.id, alt: [meter.label, value, detail?.text].filter(Boolean).join(' '), parts }
436    }
437
438    const cacheItem = (): Item | null => {
439      if (cached.last === null && cached.respondedAt === undefined) return null
440      const hitRate = cached.last === null ? undefined : hitRateOf(cached.last)
441      const value = hitRate === undefined ? '—' : `${Math.round(hitRate)}%`
442      const expiry = cacheRemainingMs === undefined ? undefined : cacheRemainingMs <= 0 ? '만료' : formatRemaining(cacheRemainingMs, true)
443      const parts: ChipPart[] = [iconPart('cache'), textPart(value, valueColor(hitRate === undefined ? 'idle' : 'ok'), true)]
444      if (expiry) parts.push({ kind: 'divider' }, iconPart('hourglass'), textPart(expiry, isCacheExpiring ? palette.warn : palette.label))
445      return { id: 'cache', alt: ['캐시 적중', value, expiry].filter(Boolean).join(' '), parts }
446    }
447
448    const costItem = (): Item | null => {
449      if (current.costUsd === undefined) return null
450      const value = `$${current.costUsd.toFixed(2)}`
451      return { id: 'cost', alt: `비용 ${value}`, parts: [iconPart('cost'), textPart(value, palette.value, true)] }
452    }
453
454    const chipStyle = (id: string) => ({
455      tint: accent[id] ?? palette.idle,
456      tintOpacity: CHIP_TINT[theme],
457      divider: palette.border,
458      ink: palette.value,
459    })
460
461    // terminal: 칩 배경 없이 글자로. 글자가 없는 아이콘(history, hourglass)은 구분점으로 대신한다
462    const textParts = (parts: ChipPart[]) =>
463      parts.flatMap((part, index) => {
464        const key = `part-${index}`
465        if (part.kind === 'icon') {
466          const glyph = ICON_GLYPH[part.id]
467          return glyph === undefined ? [] : [<Text key={key} color={part.color}>{glyph}</Text>]
468        }
469        if (part.kind === 'text') {
470          return [<Text key={key} bold={part.isBold ? true : undefined} color={part.color}>{part.text}</Text>]
471        }
472        if (part.kind === 'bar') return [<Text key={key} color={part.color}>{textBar(part.percent, 6)}</Text>]
473        return [<Text key={key} color={palette.border}>·</Text>]
474      })
475
476    // 컨텍스트는 항상 첫 미터: 캐시를 그 옆에 둔다
477    const [contextItem, ...limitItems] = readings.map(meterItem)
478    const items = [contextItem, cacheItem(), ...limitItems, costItem()]
479      .filter(item => item !== null && item !== undefined)
480      .map((item, index) => {
481        if (Svg) {
482          const chip = chipSvg(item.parts, chipStyle(item.id))
483          return <Svg key={item.id} source={chip.source} alt={item.alt} width={chip.width} height={CHIP_HEIGHT} />
484        }
485        return (
486          <Box key={item.id} flexDirection="row" alignItems="center" columnGap={1}>
487            {index > 0 && <Text color={palette.border}>│</Text>}
488            {textParts(item.parts)}
489          </Box>
490        )
491      })
492
493    const toggleDetails = () => update($, openPanel, value => (value === null ? (tabs[0] ?? null) : null))
494
495    const openReading = readings.find(({ meter }) => meter.id === opened)
496    const panel =
497      opened === 'cache'
498        ? cachePanel()
499        : openReading === undefined
500          ? null
501          : openReading.meter.id === 'context'
502            ? contextPanel(openReading.meter)
503            : trendPanel(openReading.meter, openReading.reading)
504
505    return (
506      <Box flexDirection="column">
507        {panel}
508        {panel && Svg && <Svg key="panel-gap" source={spacerSvg(PANEL_WIDTH, PANEL_GAP)} alt="패널 간격" width={PANEL_WIDTH} height={PANEL_GAP} />}
509        <Box flexDirection="row" alignItems="center" columnGap={1}>
510          <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1} flexGrow={1} flexShrink={1}>
511            {items}
512          </Box>
513          {tabs.length > 0 && <Button key="details" label="상세" onPress={toggleDetails} />}
514        </Box>
515      </Box>
516    )
517  })
518}
519
hooks/format.ts 179 lines
1export type Tone = 'ok' | 'warn' | 'danger' | 'idle'
2
3export type Palette = { label: string; value: string; track: string; surface: string; border: string; grid: string } & Record<Tone, string>
4
5export const PALETTE: Record<'dark' | 'light', Palette> = {
6  dark: {
7    label: '#a1a1aa',
8    value: '#f4f4f5',
9    track: '#3f3f46',
10    surface: '#1c1c20',
11    border: '#3f3f46',
12    grid: '#2e2e34',
13    ok: '#818cf8',
14    warn: '#fbbf24',
15    danger: '#f87171',
16    idle: '#71717a',
17  },
18  light: {
19    label: '#71717a',
20    value: '#18181b',
21    track: '#e4e4e7',
22    surface: '#ffffff',
23    border: '#d4d4d8',
24    grid: '#ececef',
25    ok: '#4f46e5',
26    warn: '#b45309',
27    danger: '#dc2626',
28    idle: '#a1a1aa',
29  },
30}
31
32// 미터 줄 항목별 고유색: 경고·위험색(amber, red)과 겹치지 않게 고른다
33export const ACCENT: Record<'dark' | 'light', Record<string, string>> = {
34  dark: { context: '#a78bfa', cache: '#2dd4bf', five_hour: '#60a5fa', seven_day: '#4ade80', cost: '#f472b6' },
35  light: { context: '#7c3aed', cache: '#0d9488', five_hour: '#2563eb', seven_day: '#16a34a', cost: '#db2777' },
36}
37
38// 24×24 선 아이콘 (Lucide: pie-chart, zap, gauge, calendar, circle-dollar-sign, history, hourglass)
39const ICON_PATHS: Record<string, string> = {
40  context: '<path d="M21 12c.55 0 1-.45.95-1A10 10 0 0 0 13 2.05c-.55-.05-1 .4-1 .95v8a1 1 0 0 0 1 1z"/><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/>',
41  cache: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>',
42  five_hour: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
43  seven_day: '<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
44  cost: '<circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8M12 18V6"/>',
45  history: '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
46  hourglass:
47    '<path d="M5 22h14M5 2h14"/><path d="M17 22v-4.17a2 2 0 0 0-.59-1.42L12 12l-4.41 4.41A2 2 0 0 0 7 17.83V22"/><path d="M7 2v4.17a2 2 0 0 0 .59 1.42L12 12l4.41-4.41A2 2 0 0 0 17 6.17V2"/>',
48}
49
50// terminal용: 모두 한 칸 폭이고 이모지로 그려지지 않는 글자. 없는 아이콘은 그리지 않는다
51export const ICON_GLYPH: Record<string, string> = { context: '◔', cache: '↯', five_hour: '◷', seven_day: '▦', cost: '$' }
52
53export type ChipPart =
54  | { kind: 'icon'; id: string; color: string }
55  | { kind: 'text'; text: string; color: string; isBold?: boolean }
56  // marker: 구간 경과 비율(0~1)
57  | { kind: 'bar'; percent: number; color: string; marker?: number }
58  | { kind: 'divider' }
59
60// ink: 글자색. 막대 바탕과 경과 마커를 이 색으로 그려 어느 칩 배경에서도 보이게 한다
61export type ChipStyle = { tint: string; tintOpacity: number; divider: string; ink: string }
62
63export const CHIP_HEIGHT = 22
64const CHIP_PAD = 8
65const CHIP_GAP = 5
66const CHIP_FONT = 12
67const CHIP_ICON = 14
68const CHIP_BAR = 36
69const CHIP_FAMILY = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
70
71function escapeXml(text: string): string {
72  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
73}
74
75// 고정폭 글꼴 기준: 라틴 0.6em, 한글 같은 넓은 글자 1em
76function textWidth(text: string): number {
77  return [...text].reduce((width, char) => width + (char.charCodeAt(0) >= 0x1100 ? 1 : 0.6), 0) * CHIP_FONT
78}
79
80// 둥근 알약 모양 칩 하나: 아이콘, 글자, 막대, 구분선을 왼쪽부터 늘어놓는다
81export function chipSvg(parts: ChipPart[], style: ChipStyle): { source: string; width: number } {
82  const mid = CHIP_HEIGHT / 2
83  const body: string[] = []
84  let x = CHIP_PAD
85  parts.forEach((part, index) => {
86    if (index > 0) x += CHIP_GAP
87    if (part.kind === 'icon') {
88      body.push(
89        `<g transform="translate(${x} ${mid - CHIP_ICON / 2}) scale(${CHIP_ICON / 24})" fill="none" stroke="${part.color}" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round">${ICON_PATHS[part.id] ?? ''}</g>`,
90      )
91      x += CHIP_ICON
92    } else if (part.kind === 'text') {
93      body.push(
94        `<text x="${x}" y="${mid}" dominant-baseline="central" font-family="${CHIP_FAMILY}" font-size="${CHIP_FONT}" font-weight="${part.isBold ? 600 : 400}" fill="${part.color}">${escapeXml(part.text)}</text>`,
95      )
96      x += textWidth(part.text)
97    } else if (part.kind === 'bar') {
98      const filled = Math.min(1, Math.max(0, part.percent / 100)) * CHIP_BAR
99      body.push(`<rect x="${x}" y="${mid - 3}" width="${CHIP_BAR}" height="6" rx="3" fill="${style.ink}" fill-opacity="0.18"/>`)
100      if (filled > 0) body.push(`<rect x="${x}" y="${mid - 3}" width="${Math.max(6, filled)}" height="6" rx="3" fill="${part.color}"/>`)
101      if (part.marker !== undefined) {
102        const at = x + Math.min(1, Math.max(0, part.marker)) * CHIP_BAR
103        body.push(`<line x1="${at}" y1="${mid - 6}" x2="${at}" y2="${mid + 6}" stroke="${style.ink}" stroke-width="1.5" stroke-linecap="round"/>`)
104      }
105      x += CHIP_BAR
106    } else {
107      body.push(`<line x1="${x}" y1="${mid - 6}" x2="${x}" y2="${mid + 6}" stroke="${style.divider}" stroke-width="1"/>`)
108      x += 1
109    }
110  })
111  const width = Math.ceil(x + CHIP_PAD)
112  return {
113    width,
114    source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${CHIP_HEIGHT}" viewBox="0 0 ${width} ${CHIP_HEIGHT}"><rect width="${width}" height="${CHIP_HEIGHT}" rx="${mid}" fill="${style.tint}" fill-opacity="${style.tintOpacity}"/>${body.join('')}</svg>`,
115  }
116}
117
118export function toneOf(percent: number | undefined): Tone {
119  if (percent === undefined) return 'idle'
120  if (percent >= 90) return 'danger'
121  if (percent >= 80) return 'warn'
122  return 'ok'
123}
124
125export function formatTokens(tokens: number): string {
126  if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(tokens % 1_000_000 === 0 ? 0 : 1)}M`
127  if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`
128  return String(tokens)
129}
130
131export function formatRemaining(ms: number, isShort: boolean): string {
132  const minutes = Math.max(1, Math.round(ms / 60_000))
133  const days = Math.floor(minutes / 1440)
134  const hours = Math.floor((minutes % 1440) / 60)
135  if (isShort) {
136    if (days > 0) return `${days}d${hours}h`
137    return hours > 0 ? `${hours}h${minutes % 60}m` : `${minutes}m`
138  }
139  if (days > 0) return hours > 0 ? `${days}일 ${hours}시간` : `${days}일`
140  if (hours > 0) return `${hours}시간 ${minutes % 60}분`
141  return `${minutes}분`
142}
143
144export function formatTime(at: number): string {
145  const date = new Date(at)
146  return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
147}
148
149export function formatClock(at: number): string {
150  const weekday = ['일', '월', '화', '수', '목', '금', '토'][new Date(at).getDay()]
151  return weekday === undefined ? formatTime(at) : `${weekday} ${formatTime(at)}`
152}
153
154export function formatDate(at: number): string {
155  const date = new Date(at)
156  return `${date.getMonth() + 1}/${date.getDate()} ${formatTime(at)}`
157}
158
159export function formatPercent(percent: number): string {
160  return percent >= 10 || percent === 0 ? `${Math.round(percent)}%` : `${percent.toFixed(1)}%`
161}
162
163export function barSvg(percent: number, width: number, fill: string, track: string): string {
164  const filled = Math.min(1, Math.max(0, percent / 100)) * width
165  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="6" viewBox="0 0 ${width} 6"><rect width="${width}" height="6" rx="3" fill="${track}"/>${
166    filled > 0 ? `<rect width="${Math.max(6, filled)}" height="6" rx="3" fill="${fill}"/>` : ''
167  }</svg>`
168}
169
170export function textBar(percent: number, cells: number): string {
171  const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * cells)
172  return '▰'.repeat(filled) + '▱'.repeat(cells - filled)
173}
174
175// Desktop 에서 픽셀 단위 간격을 만드는 투명 SVG: 그리는 내용이 없으면 크기 없이 접히므로 투명 사각형을 둔다
176export function spacerSvg(width: number, height: number): string {
177  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="#000" fill-opacity="0"/></svg>`
178}
179
hooks/trend.ts 126 lines
1import type { SessionRateLimit } from 'claude-code'
2
3import type { History, Sample, WindowHistory } from '../types'
4import { formatClock, formatDate, formatPercent, formatTime } from './format'
5import type { Palette } from './format'
6
7const MAX_POINTS = 500
8// 같은 구간인데 응답마다 리셋 시각이 조금씩 달라지는 것을 같은 구간으로 본다
9const SAME_WINDOW_MS = 10 * 60_000
10// 값이 그대로여도 이 간격마다 점을 남겨 평평한 구간을 그린다
11const FLAT_SAMPLE_MS = 30 * 60_000
12
13export function isHistory(value: unknown): value is History {
14  if (!value || typeof value !== 'object') return false
15  return Object.values(value).every(
16    entry => entry && typeof entry === 'object' && typeof entry.resetsAt === 'string' && Array.isArray(entry.points),
17  )
18}
19
20export function recordSamples(history: History, limits: SessionRateLimit[], now: number, windows: Record<string, unknown>): History {
21  const next: History = { ...history }
22  for (const limit of limits) {
23    if (!(limit.kind in windows) || limit.resetsAt === undefined) continue
24    const resetsAt = Date.parse(limit.resetsAt)
25    if (Number.isNaN(resetsAt)) continue
26
27    const existing = next[limit.kind]
28    const isSameWindow = existing !== undefined && Math.abs(Date.parse(existing.resetsAt) - resetsAt) < SAME_WINDOW_MS
29    const points: Sample[] = isSameWindow ? [...existing.points] : []
30    const last = points[points.length - 1]
31    if (last === undefined || last[1] !== limit.percentUsed || now - last[0] >= FLAT_SAMPLE_MS) {
32      points.push([now, limit.percentUsed])
33    }
34    next[limit.kind] = { resetsAt: limit.resetsAt, points: points.slice(-MAX_POINTS) }
35  }
36  return next
37}
38
39export type Projection = {
40  start: number
41  resetsAt: number
42  percent: number
43  // 구간 시작부터의 평균 속도(%/ms)
44  rate: number
45  // 지금 속도로 리셋 시점에 예상되는 사용률
46  atReset: number
47  exhaustsAt?: number
48}
49
50export function projectionOf(percent: number, resetsAt: number, windowMs: number, now: number): Projection {
51  const start = resetsAt - windowMs
52  const elapsed = Math.max(1, now - start)
53  const rate = percent / elapsed
54  const atReset = percent + rate * Math.max(0, resetsAt - now)
55  const exhaustsAt = rate > 0 && percent < 100 ? now + (100 - percent) / rate : undefined
56  return { start, resetsAt, percent, rate, atReset, exhaustsAt: exhaustsAt !== undefined && exhaustsAt < resetsAt ? exhaustsAt : undefined }
57}
58
59export function describeRate(projection: Projection, windowMs: number): string {
60  const perHour = projection.rate * 3_600_000
61  return windowMs > 24 * 3_600_000 ? `하루 평균 ${formatPercent(perHour * 24)}` : `시간당 평균 ${formatPercent(perHour)}`
62}
63
64export function describeForecast(projection: Projection): string {
65  if (projection.exhaustsAt !== undefined) return `지금 속도면 ${formatClock(projection.exhaustsAt)}경 소진`
66  return `지금 속도면 리셋 시 ${formatPercent(Math.min(100, projection.atReset))} 예상`
67}
68
69const CHART = { width: 372, height: 96, left: 30, right: 10, top: 8, bottom: 20 }
70
71function axisLabel(at: number, windowMs: number): string {
72  return windowMs > 24 * 3_600_000 ? formatDate(at) : formatTime(at)
73}
74
75export function trendSvg(entry: WindowHistory | undefined, projection: Projection, windowMs: number, now: number, palette: Palette, tone: string): string {
76  const plotWidth = CHART.width - CHART.left - CHART.right
77  const plotHeight = CHART.height - CHART.top - CHART.bottom
78  const x = (at: number) => CHART.left + (Math.min(Math.max(at, projection.start), projection.resetsAt) - projection.start) / windowMs * plotWidth
79  const y = (percent: number) => CHART.top + plotHeight * (1 - Math.min(100, Math.max(0, percent)) / 100)
80  const point = (at: number, percent: number) => `${x(at).toFixed(1)},${y(percent).toFixed(1)}`
81
82  const points = (entry?.points ?? []).filter(([at]) => at >= projection.start && at <= now)
83  const known = [...points, [now, projection.percent] as Sample]
84  const [firstAt, firstPercent] = known[0] ?? [now, projection.percent]
85
86  const solid = known.map(([at, percent]) => point(at, percent)).join(' ')
87  const area = `${point(firstAt, 0)} ${solid} ${point(now, 0)}`
88  // 기록 전 구간은 시작 시점 0%부터 첫 기록까지 점선으로 잇는다
89  const before = firstAt > projection.start ? `<polyline points="${point(projection.start, 0)} ${point(firstAt, firstPercent)}" fill="none" stroke="${tone}" stroke-width="1.5" stroke-dasharray="2 3" opacity=".6"/>` : ''
90  const forecastEnd = projection.exhaustsAt ?? projection.resetsAt
91  const forecastPercent = projection.exhaustsAt !== undefined ? 100 : projection.atReset
92  const forecastColor = projection.exhaustsAt !== undefined ? palette.warn : palette.label
93
94  const gridLine = (percent: number, label: string, isLimit = false) =>
95    `<line x1="${CHART.left}" x2="${CHART.width - CHART.right}" y1="${y(percent)}" y2="${y(percent)}" stroke="${isLimit ? palette.danger : palette.grid}" stroke-width="1" ${isLimit ? 'stroke-dasharray="3 3" opacity=".55"' : ''}/>` +
96    `<text x="${CHART.left - 6}" y="${y(percent) + 3}" font-size="9.5" fill="${palette.label}" text-anchor="end">${label}</text>`
97  const timeLabel = (at: number, label: string, anchor: string) =>
98    `<text x="${x(at)}" y="${CHART.height - 5}" font-size="9.5" fill="${palette.label}" text-anchor="${anchor}">${label}</text>`
99
100  return `<svg xmlns="http://www.w3.org/2000/svg" width="${CHART.width}" height="${CHART.height}" viewBox="0 0 ${CHART.width} ${CHART.height}" font-family="-apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', system-ui, sans-serif">
101${gridLine(0, '0')}${gridLine(50, '50')}${gridLine(100, '100', true)}
102<line x1="${x(now)}" x2="${x(now)}" y1="${CHART.top}" y2="${CHART.top + plotHeight}" stroke="${palette.grid}" stroke-width="1"/>
103<polygon points="${area}" fill="${tone}" opacity=".16"/>
104${before}
105<polyline points="${solid}" fill="none" stroke="${tone}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
106<line x1="${x(now)}" y1="${y(projection.percent)}" x2="${x(forecastEnd)}" y2="${y(forecastPercent)}" stroke="${forecastColor}" stroke-width="1.5" stroke-dasharray="4 3"/>
107<circle cx="${x(now)}" cy="${y(projection.percent)}" r="3" fill="${tone}"/>
108${timeLabel(projection.start, axisLabel(projection.start, windowMs), 'start')}${timeLabel(now, '지금', 'middle')}${timeLabel(projection.resetsAt, `리셋 ${axisLabel(projection.resetsAt, windowMs)}`, 'end')}
109</svg>`
110}
111
112const SPARK = '▁▂▃▄▅▆▇█'
113
114// 터미널용: 구간 시작부터 지금까지를 cells 칸으로 나눠 각 칸 끝의 사용률을 막대 높이로 그린다
115export function sparkline(entry: WindowHistory | undefined, projection: Projection, now: number, cells: number): string {
116  const points: Sample[] = [[projection.start, 0], ...(entry?.points ?? []), [now, projection.percent]]
117  const span = Math.max(1, now - projection.start)
118  let line = ''
119  for (let cell = 1; cell <= cells; cell++) {
120    const at = projection.start + (span * cell) / cells
121    const value = points.filter(([sampleAt]) => sampleAt <= at).pop()?.[1] ?? 0
122    line += SPARK[Math.min(SPARK.length - 1, Math.floor((value / 100) * SPARK.length))] ?? SPARK[0]
123  }
124  return line
125}
126
types/index.d.ts 54 lines
1export type Meter = {
2  id: string
3  label: string
4  percent?: number
5  // 한도 구간만 있음
6  resetsAt?: string
7  windowMs?: number
8  // 컨텍스트만 있음
9  tokens?: number
10  capacity?: number
11}
12
13export type Snapshot = { meters: Meter[]; costUsd?: number }
14
15// /context 의 한 줄: color 는 Claude Code 테마 키
16export type Slice = { name: string; tokens: number; color: string }
17
18export type Breakdown = { used: Slice[]; freeTokens: number; bufferTokens: number }
19
20// [기록 시각(ms), 사용률(%)]
21export type Sample = [number, number]
22
23export type WindowHistory = { resetsAt: string; points: Sample[] }
24
25// 한도 종류(five_hour, seven_day)별 현재 구간의 기록
26export type History = Record<string, WindowHistory>
27
28export type CacheTokens = { input: number; read: number; write: number }
29
30export type CacheState = {
31  // 메인 스레드 직전 응답의 토큰
32  last: CacheTokens | null
33  // 서브에이전트를 포함한 세션 누적
34  total: CacheTokens
35  // 메인 스레드 턴이 마지막으로 끝난 시각(ms)
36  respondedAt?: number
37}
38
39declare module 'claude-code' {
40  interface PluginState {
41    'usage-meter': {
42      snapshot: Snapshot | null
43      breakdown: Breakdown | null
44      history: History
45      isDark: boolean
46      isHidden: boolean
47      // 열린 패널의 미터 id (context, five_hour, seven_day)
48      openPanel: string | null
49      alerted: string[]
50      cache: CacheState
51    }
52  }
53}
54