SLOPSHOPPER

usage-mods

Shows context, rate-limit and cost usage above the prompt

newbandtoastprompttimer
v0.3.4no licenseupdated 2026-10-16akixi-maison/usage-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-mods
› fix the failing auth test and add an audit log call ● usage-mods: usage-mods: session started: surface terminal, interactive true ⏺ 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 컨텍스트 █████░░░░░ [ 압축 ] 5시간 ███░░░░░░░ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
컨텍스트 █████░░░░░ [ 압축 ] 5시간 ███░░░░░░░
README

usage-mods

Claude Code 입력창 위에 사용량을 보여주는 모드입니다.

컨텍스트 ███░░░░░░░  [ 압축 ]  5시간 ██░░░░░░░░  7일 █░░░░░░░░░ 초기화까지 3일 0시간
  • 컨텍스트, 5시간, 7일 사용률을 막대로 표시합니다. 데스크톱 앱에서는 둥근 막대, 터미널에서는 10칸 글자 막대입니다. 마우스를 올리면 퍼센트가 보이고, 5시간·7일 막대에서는 23% · 2시간 13분 후 초기화처럼 초기화까지 남은 시간도 함께 보입니다. 데스크톱 앱에서는 막대 위에 뜨는 카드에 글자로, 터미널에서는 막대 자리에 반전 글자로 보입니다.
  • 80% 미만은 초록, 80% 이상은 노랑, 90% 이상은 빨강입니다. 글자 색은 쓰는 테마의 성공·경고·오류 색을 따릅니다.
  • [ 압축 ] 버튼은 답변이 끝났고 컨텍스트에 내용이 있을 때만 켜집니다. 꺼져 있을 때는 흐리게 보이고, 누르면 왜 안 되는지 알려 줍니다. 켜졌을 때 누르면 "압축하시겠습니까? 예 / 아니오"를 묻고, 예를 고르면 /compact와 같이 압축합니다.
  • 7일 막대 바로 옆에 주간 한도가 초기화되기까지 남은 시간을 보여줍니다. 하루 미만이면 22시간 47분, 한 시간 미만이면 13분으로 바뀌고 1분마다 갱신됩니다.
  • 5시간/7일 사용률과 초기화 카운트다운은 구독 계정에서만 표시됩니다.
  • API 키로 쓸 때는 맨 끝에 이 대화의 비용($0.50)을 보여 줍니다. 구독 계정에서는 실제로 청구되는 돈이 아니라서 보이지 않습니다.

설치

터미널에서 Claude Code를 열고 입력합니다.

/plugin install usage-mods --marketplace akixi-maison/usage-mods

Add marketplace?에 y, 설치 범위는 User를 고르면 모든 프로젝트에 적용됩니다.

Source 2 files
hooks/register.tsx 487 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMeasureInput, Timer } from 'claude-code'
3
4import type { Usage } from '../types'
5
6const usage = atom({ plugin: 'usage-mods', key: 'usage' } as const, null)
7const isCompacting = atom({ plugin: 'usage-mods', key: 'isCompacting' } as const, false)
8const isTurnRunning = atom({ plugin: 'usage-mods', key: 'isTurnRunning' } as const, false)
9const clockNow = atom({ plugin: 'usage-mods', key: 'now' } as const, 0)
10
11const LABELS: Record<string, string> = { five_hour: '5시간', seven_day: '7일', spend_limit: '한도' }
12
13const toUsage = (m: Pick<SessionMeasureInput, 'context' | 'rateLimits' | 'cost'>): Usage => ({
14  contextPercent: m.context.percent,
15  limits: m.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
16  usd: m.cost?.usd,
17})
18
19const BAR_WIDTH = 10
20
21export type Meter = { key: string; label: string; percent: number }
22
23export const meters = (u: Usage): Meter[] => [
24  { key: 'context', label: '컨텍스트', percent: u.contextPercent ?? 0 },
25  ...u.limits.map(l => ({ key: l.kind, label: LABELS[l.kind] ?? l.kind, percent: l.percentUsed })),
26]
27
28export const bar = (percent: number): string => {
29  const filled = Math.round((Math.min(Math.max(percent, 0), 100) / 100) * BAR_WIDTH)
30  return '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
31}
32
33/** The theme's own colors for a meter, so the band follows the person's theme. */
34export const meterColor = (percent: number): 'error' | 'warning' | 'success' =>
35  percent >= 90 ? 'error' : percent >= 80 ? 'warning' : 'success'
36
37// Off the terminal a bar is a picture: a rounded track and its fill, in CSS pixels. A picture cannot
38// follow the theme, so the track is a see-through grey and the fills read on light and dark alike.
39export const BAR_PX = { width: 96, height: 6 }
40const BAR_FILL: Record<ReturnType<typeof meterColor>, string> = { success: '#2f9e57', warning: '#c27c0e', error: '#d93f3f' }
41
42/** The bar as SVG: the fill in proportion, clamped, never thinner than its own height once above 0. */
43export const barSvg = (percent: number, color: ReturnType<typeof meterColor>): string => {
44  const { width, height } = BAR_PX
45  const p = Math.min(Math.max(percent, 0), 100)
46  const filled = p === 0 ? 0 : Math.max((p / 100) * width, height)
47  const rect = (w: number, paint: string) =>
48    `<rect width="${Number(w.toFixed(1))}" height="${height}" rx="${height / 2}" ${paint}/>`
49  return (
50    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
51    rect(width, 'fill="#808080" fill-opacity="0.25"') +
52    (filled === 0 ? '' : rect(filled, `fill="${BAR_FILL[color]}"`)) +
53    '</svg>'
54  )
55}
56
57const center = (text: string): string => {
58  const pad = Math.max(BAR_WIDTH - text.length, 0)
59  const left = Math.floor(pad / 2)
60  return ' '.repeat(left) + text + ' '.repeat(pad - left)
61}
62
63/** `$.session.compact` refuses in a headless (SDK) session, which is how the desktop app runs Claude Code. */
64export const isHeadlessRefusal = (message: string): boolean => /headless/i.test(message)
65
66/**
67 * Whether a refused compaction is sent as a /compact prompt instead: in a session with no person at
68 * the prompt (the desktop app, an SDK host) whatever the reason, or when the refusal says headless.
69 */
70export const shouldSendCompact = (message: string, isInteractive: boolean): boolean =>
71  !isInteractive || isHeadlessRefusal(message)
72
73/**
74 * Compaction runs between turns over a conversation the model has answered:
75 * not while a turn runs, not while one is under way, and not before the live
76 * window's first response (a fresh or just-compacted session has no fill yet).
77 */
78export const canCompact = (u: Usage, isWorking: boolean, compacting: boolean): boolean =>
79  !isWorking && !compacting && (u.contextPercent ?? 0) > 0
80
81const MINUTE = 60_000
82const HOUR = 60 * MINUTE
83const DAY = 24 * HOUR
84
85/** `3일 0시간` from a day out, `2시간 13분` within one, `13분` within the hour. */
86export const timeLeft = (resetsAt: string, now: number): string => {
87  const ms = Math.max(0, Date.parse(resetsAt) - now)
88  const days = Math.floor(ms / DAY)
89  const hours = Math.floor((ms % DAY) / HOUR)
90  const minutes = Math.floor((ms % HOUR) / MINUTE)
91  return days > 0 ? `${days}일 ${hours}시간` : hours > 0 ? `${hours}시간 ${minutes}분` : `${minutes}분`
92}
93
94/** `초기화까지 3일 0시간`; null while the 7-day window has no reset time. */
95export const weeklyReset = (u: Usage, now: number): string | null => {
96  const resetsAt = u.limits.find(l => l.kind === 'seven_day')?.resetsAt
97  return resetsAt === undefined ? null : `초기화까지 ${timeLeft(resetsAt, now)}`
98}
99
100/** Whether the account has a subscription's 5-hour or 7-day limits, which are what bound its use. */
101export const isSubscription = (u: Usage): boolean =>
102  u.limits.some(l => l.kind === 'five_hour' || l.kind === 'seven_day')
103
104/** `$0.50`, the session's cost at API prices; null on a subscription, which is not billed by it. */
105export const costText = (u: Usage): string | null =>
106  u.usd === undefined || isSubscription(u) ? null : `$${u.usd.toFixed(2)}`
107
108export type Hover = { percent: string; reset?: string }
109
110/** What hovering a meter shows: the percent, and when its window resets where it has a reset time. */
111export const hoverParts = (u: Usage, m: Meter, now: number): Hover => {
112  const resetsAt = u.limits.find(l => l.kind === m.key)?.resetsAt
113  return { percent: `${m.percent}%`, reset: resetsAt === undefined ? undefined : `${timeLeft(resetsAt, now)} 후 초기화` }
114}
115
116/** The hover as one line, `23% · 2시간 13분 후 초기화`. */
117export const hoverText = (u: Usage, m: Meter, now: number): string => {
118  const { percent, reset } = hoverParts(u, m, now)
119  return reset === undefined ? percent : `${percent} · ${reset}`
120}
121
122// Moves the clock the countdown reads, so the band redraws once a minute.
123const tick = async ($: EngineInterface) => {
124  const now = await $.clock.now()
125  await update($, clockNow, () => now)
126}
127
128const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
129
130// --- Compaction trace (temporary) -------------------------------------------------------------
131// Kept while checking that the compact button really compacts in the desktop app, where the
132// debug log is out of reach: each step is also appended to ~/.claude/usage-mods-compact.log.
133// Remove this block and the trace() calls once compaction is confirmed.
134
135export const LOG_FILE = '.claude/usage-mods-compact.log'
136const LOG_LIMIT = 300
137
138export const logLine = (time: number, session: string, text: string): string =>
139  `${new Date(time).toISOString()} [${session.slice(0, 8)}] ${text}`
140
141/** The log's text with `line` added, keeping the newest `limit` lines. */
142export const appendLine = (text: string, line: string, limit = LOG_LIMIT): string =>
143  [...text.split('\n').filter(l => l !== ''), line].slice(-limit).join('\n') + '\n'
144
145/** A prompt as the trace names it: `/compact` itself, otherwise only its length. */
146export const describePrompt = (text: string): string =>
147  text.trim() === '/compact' ? '/compact' : `prompt of ${text.length} chars`
148
149// One write at a time, so one session's lines keep their order; every session shares the file.
150let traceQueue: Promise<void> = Promise.resolve()
151
152const writeTrace = async ($: EngineInterface, text: string) => {
153  const home = await $.env.get('HOME')
154  if (home === undefined || home === '') {
155    return
156  }
157  const path = `${home}/${LOG_FILE}`
158  const [now, session, before] = await Promise.all([
159    $.clock.now(),
160    $.session.id().catch(() => '?'),
161    $.fs.read(path).catch(() => ''),
162  ])
163  await $.fs.write(path, appendLine(before, logLine(now, session, text)))
164}
165
166const trace = ($: EngineInterface, text: string) => {
167  $.ui.log(`usage-mods: ${text}`, { to: 'debug' })
168  traceQueue = traceQueue.then(() => writeTrace($, text)).catch(() => {})
169}
170
171// The band's isWorking as last drawn, for the trace to set beside the plugin's own turn tracking.
172let lastIsWorking: boolean | undefined
173// --- end of compaction trace -------------------------------------------------------------------
174
175export const QUESTION = '압축하시겠습니까?'
176export const YES = '예'
177export const NO = '아니오'
178
179/** What pressing the button says while it is off. */
180export const OFF_HINT = '답변이 끝나면 압축할 수 있어요'
181export const COMPACTING_HINT = '압축하는 중이에요'
182
183/** How long the /compact the band ran may wait for its compaction before the band lets go. */
184export const QUEUE_TIMEOUT = 10 * MINUTE
185
186// Dismissing the dialog counts as 아니오.
187const confirmCompact = async ($: EngineInterface): Promise<boolean> => {
188  try {
189    return (await $.ui.ask(QUESTION, { header: '압축', options: [YES, NO] })) === YES
190  } catch {
191    return false
192  }
193}
194
195// One compaction at a time across redraws and repeated presses; the module's own, so a reload clears it.
196let isBusy = false
197
198// Whether a person is at the prompt, as session.start said: false in the desktop app.
199let isInteractive = true
200
201// A /compact the band sent, waiting for the compaction it asked for; `timer` lets go of it.
202let waiting: { timer: Timer | null } | null = null
203
204const finishWaiting = async ($: EngineInterface, toast: string) => {
205  if (waiting === null) {
206    return
207  }
208  waiting.timer?.cancel()
209  waiting = null
210  await update($, isCompacting, () => false)
211  $.ui.toast(toast)
212}
213
214/**
215 * After the /compact command: whether it compacted, read from the context fill (it drops, or is
216 * unknown until the next answer) in case no `session.compact` reached this plugin.
217 */
218export const hasCompacted = (before: number, after: number | undefined): boolean => (after ?? 0) < before
219
220const endCompactCommand = async ($: EngineInterface, before: number, output: string) => {
221  const now = await $.session.usage()
222  await update($, usage, () => toUsage(now))
223  trace($, `/compact command ended, context ${before}% → ${now.context.percent}%: ${output || '(no output)'}`)
224  if (hasCompacted(before, now.context.percent)) {
225    await finishWaiting($, '컨텍스트를 압축했어요')
226  } else {
227    await finishWaiting($, output ? `압축되지 않았어요: ${output}` : '압축되지 않았어요')
228  }
229}
230
231/**
232 * The desktop app and other SDK hosts compact only inside a turn: runs /compact as if the person
233 * typed it (`$.prompt.submit` refuses a text that starts with `/`), queued until the session is idle.
234 * The band keeps compacting until the compaction lands (`session.compact`), the command ends
235 * (compacted or not), or the wait runs out.
236 */
237const sendCompact = ($: EngineInterface, before: number) => {
238  waiting = {
239    timer: $.clock.after(QUEUE_TIMEOUT, () => {
240      trace($, `no compaction within ${QUEUE_TIMEOUT / MINUTE} minutes; letting go`)
241      void finishWaiting($, '압축되지 않았어요. 입력창에 /compact를 보내 주세요')
242    }),
243  }
244  $.ui.toast('압축을 요청했어요')
245  void (async () => {
246    try {
247      const result = await $.command.run({ command: 'compact' })
248      await endCompactCommand($, before, result.text ?? '')
249    } catch (error) {
250      const message = errorMessage(error)
251      trace($, `/compact command failed: ${message}`)
252      await finishWaiting($, `압축하지 못했어요: ${message}`)
253    }
254  })()
255}
256
257/**
258 * Asks, then compacts. Runs on a timer, not inside the press: the band redraws often (usage updates,
259 * the minute clock), and work tied to the drawn Button or its press dispatch is lost with it.
260 */
261const runCompact = async ($: EngineInterface) => {
262  let isWaiting = false
263  try {
264    const [u, turnRunning] = await Promise.all([read($, usage), read($, isTurnRunning)])
265    trace($, `pressed: band isWorking=${lastIsWorking}, turn running=${turnRunning}, context=${u?.contextPercent}%`)
266    if (!(await confirmCompact($))) {
267      trace($, 'declined')
268      return
269    }
270    await update($, isCompacting, () => true)
271    try {
272      const result = await $.session.compact()
273      if (result.skip !== undefined) {
274        trace($, `compact skipped: ${result.skip}`)
275        $.ui.toast(`압축하지 않았어요: ${result.skip}`)
276      } else {
277        trace($, 'compacted')
278        const after = await $.session.usage()
279        await update($, usage, () => toUsage(after))
280        $.ui.toast('컨텍스트를 압축했어요')
281      }
282    } catch (error) {
283      const message = errorMessage(error)
284      trace($, `compact refused: ${message}`)
285      if (shouldSendCompact(message, isInteractive)) {
286        sendCompact($, u?.contextPercent ?? 0)
287        isWaiting = true
288      } else {
289        $.ui.toast(`압축하지 못했어요: ${message}`)
290      }
291    }
292  } finally {
293    if (!isWaiting) {
294      await update($, isCompacting, () => false)
295    }
296    isBusy = false
297  }
298}
299
300const startCompact = ($: EngineInterface) => {
301  if (isBusy || waiting !== null) {
302    return
303  }
304  isBusy = true
305  try {
306    $.clock.after(0, () => void runCompact($))
307  } catch (error) {
308    isBusy = false
309    throw error
310  }
311}
312
313export const register: Register = on => {
314  // Takes the press by its key, before the drawn Button's own onPress: a redraw replaces that handler.
315  on('ui.press', { plugin: 'usage-mods', element: 'compact' }, ($, e) => {
316    startCompact($)
317    return { element: e.element }
318  })
319
320  // The button while it is off: a press only says why.
321  on('ui.press', { plugin: 'usage-mods', element: 'compact-off' }, async ($, e) => {
322    $.ui.toast((await read($, isCompacting)) ? COMPACTING_HINT : OFF_HINT)
323    return { element: e.element }
324  })
325
326  on('session.start', async ($, e, next) => {
327    const result = await next(e)
328    isInteractive = e.isInteractive
329    trace($, `session started: surface ${e.surface}, interactive ${e.isInteractive}`)
330    // A reload drops the module's waiting /compact, so the band starts out of it.
331    await update($, isCompacting, () => false)
332    await update($, isTurnRunning, () => false)
333    const u = await $.session.usage()
334    await update($, usage, () => toUsage(u))
335    await tick($)
336    $.clock.every(MINUTE, () => void tick($))
337    return result
338  })
339
340  on('session.measure', async ($, e, next) => {
341    await update($, usage, () => toUsage(e))
342    return next(e)
343  })
344
345  // The plugin's own record of a running turn, beside the band's isWorking: the button is off while either says so.
346  on('turn.start', async ($, e, next) => {
347    await update($, isTurnRunning, () => true)
348    if (waiting !== null) {
349      trace($, `turn started while /compact waits: ${describePrompt(e.text)}`)
350    }
351    return next(e)
352  })
353
354  on('turn.complete', async ($, e, next) => {
355    const result = await next(e)
356    if (e.agentId === undefined) {
357      await update($, isTurnRunning, () => false)
358      if (waiting !== null) {
359        trace($, `turn ended (${e.reason}) while /compact waits`)
360      }
361    }
362    return result
363  })
364
365  // Every compaction of the main conversation: the one a /compact the band sent waits for.
366  on('session.compact', async ($, e, next) => {
367    if (e.agentId !== undefined || e.trigger === 'precompute') {
368      return next(e)
369    }
370    trace($, `compaction starting (trigger ${e.trigger})`)
371    const result = await next(e).catch(async error => {
372      const message = errorMessage(error)
373      trace($, `compaction failed (trigger ${e.trigger}): ${message}`)
374      await finishWaiting($, `압축하지 못했어요: ${message}`)
375      throw error
376    })
377    if (result.skip !== undefined) {
378      trace($, `compaction skipped (trigger ${e.trigger}): ${result.skip}`)
379      await finishWaiting($, `압축하지 않았어요: ${result.skip}`)
380    } else {
381      trace($, `compaction done (trigger ${e.trigger}): ${result.tokensBefore} → ${result.tokensAfter} tokens`)
382      await finishWaiting($, '컨텍스트를 압축했어요')
383    }
384    return result
385  }).catch(($, e, next) => next(e))
386
387  // Trace only: what became of a /compact, the band's or typed, and of prompts sent while it waits.
388  on('prompt.submit', async ($, e, next) => {
389    const result = await next(e)
390    if (waiting !== null || e.text.trim() === '/compact') {
391      const outcome = result.drop === undefined ? 'entered' : `dropped: ${result.drop}`
392      trace($, `prompt from ${e.origin.kind}: ${describePrompt(e.text)}, ${outcome}`)
393    }
394    return result
395  }).catch(($, e, next) => next(e))
396
397  on('command.run', { command: 'compact' }, async ($, e, next) => {
398    trace($, `/compact command from ${e.origin.kind}`)
399    return next(e)
400  }).catch(($, e, next) => next(e))
401
402  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
403    const u = await read($, usage)
404    if (e.props.hasSurvey || u === null) {
405      return next(e)
406    }
407    lastIsWorking = e.props.isWorking // compaction trace
408
409    const rows = meters(u)
410    const hasReset = u.limits.some(l => l.resetsAt !== undefined)
411    const now = hasReset ? (await read($, clockNow)) || (await $.clock.now()) : 0
412    const reset = weeklyReset(u, now)
413    const cost = costText(u)
414    const compacting = await read($, isCompacting)
415    const turnRunning = await read($, isTurnRunning)
416    const isEnabled = canCompact(u, e.props.isWorking || turnRunning, compacting)
417    const table = $.ui.resolve(e)
418    const { Box, Button, Text } = table
419    // The terminal draws the bar in block glyphs; elsewhere they show seams and a dotted track.
420    const Svg = e.surface !== 'terminal' && 'Svg' in table ? table.Svg : undefined
421
422    // The terminal lays the hover over the bar's own cells, reversed; the desktop draws the hover Box
423    // as a card of its own above the band, where reversed text reads as a highlighter mark.
424    const hoverCard = (m: Meter, color: ReturnType<typeof meterColor>) => {
425      const { percent, reset } = hoverParts(u, m, now)
426      return e.surface === 'terminal' ? (
427        <Text bold inverse color={color}>{center(hoverText(u, m, now))}</Text>
428      ) : (
429        <Box flexDirection="row">
430          <Text bold color={color}>{percent}</Text>
431          {reset === undefined ? null : <Text dimColor>{` · ${reset}`}</Text>}
432        </Box>
433      )
434    }
435
436    const meter = (m: Meter) => {
437      const color = meterColor(m.percent)
438      // The weekly countdown sits beside the 7-day bar it belongs to.
439      const after = m.key === 'seven_day' ? reset : null
440      return (
441        <Box key={m.key} flexDirection="row" alignItems="center" marginRight={2}>
442          <Text dimColor>{m.label} </Text>
443          <Box key={`${m.key}-bar`}>
444            {Svg === undefined ? (
445              <Text color={color}>{bar(m.percent)}</Text>
446            ) : (
447              <Svg source={barSvg(m.percent, color)} alt={`${m.label} ${m.percent}%`} {...BAR_PX} />
448            )}
449            <Box position="absolute" top={0} left={0} display="none" hover={{ display: 'flex' }}>
450              {hoverCard(m, color)}
451            </Box>
452          </Box>
453          {after === null ? null : <Text dimColor> {after}</Text>}
454        </Box>
455      )
456    }
457
458    // meters() always leads with the context meter.
459    const [context, ...limits] = rows as [Meter, ...Meter[]]
460
461    return (
462      <Box flexDirection="row">
463        {meter(context)}
464        <Box key="compact-slot" marginRight={2}>
465          {isEnabled ? (
466            <Button key="compact" label="압축" onPress={() => startCompact($)} />
467          ) : (
468            // Off, the same button drawn dim, so the band keeps one look on every surface.
469            <Button
470              key="compact-off"
471              label={compacting ? '압축 중…' : '압축'}
472              dimColor
473              onPress={() => $.ui.toast(compacting ? COMPACTING_HINT : OFF_HINT)}
474            />
475          )}
476        </Box>
477        {limits.map(meter)}
478        {cost === null ? null : (
479          <Box key="usd" marginRight={2}>
480            <Text dimColor>{cost}</Text>
481          </Box>
482        )}
483      </Box>
484    )
485  })
486}
487
types/index.d.ts 12 lines
1export type Usage = {
2  contextPercent?: number
3  limits: { kind: string; percentUsed: number; resetsAt?: string }[]
4  usd?: number
5}
6
7declare module 'claude-code' {
8  interface PluginState {
9    'usage-mods': { usage: Usage | null; isCompacting: boolean; isTurnRunning: boolean; now: number }
10  }
11}
12