SLOPSHOPPER

context-meter

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

newpanecommandtoaststatustimer
v0.2.1no licenseupdated 2026-10-09jongcheol-pak/Claude-Code-Mod/context-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-meter
│ ┃ context-meter-detail ✕ › 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 │ │ › /context-meter │ ⎿ context-meter: 컨텍스트 사용량 표시: 꺼짐 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · context-meter-detail
내역을 읽을 수 없습니다
README

Claude Code Mod

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와 위 모드 목록에 한 줄씩 추가한다.

Source 3 files
hooks/register.ts 258 lines
1import 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}
258
hooks/detail-pane.tsx 27 lines
1// /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}
27
hooks/meter.ts 209 lines
1// 엔진에 의존하지 않는 계산·표기 — 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