현재 세션의 컨텍스트 윈도우 사용량을 상태 줄에 표시한다

Claude Code 모드(함수 훅 플러그인) 모음이다. 저장소 루트의 .claude-plugin/marketplace.json이 모드 목록이고, 모드마다 하위 폴더 하나를 쓴다.
| 모드 | 설명 | |
|---|---|---|
| context-meter | 상태 줄에 컨텍스트 사용량을 자동 압축 임계치 기준(꺼져 있으면 모델 윈도우)으로 표시하고, 80%·90% 도달 시 토스트로 알린다. `/context-meter [on\ | off]로 켜고 끄며(인자 없으면 전환, 세션 간 유지) /context-meter detail로 카테고리별 내역 패널을 연다. 설정(/config): 증가량·남은 턴(기본 켬), 비용·플랜 한도(기본 끔), N% 자동 압축(기본 끔, N=90 — /context-meter off` 상태에서는 동작하지 않음). |
터미널의 Claude Code 프롬프트에서 실행한다.
/plugin install context-meter --marketplace jongcheol-pak/Claude-Code-Mod
마켓플레이스 추가 질문에 y, 설치 범위를 고르면 바로 활성화된다. 설정 항목(userConfig)이 있는 모드는 그 사이에 옵션 설정 화면이 이어질 수 있다.
모드 폴더를 직접 로드해 실행한다.
claude --plugin-dir ./context-meter
검증·테스트:
claude plugin validate ./context-meter
claude plugin test ./context-meter
새 모드를 추가하면 하위 폴더를 만들고 .claude-plugin/marketplace.json의 plugins와 위 모드 목록에 한 줄씩 추가한다.
hooks/register.ts 258 lines1import type { EngineInterface, Register, SessionUsage } from 'claude-code'
2
3import { renderDetailPane } from './detail-pane.tsx'
4import {
5 composeStatus,
6 crossedPercents,
7 detailHeader,
8 detailRows,
9 formatWarning,
10 percentOf,
11 readAutoCompact,
12 readDisplay,
13 recordGrowth,
14 resolveLimit,
15 shouldAutoCompact,
16} from './meter.ts'
17import type { AutoCompact, Display } from './meter.ts'
18
19const COMMAND = 'context-meter'
20const ENABLED_KEY = 'isEnabled'
21const USAGE = `사용법: /${COMMAND} [on|off|detail] — 인자 없이 실행하면 켜고 끄기를 전환, detail 은 카테고리별 내역 패널`
22const PANE_ID = 'context-meter-detail'
23
24// 켜짐/꺼짐은 세션을 넘어 유지된다 — 한 번도 끈 적 없으면 켜짐
25const isEnabled = async ($: EngineInterface): Promise<boolean> =>
26 (await $.store.get(ENABLED_KEY)) !== false
27
28type Basis = { limit: number; isCompactBasis: boolean }
29
30// 자동 압축 임계치는 측정 이벤트에 없어 breakdown(로컬 추정, API 요청 없음)에서 읽는다
31const readBasis = async ($: EngineInterface, window: number): Promise<Basis> => {
32 const { context } = await $.session.usage({ breakdown: 'summary' })
33 const threshold = context.breakdown?.autoCompactThreshold
34
35 return { limit: resolveLimit(window, threshold), isCompactBasis: threshold !== undefined }
36}
37
38// 경고 기준선(직전 측정의 %) — 재로드·clear 직후에는 비어 있어 첫 측정은 기준선만 잡는다
39let lastPercent: number | undefined
40
41const warnCrossings = ($: EngineInterface, tokens: number | undefined, basis: Basis): void => {
42 if (tokens === undefined) {
43 return
44 }
45
46 const percent = percentOf(tokens, basis.limit)
47
48 for (const crossed of crossedPercents(lastPercent, percent)) {
49 $.ui.toast(formatWarning(crossed, tokens, basis.limit, basis.isCompactBasis))
50 }
51
52 lastPercent = percent
53}
54
55// userConfig 로 고른 덧붙임 항목 — 설정을 바꾸면 모듈이 다시 로드돼 register 가 새 값을 넣는다
56let display: Display = readDisplay({})
57
58// 증가량 이력과 직전 토큰 — /clear 시 비운다
59let growth: number[] = []
60let lastTokens: number | undefined
61
62const trackGrowth = (tokens: number | undefined): void => {
63 if (tokens === undefined) {
64 return
65 }
66
67 growth = recordGrowth(growth, lastTokens, tokens)
68 lastTokens = tokens
69}
70
71// N% 자동 압축 설정과 시도 표시 — N 미만으로 내려가거나 /clear 하면 다시 시도할 수 있다
72let autoCompact: AutoCompact = readAutoCompact({})
73let isCompactAttempted = false
74
75const AUTO_COMPACT_DELAY_MS = 1_000
76
77// 엔진은 턴 중의 압축을 거부한다 — 거부·건너뜀은 알리고 재시도는 구간이 바뀔 때까지 미룬다
78const runAutoCompact = async ($: EngineInterface): Promise<void> => {
79 try {
80 const result = await $.session.compact()
81
82 if ('skip' in result) {
83 $.ui.toast(`자동 압축을 건너뛰었습니다: ${result.skip}`)
84 }
85 } catch (error) {
86 $.ui.toast(`자동 압축 실패: ${error instanceof Error ? error.message : String(error)}`)
87 }
88}
89
90// 측정 디스패치 안에서 압축을 기다리지 않도록 잠시 뒤로 예약한다
91const scheduleAutoCompact = ($: EngineInterface, tokens: number | undefined, basis: Basis): void => {
92 if (tokens === undefined) {
93 return
94 }
95
96 const percent = percentOf(tokens, basis.limit)
97
98 if (percent < autoCompact.percent) {
99 isCompactAttempted = false
100 return
101 }
102
103 if (!shouldAutoCompact(percent, autoCompact, isCompactAttempted)) {
104 return
105 }
106
107 isCompactAttempted = true
108 $.clock.after(AUTO_COMPACT_DELAY_MS, () => {
109 void runAutoCompact($)
110 })
111}
112
113const resetHistory = (): void => {
114 growth = []
115 lastTokens = undefined
116 lastPercent = undefined
117 isCompactAttempted = false
118}
119
120// 측정 이벤트와 $.session.usage() 가 같은 이름으로 주는 값
121type Snapshot = Pick<SessionUsage, 'context' | 'cost' | 'rateLimits'>
122
123const draw = ($: EngineInterface, snapshot: Snapshot, basis: Basis): void => {
124 $.ui.status(composeStatus({
125 tokens: snapshot.context.tokens,
126 limit: basis.limit,
127 growth,
128 cost: snapshot.cost,
129 rateLimits: snapshot.rateLimits,
130 display,
131 }))
132}
133
134const showCurrent = async ($: EngineInterface): Promise<void> => {
135 const usage = await $.session.usage({ breakdown: 'summary' })
136 const threshold = usage.context.breakdown?.autoCompactThreshold
137 draw($, usage, { limit: resolveLimit(usage.context.window, threshold), isCompactBasis: threshold !== undefined })
138}
139
140// 다시 그리기가 실패해도 압축·clear 자체는 막지 않는다 — next 는 재호출해도 같은 결과를 돌려준다
141const passThrough = <E, R>($: unknown, e: E, next: (e: E) => R): R => next(e)
142
143export const register: Register = (on, options) => {
144 display = readDisplay(options)
145 autoCompact = readAutoCompact(options)
146
147 // 세션 시작(재로드 포함) 시 명령을 등록하고, 켜져 있으면 현재 값으로 한 번 그린다
148 on('session.start', async ($, e, next) => {
149 await $.command.register({
150 name: COMMAND,
151 description: '상태 줄의 컨텍스트 사용량 표시를 켜거나 끄고, detail 로 내역 패널을 연다',
152 argumentHint: '[on|off|detail]',
153 })
154
155 if (await isEnabled($)) {
156 await showCurrent($)
157 }
158
159 return next(e)
160 })
161
162 // 턴마다 엔진이 측정값을 밀어 준다 — 컨텍스트가 바뀌었거나, 켜 둔 비용·한도가 바뀐 경우만 갱신
163 on('session.measure', async ($, e, next) => {
164 const isContextChange = e.changed.includes('context')
165 const isExtraChange = (display.showCost && e.changed.includes('cost'))
166 || (display.showRateLimits && e.changed.includes('rateLimits'))
167
168 if ((isContextChange || isExtraChange) && (await isEnabled($))) {
169 const basis = await readBasis($, e.context.window)
170
171 if (isContextChange) {
172 trackGrowth(e.context.tokens)
173 warnCrossings($, e.context.tokens, basis)
174 scheduleAutoCompact($, e.context.tokens, basis)
175 }
176
177 draw($, e, basis)
178
179 if (isContextChange) {
180 $.ui.invalidate('ui.render')
181 }
182 }
183
184 return next(e)
185 })
186
187 // 압축 직후 토큰은 다음 응답 전까지 비어 있다 — 메인 대화가 실제로 압축됐을 때만 다시 그린다
188 on('session.compact', async ($, e, next) => {
189 const result = await next(e)
190 const isMainCompaction = e.agentId === undefined && e.trigger !== 'precompute' && !('skip' in result)
191
192 if (isMainCompaction && (await isEnabled($))) {
193 await showCurrent($)
194 }
195
196 return result
197 }).catch(passThrough)
198
199 // /clear 는 session.start 를 다시 부르지 않는다 — classic SessionStart(source clear)로 받는다
200 on('classic.SessionStart', async ($, e, next) => {
201 const result = await next(e)
202
203 if (e.source !== 'clear') {
204 return result
205 }
206
207 resetHistory()
208
209 if (await isEnabled($)) {
210 await showCurrent($)
211 }
212
213 return result
214 }).catch(passThrough)
215
216 // 내역 패널 — 열릴 때와 측정 뒤 다시 그릴 때마다 최신 내역을 읽는다
217 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
218 const { context } = await $.session.usage({ breakdown: 'summary' })
219 const elements = $.ui.resolve(e)
220
221 if (context.breakdown === undefined) {
222 return renderDetailPane(elements, '내역을 읽을 수 없습니다', [])
223 }
224
225 const { categories, totalTokens, rawMaxTokens, autoCompactThreshold, autocompactSource } = context.breakdown
226 // autocompactSource 'auto' 는 모델 자체 한도, 나머지는 누군가 정한 더 작은 압축 창이다
227 const windowLabel = autocompactSource === 'auto' ? '모델 윈도우' : '압축 창'
228 const header = detailHeader(totalTokens, rawMaxTokens, windowLabel, autoCompactThreshold)
229
230 return renderDetailPane(elements, header, detailRows(categories, rawMaxTokens))
231 })
232
233 on('command.run', { command: COMMAND }, async ($, e) => {
234 const arg = e.args.trim().toLowerCase()
235
236 if (arg === 'detail') {
237 const opened = await $.ui.open({ id: PANE_ID, title: '컨텍스트 내역' })
238
239 return { text: opened.isPlaced ? '컨텍스트 내역 패널을 열었습니다' : `내역 패널을 열지 못했습니다: ${opened.reason}` }
240 }
241
242 if (arg !== '' && arg !== 'on' && arg !== 'off') {
243 return { text: USAGE }
244 }
245
246 const shouldEnable = arg === '' ? !(await isEnabled($)) : arg === 'on'
247 await $.store.set(ENABLED_KEY, shouldEnable)
248
249 if (shouldEnable) {
250 await showCurrent($)
251 } else {
252 $.ui.status(undefined)
253 }
254
255 return { text: `컨텍스트 사용량 표시: ${shouldEnable ? '켜짐' : '꺼짐'}` }
256 })
257}
258hooks/detail-pane.tsx 27 lines1// /context-meter detail 패널의 트리 — 엔진을 받지 않고 요소 테이블과 행만 받는다
2import type { Elements } from 'claude-code'
3
4import type { DetailRow } from './meter.ts'
5
6export type PaneElements = Pick<Elements['terminal'], 'Box' | 'Text'>
7
8const NAME_WIDTH = 28
9const TOKENS_WIDTH = 7
10
11// 이름 · 토큰 · % 를 고정 폭으로 맞춘 한 줄
12const formatRow = (row: DetailRow): string =>
13 `${row.name.padEnd(NAME_WIDTH)} ${row.tokens.padStart(TOKENS_WIDTH)}${row.percent === undefined ? '' : ` ${row.percent}`}`
14
15export const renderDetailPane = (elements: PaneElements, header: string, rows: readonly DetailRow[]) => {
16 const { Box, Text } = elements
17
18 return (
19 <Box flexDirection="column">
20 <Text key="header" bold>{header}</Text>
21 {rows.map(row => (
22 <Text key={row.name} dimColor={row.percent === undefined}>{formatRow(row)}</Text>
23 ))}
24 </Box>
25 )
26}
27hooks/meter.ts 209 lines1// 엔진에 의존하지 않는 계산·표기 — register.ts 가 엔진 값을 넣어 호출한다
2
3const BAR_WIDTH = 10
4
5// 소수 첫째 자리까지, 0 이면 생략 (84.0 → 84, 84.5 → 84.5)
6const trimDecimal = (value: number): string => {
7 const fixed = value.toFixed(1)
8
9 return fixed.endsWith('.0') ? fixed.slice(0, -2) : fixed
10}
11
12// 토큰 수를 200k · 84.5k · 1.5M 형태로 줄인다
13export const formatTokens = (n: number): string => {
14 if (n >= 1_000_000) {
15 return `${trimDecimal(n / 1_000_000)}M`
16 }
17
18 if (n >= 1_000) {
19 return `${trimDecimal(n / 1_000)}k`
20 }
21
22 return `${n}`
23}
24
25// % 의 분모: 자동 압축 임계치가 있으면 그것, 꺼져 있으면 모델 윈도우
26export const resolveLimit = (window: number, autoCompactThreshold?: number): number =>
27 autoCompactThreshold ?? window
28
29export const percentOf = (tokens: number, limit: number): number =>
30 Math.round((tokens / limit) * 100)
31
32const renderBar = (percent: number): string => {
33 const filled = Math.min(BAR_WIDTH, Math.round((percent / 100) * BAR_WIDTH))
34
35 return '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
36}
37
38// 상태 줄의 미터 부분: 막대 · % · 사용/기준. 첫 응답 전(tokens 없음)이면 기준만
39export const formatMeter = (tokens: number | undefined, limit: number): string => {
40 if (tokens === undefined) {
41 return `컨텍스트 –/${formatTokens(limit)}`
42 }
43
44 const percent = percentOf(tokens, limit)
45
46 return `컨텍스트 ${renderBar(percent)} ${percent}% · ${formatTokens(tokens)}/${formatTokens(limit)}`
47}
48
49export const WARN_PERCENTS = [80, 90] as const
50
51// 이전 측정에서 이번 측정 사이에 새로 넘은 경고 % — 이전 값이 없으면(첫 측정) 기준선만 잡는다
52export const crossedPercents = (previous: number | undefined, current: number): number[] => {
53 if (previous === undefined) {
54 return []
55 }
56
57 return WARN_PERCENTS.filter(percent => previous < percent && current >= percent)
58}
59
60// 경고 토스트 문구 — 기준이 자동 압축 임계치인지 모델 윈도우인지 밝힌다
61export const formatWarning = (percent: number, tokens: number, limit: number, isCompactBasis: boolean): string =>
62 `컨텍스트 ${percent}% 도달 — ${isCompactBasis ? '자동 압축 기준' : '모델 윈도우'} ${formatTokens(limit)} 중 ${formatTokens(tokens)} 사용`
63
64// 상태 줄에 덧붙이는 항목의 켜짐/꺼짐 — userConfig 값에서 읽는다
65export type Display = { showDelta: boolean; showCost: boolean; showRateLimits: boolean }
66
67// 값이 없거나 boolean 이 아니면 매니페스트 기본값과 같은 값을 쓴다
68const flag = (value: unknown, fallback: boolean): boolean =>
69 typeof value === 'boolean' ? value : fallback
70
71export const readDisplay = (options: Readonly<Record<string, unknown>>): Display => ({
72 showDelta: flag(options.show_delta, true),
73 showCost: flag(options.show_cost, false),
74 showRateLimits: flag(options.show_rate_limits, false),
75})
76
77const GROWTH_WINDOW = 3
78
79// 최근 증가량 이력(양수만, 최근 3개) — 압축으로 줄어든 측정은 넣지 않는다
80export const recordGrowth = (history: readonly number[], previous: number | undefined, current: number): number[] => {
81 if (previous === undefined || current <= previous) {
82 return [...history]
83 }
84
85 return [...history, current - previous].slice(-GROWTH_WINDOW)
86}
87
88// 기준까지 남은 턴 추정 — 이력 평균 증가량으로 나눈다, 이력이 없으면 모름
89export const estimateTurns = (history: readonly number[], remaining: number): number | undefined => {
90 if (history.length === 0) {
91 return undefined
92 }
93
94 const average = history.reduce((sum, delta) => sum + delta, 0) / history.length
95
96 return Math.max(0, Math.floor(remaining / average))
97}
98
99const RATE_LIMIT_LABELS: Readonly<Record<string, string>> = { five_hour: '5h', seven_day: '7d' }
100
101const formatGrowth = (tokens: number | undefined, limit: number, growth: readonly number[]): string | undefined => {
102 const last = growth.at(-1)
103
104 if (last === undefined || tokens === undefined) {
105 return undefined
106 }
107
108 const turns = estimateTurns(growth, limit - tokens)
109
110 return turns === undefined ? `+${formatTokens(last)}` : `+${formatTokens(last)} · ≈${turns}턴`
111}
112
113const formatRateLimits = (rateLimits: StatusInput['rateLimits']): string | undefined => {
114 if (rateLimits.length === 0) {
115 return undefined
116 }
117
118 return rateLimits
119 .map(limit => `${RATE_LIMIT_LABELS[limit.kind] ?? limit.kind} ${limit.percentUsed}%`)
120 .join(' · ')
121}
122
123export type StatusInput = {
124 tokens: number | undefined
125 limit: number
126 growth: readonly number[]
127 cost?: { usd: number }
128 rateLimits: readonly { kind: string; percentUsed: number }[]
129 display: Display
130}
131
132// 상태 줄 전체: 미터 · 증가량·남은 턴 · 비용 · 플랜 한도 (꺼졌거나 값이 없으면 생략)
133export const composeStatus = (input: StatusInput): string => {
134 const { tokens, limit, growth, cost, rateLimits, display } = input
135 const parts = [
136 formatMeter(tokens, limit),
137 display.showDelta ? formatGrowth(tokens, limit, growth) : undefined,
138 display.showCost && cost !== undefined ? `$${cost.usd.toFixed(2)}` : undefined,
139 display.showRateLimits ? formatRateLimits(rateLimits) : undefined,
140 ]
141
142 return parts.filter(part => part !== undefined).join(' · ')
143}
144
145// 내역 패널의 한 줄 — % 는 사용 중인 행에만, 지연 로드 행은 합계 밖이라 표시만 한다
146export type DetailRow = { name: string; tokens: string; percent?: string }
147
148export type DetailCategory = { name: string; tokens: number; kind: 'used' | 'free' | 'buffer' | 'deferred' }
149
150const toDetailRow = (category: DetailCategory, rawMaxTokens: number): DetailRow => {
151 const tokens = formatTokens(category.tokens)
152
153 if (category.kind === 'used') {
154 return { name: category.name, tokens, percent: `${percentOf(category.tokens, rawMaxTokens)}%` }
155 }
156
157 if (category.kind === 'deferred') {
158 return { name: `${category.name} · 지연 로드`, tokens }
159 }
160
161 return { name: category.name, tokens }
162}
163
164// 엔진 순서를 지키되 지연 로드 행(합계 밖)은 맨 뒤로 보낸다
165export const detailRows = (categories: readonly DetailCategory[], rawMaxTokens: number): DetailRow[] => {
166 const inWindow = categories.filter(category => category.kind !== 'deferred')
167 const deferred = categories.filter(category => category.kind === 'deferred')
168
169 return [...inWindow, ...deferred].map(category => toDetailRow(category, rawMaxTokens))
170}
171
172// 내역의 분모(rawMaxTokens)는 모델 윈도우이거나 그보다 작은 압축 창이다 — 자동 압축 임계치와 다른 값이라 따로 적는다
173export type WindowLabel = '모델 윈도우' | '압축 창'
174
175export const detailHeader = (
176 totalTokens: number,
177 rawMaxTokens: number,
178 windowLabel: WindowLabel,
179 autoCompactThreshold: number | undefined,
180): string => {
181 const threshold = autoCompactThreshold === undefined ? '꺼짐' : formatTokens(autoCompactThreshold)
182
183 return `사용 ${formatTokens(totalTokens)} / ${windowLabel} ${formatTokens(rawMaxTokens)} (${percentOf(totalTokens, rawMaxTokens)}%) · 자동 압축 ${threshold}`
184}
185
186// N% 자동 압축 설정 — 기본은 꺼짐, N 은 90
187export type AutoCompact = { isEnabled: boolean; percent: number }
188
189const DEFAULT_AUTO_COMPACT_PERCENT = 90
190
191// 매니페스트(plugin.json)의 min·max 와 같은 범위
192const MIN_AUTO_COMPACT_PERCENT = 50
193const MAX_AUTO_COMPACT_PERCENT = 99
194
195// 유한한 숫자가 아니면 기본값, 범위 밖이면 가까운 경계로 맞춘다
196const readPercent = (value: unknown): number =>
197 typeof value === 'number' && Number.isFinite(value)
198 ? Math.min(MAX_AUTO_COMPACT_PERCENT, Math.max(MIN_AUTO_COMPACT_PERCENT, value))
199 : DEFAULT_AUTO_COMPACT_PERCENT
200
201export const readAutoCompact = (options: Readonly<Record<string, unknown>>): AutoCompact => ({
202 isEnabled: flag(options.auto_compact, false),
203 percent: readPercent(options.auto_compact_percent),
204})
205
206// 이번 측정에서 자동 압축을 시도할지 — 같은 구간(N 이상)에서는 한 번만
207export const shouldAutoCompact = (percent: number, setting: AutoCompact, isAttempted: boolean): boolean =>
208 setting.isEnabled && percent >= setting.percent && !isAttempted
209