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

Claude Code 입력창 위에 사용량을 보여주는 모드입니다.
컨텍스트 ███░░░░░░░ [ 압축 ] 5시간 ██░░░░░░░░ 7일 █░░░░░░░░░ 초기화까지 3일 0시간
23% · 2시간 13분 후 초기화처럼 초기화까지 남은 시간도 함께 보입니다. 데스크톱 앱에서는 막대 위에 뜨는 카드에 글자로, 터미널에서는 막대 자리에 반전 글자로 보입니다.[ 압축 ] 버튼은 답변이 끝났고 컨텍스트에 내용이 있을 때만 켜집니다. 꺼져 있을 때는 흐리게 보이고, 누르면 왜 안 되는지 알려 줍니다. 켜졌을 때 누르면 "압축하시겠습니까? 예 / 아니오"를 묻고, 예를 고르면 /compact와 같이 압축합니다.22시간 47분, 한 시간 미만이면 13분으로 바뀌고 1분마다 갱신됩니다.$0.50)을 보여 줍니다. 구독 계정에서는 실제로 청구되는 돈이 아니라서 보이지 않습니다.터미널에서 Claude Code를 열고 입력합니다.
/plugin install usage-mods --marketplace akixi-maison/usage-mods
Add marketplace?에 y, 설치 범위는 User를 고르면 모든 프로젝트에 적용됩니다.
hooks/register.tsx 487 lines1import { 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}
487types/index.d.ts 12 lines1export 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