SLOPSHOPPER

usage-meter

One line above the prompt: model, effort, context fill and the 5-hour limit

newbandcommand
v0.3.0no licenseupdated 2026-10-08KKK12142/claude-mods/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 모드(함수 훅 플러그인) 모음.

설치

Claude Code 터미널 세션에서 저장소를 한 번 추가한 뒤, 원하는 모드를 설치합니다.

/plugin marketplace add KKK12142/claude-mods
/plugin install usage-meter@claude-mods
/plugin install prism-tree@claude-mods

업데이트: claude plugin marketplace update claude-mods 후 /reload-plugins

모드

모드설명
usage-meter입력창 위 한 줄에 모델·effort·컨텍스트·5시간 한도를 막대로 표시. 화면 폭에 맞춰 줄어듦. /usage-meter on/off로 켜고 끔
prism-tree오른쪽 사이드바 파일 트리. git 상태·바뀐 줄 수를 보여 주고 Claude가 건드린 파일을 반짝임으로 표시. 테마 6종, 머리줄 ❖ 버튼으로 전환. /prism-tree로 열기, /prism-tree on/off로 켜고 끔 (자세히)

모드 추가하기

  1. <모드이름>/ 폴더에 .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.tsx
  2. .claude-plugin/marketplace.json의 plugins에 한 줄 추가
  3. claude plugin validate <모드이름> 와 claude plugin test <모드이름> 통과 확인 후 push
Source 2 files
hooks/register.tsx 171 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionUsage, SessionMeasureInput } from 'claude-code'
3
4import type { Meter } from '../types'
5
6const meter = atom({ plugin: 'usage-meter', key: 'meter' } as const, null)
7const isHidden = atom({ plugin: 'usage-meter', key: 'isHidden' } as const, false)
8const modelInfo = atom({ plugin: 'usage-meter', key: 'modelInfo' } as const, { model: null, effort: null })
9
10const toMeter = (u: SessionUsage | SessionMeasureInput): Meter => ({
11  context: { tokens: u.context.tokens, window: u.context.window, percent: u.context.percent },
12  rateLimits: u.rateLimits
13    .filter(r => r.kind === 'five_hour')
14    .map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
15})
16
17const bar = (percent: number, width: number) => {
18  const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
19  return '█'.repeat(filled) + '░'.repeat(width - filled)
20}
21
22// 터미널 칸 수: 한글 등 전각 문자는 2칸
23const cells = (text: string) =>
24  [...text].reduce((n, ch) => n + (/[ᄀ-ᅟ⺀-꓏가-힣豈-﫿＀-⦆]/.test(ch) ? 2 : 1), 0)
25
26type Seg = { text: string; color?: string; bold?: boolean; dim?: boolean }
27
28const SEP: Seg = { text: '│', dim: true }
29
30// 넓은 것부터: 화면 폭에 들어가는 첫 번째 배치를 쓴다
31const layouts = (model: string | null, effort: string | null, ctx: number, limit: Meter['rateLimits'][number] | undefined, now: number) => {
32  const head = (withEffort: boolean): Seg[] => [
33    ...(model !== null ? [{ text: model, color: 'claude', bold: true }] : []),
34    ...(withEffort && effort !== null ? [{ text: `· ${effort}`, dim: true }] : []),
35  ]
36  const pct = (p: number): Seg => ({ text: `${Math.round(p)}%`, color: colorFor(p), bold: true })
37  const part = (label: string, p: number, barWidth: number): Seg[] => [
38    { text: label },
39    ...(barWidth > 0 ? [{ text: bar(p, barWidth), color: colorFor(p) }] : []),
40    pct(p),
41  ]
42  const build = (ctxLabel: string, limLabel: string, barWidth: number, withEffort: boolean, withReset: boolean): Seg[] => [
43    ...head(withEffort),
44    SEP,
45    ...part(ctxLabel, ctx, barWidth),
46    ...(limit === undefined
47      ? []
48      : [
49          SEP,
50          ...part(limLabel, limit.percentUsed, barWidth),
51          ...(withReset && limit.resetsAt ? [{ text: until(limit.resetsAt, now), dim: true }] : []),
52        ]),
53  ]
54
55  return [
56    build('컨텍스트', '5시간', 8, true, true),
57    build('컨텍스트', '5시간', 5, true, true),
58    build('ctx', '5h', 5, true, true),
59    build('ctx', '5h', 0, true, true),
60    build('ctx', '5h', 0, false, false),
61  ]
62}
63
64const widthOf = (segs: Seg[]) => segs.reduce((n, seg) => n + cells(seg.text), 0) + Math.max(0, segs.length - 1)
65
66const colorFor = (percent: number) => (percent >= 90 ? 'error' : percent >= 70 ? 'warning' : 'success')
67
68const until = (iso: string | undefined, now: number) => {
69  if (!iso) return ''
70  const ms = Date.parse(iso) - now
71  if (!(ms > 0)) return '↻곧'
72  const minutes = Math.round(ms / 60_000)
73  const hours = Math.floor(minutes / 60)
74  const mins = minutes % 60
75  return hours > 0 ? `↻${hours}h${mins}m` : `↻${mins}m`
76}
77
78// "claude-opus-5-5[1m]" → "Opus 5.5 (1M)"
79const prettyModel = (raw: string) => {
80  const isLong = /\[1m\]/i.test(raw)
81  const name = raw.replace(/\[1m\]/i, '').trim()
82  const m = /^claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d+))?/i.exec(name)
83  const base = m
84    ? `${m[1]![0]!.toUpperCase()}${m[1]!.slice(1)} ${m[2]}${m[3] ? `.${m[3]}` : ''}`
85    : name.charAt(0).toUpperCase() + name.slice(1)
86  return isLong ? `${base} (1M)` : base
87}
88
89const readEffort = async ($: EngineInterface) => {
90  try {
91    const row = (await $.config.list()).find(r => /effort/i.test(r.key))
92    return row && typeof row.value === 'string' ? row.value : null
93  } catch {
94    return null
95  }
96}
97
98const refresh = async ($: EngineInterface) => {
99  const usage = await $.session.usage()
100  await update($, meter, () => toMeter(usage))
101  const model = await $.session.model()
102  await update($, modelInfo, info => ({ ...info, model }))
103}
104
105export const register: Register = on => {
106  on('session.start', async ($, e, next) => {
107    await $.command.register({
108      name: 'usage-meter',
109      description: '모델·effort·컨텍스트·5시간 한도 미터를 켜고 끕니다 (on / off)',
110    })
111    await refresh($)
112    const effort = await readEffort($)
113    await update($, modelInfo, info => ({ ...info, effort }))
114
115    return next(e)
116  })
117
118  on('session.measure', async ($, e, next) => {
119    await update($, meter, () => toMeter(e))
120
121    return next(e)
122  })
123
124  // 실제로 요청에 실린 모델과 effort가 가장 정확하다 (메인 루프만)
125  on('turn.step', async function* ($, e, next) {
126    if (e.agentId === undefined) {
127      const effort = e.effort === undefined ? null : String(e.effort)
128      await update($, modelInfo, () => ({ model: e.model, effort }))
129    }
130
131    return yield* next(e)
132  })
133
134  on('command.run', { command: 'usage-meter' }, async ($, e) => {
135    const arg = e.args.trim().toLowerCase()
136    const hide = arg === 'off' ? true : arg === 'on' ? false : !(await read($, isHidden))
137    await update($, isHidden, () => hide)
138    if (!hide) await refresh($)
139
140    return { text: hide ? 'usage-meter 숨김' : 'usage-meter 표시' }
141  })
142
143  // 입력창 바로 위 한 줄
144  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
145    const m = await read($, meter)
146    if (e.props.hasSurvey || m === null || (await read($, isHidden))) {
147      return next(e)
148    }
149
150    const { Box, Text } = $.ui.resolve(e)
151    const now = await $.clock.now()
152    const info = await read($, modelInfo)
153
154    const model = info.model === null ? null : prettyModel(info.model)
155    const options = layouts(model, info.effort, m.context.percent ?? 0, m.rateLimits[0], now)
156    // 좌우 여백 2칸 + 엔진이 옆에 그리는 접기 버튼 [-] 자리 4칸 + 여유 1칸
157    const room = (e.viewport?.columns ?? Infinity) - 7
158    const segs = options.find(option => widthOf(option) <= room) ?? options[options.length - 1]!
159
160    return (
161      <Box flexDirection="row" flexWrap="nowrap" gap={1} paddingX={1} overflow="hidden">
162        {segs.map(seg => (
163          <Text color={seg.color} bold={seg.bold} dimColor={seg.dim} wrap="truncate-end">
164            {seg.text}
165          </Text>
166        ))}
167      </Box>
168    )
169  })
170}
171
types/index.d.ts 13 lines
1export type Meter = {
2  context: { tokens?: number; window: number; percent?: number }
3  rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
4}
5
6export type ModelInfo = { model: string | null; effort: string | null }
7
8declare module 'claude-code' {
9  interface PluginState {
10    'usage-meter': { meter: Meter | null; isHidden: boolean; modelInfo: ModelInfo }
11  }
12}
13