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

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로 켜고 끔 (자세히) |
<모드이름>/ 폴더에 .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.tsx.claude-plugin/marketplace.json의 plugins에 한 줄 추가claude plugin validate <모드이름> 와 claude plugin test <모드이름> 통과 확인 후 pushhooks/register.tsx 171 lines1import { 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}
171types/index.d.ts 13 lines1export 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