입력창 위에 모델·effort·fast, 남은 컨텍스트와 사용 한도, 직전 대화와 세션 통계를 한 줄로 보여주고, 마우스로 자세히 보거나 모델·effort·fast를 바로 바꿉니다

Claude Code 터미널의 입력창 위에 한 줄짜리 정보를 더해 주는 모드 두 개입니다.
| 모드 | 하는 일 | 지원 환경 |
|---|---|---|
| total-stats | 모델·effort·fast, 남은 컨텍스트와 사용 한도, 직전 대화의 토큰과 걸린 시간, 세션 전체 턴·토큰·스킬 횟수를 한 줄로 보여주고, 마우스로 자세히 보거나 바로 바꿀 수 있습니다 | 모든 OS |
| image-peek | 입력창에 붙여 넣은 이미지([Image #1])를 보내기 전에 목록으로 보여주고, 누르면 원본을 사진 뷰어로 엽니다 | Windows |
📎 [ Image 1 ] [ Image 2 ]
🧠 Opus 5.5 · medium · ⚡fast · ctx ███████░░░ 68% · 남은 5h 77% 주 60% · 직전 4.0k (1m23s) · 세션 12턴 244.0k · 스킬 3
> 이거 봐줘 [Image #1] [Image #2]
Claude Code 터미널의 입력창에 입력합니다. 필요한 것만 설치해도 됩니다.
/plugin install total-stats --marketplace glglekdy/claude-mods
/plugin install image-peek --marketplace glglekdy/claude-mods
처음 설치할 때 마켓플레이스를 추가할지 물으면 y, 설치 범위는 user(모든 세션)를 고르면 됩니다. 설치한 세션에서 바로 켜집니다.
새 버전이 나오면 터미널에서 마켓플레이스 목록을 새로 받은 뒤 플러그인을 업데이트합니다. Claude Code를 다시 켜거나 /reload-plugins를 입력하면 적용됩니다.
claude plugin marketplace update glglekdy-mods
claude plugin update total-stats@glglekdy-mods
claude plugin update image-peek@glglekdy-mods
Claude Code 안에서 /plugin 메뉴로 업데이트해도 됩니다.
끄기와 지우기:
claude plugin disable total-stats
claude plugin uninstall total-stats
| 항목 | 내용 | 색 | 누르면 |
|---|---|---|---|
| 🧠 모델 | 지금 대화에 쓰이는 모델 | 마우스를 올리면 Claude 색 | 모델·effort·fast 선택 창 |
| effort | 실제 요청에 적용된 effort | 마우스를 올리면 강조색 | 모델·effort·fast 선택 창 |
| fast | fast 모드 상태 | 켜짐은 노랑, 꺼짐은 흐리게, 켜지지 않는 환경이면 빨강 ⚡fast 불가 | |
| ctx | 남은 컨텍스트, 10칸 게이지 | 50% 넘게 남으면 초록, 20~50% 노랑, 20% 밑 빨강 | ctx 글자: /context 실행 |
| 남은 | 5시간·주간 한도가 남은 비율(구독 요금제일 때만) | 같은 기준 | 남은 글자: /usage 실행 |
| 직전 | 바로 전 대화 한 번(턴)에 쓴 토큰과 (걸린 시간) | 시간은 강조색 | |
| 세션 | 이번 세션의 턴 수와 전체 토큰(서브에이전트 포함) | ||
| 스킬 | 이번 세션에서 스킬을 부른 횟수(/이름, Skill 도구, 서브에이전트 미리 불러오기 포함) |
컨텍스트가 20% 이하로 남으면 ctx 옆에 빨간 경고와 버튼이 생깁니다. 버튼을 누르면 /compact가 실행됩니다.
ctx ██░░░░░░░░ 15% ⚠ 곧 가득 참 [ /compact ]
줄 바로 위에 카드가 펼쳐지고, 마우스를 치우면 사라집니다.
╭──────────────────────────────────────────╮ ╭──────────────────────────────────────────────╮
│ 컨텍스트 64.0k / 200.0k │ │ 5시간 15% 남음 (85% 사용) · 리셋까지 2h13m │
│ Messages ███░░░░░░░ 50.0k 25% │ │ 주간 60% 남음 (40% 사용) · 리셋까지 3d4h │
│ MCP tools █░░░░░░░░░ 10.0k 5% │ │ 이번 세션 API 환산 비용 $0.31 │
│ System prompt █░░░░░░░░░ 4.0k 2% │ ╰──────────────────────────────────────────────╯
╰──────────────────────────────────────────╯ 남은 위에 올렸을 때
ctx 위에 올렸을 때
╭──────────────────────────────────────────────────────────╮
│ 모델 [ Opus ] [ Sonnet ] [ Haiku ] [ Fable ] [ 더 보기 ] │
│ effort [ low ] [ medium ] [ high ] [ xhigh ] [ max ] │
│ fast [ 켜짐 → 끄기 ] 크레딧(추가 사용량)을 씁니다 │
╰──────────────────────────────────────────────────────────╯
모델·effort·fast 위에 올렸을 때
/context처럼 컨텍스트를 무엇이 얼마나 차지하는지 많은 순서대로 최대 8개 보여줍니다. 대화가 한 번 끝날 때마다 새로 계산하고, 로컬에서 어림하므로 API를 따로 부르지 않습니다./model, /effort, /fast를 직접 입력한 것처럼 실행합니다. 지금 쓰는 값은 강조색으로 보입니다. 모델이나 effort를 클릭하면 같은 내용의 선택 창이 열리고, 그 창에서는 숫자 키(모델 1~4, effort 5~9, fast 0)와 Esc도 쓸 수 있습니다./fast를 직접 쳤든 빨간색으로 표시하고 이유를 메뉴에 적습니다./model, /effort, /fast를 직접 쳐도 늦어도 2초 안에 반영됩니다.[Image #1]이 생기면 바로 위에 [ Image 1 ] 버튼이 생깁니다.[Image #1]을 지우면 목록에서도 빠지고, 메시지를 보내면 목록이 비워집니다.원본 파일은 이렇게 찾습니다.
%TEMP%\orca-paste-….png로 저장합니다.[Image #N]만 들어오면 %TEMP%에서 15초 안에 새로 생긴 이미지 파일을 씁니다.Windows의 Orca 터미널에서 확인했습니다. 다른 터미널은 이미지를 붙여 넣는 방식이 달라 원본을 못 찾을 수 있고, macOS와 Linux에서는 이미지가 열리지 않습니다.
git clone https://github.com/glglekdy/claude-mods
claude --plugin-dir ./claude-mods/total-stats
claude plugin validate ./claude-mods/total-stats
claude plugin test ./claude-mods/total-statshooks/register.tsx 547 lines1import { atom, read, update } from 'claude-code'
2import type { Color, EngineInterface, ModelUsage, Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
3
4import type { ContextPart, Snapshot } from '../types'
5
6type Tokens = {
7 id: string
8 startedAt: number
9 last: number
10 total: number
11 usd: number
12 // 아래 셋은 나중에 더해서, 예전에 저장된 값에는 없을 수 있습니다.
13 lastMs?: number
14 turns?: number
15 skills?: number
16}
17
18type Live = {
19 model?: string
20 effort?: string
21 isFast?: boolean
22 // fast를 켜려다 실패한 이유. 있으면 줄에 빨갛게 표시합니다.
23 fastBlocked?: string
24 context?: SessionContextUsage
25 rateLimits: SessionRateLimit[]
26 contextParts: ContextPart[]
27 contextMax?: number
28}
29
30// 세션마다 자기 키에만 쓰므로 여러 창을 동시에 열어도 서로 덮어쓰지 않습니다.
31const PREFIX = 'session:'
32const KEEP = 20
33const GAUGE = 10
34// 마우스를 올리면 카드가 뜨는 묶음 이름
35const CTX = 'total-stats-ctx'
36const LIMIT = 'total-stats-limit'
37const CARD_PARTS = 8
38// 컨텍스트가 이만큼(%) 이하로 남으면 옆에 /compact 경고와 버튼을 띄웁니다.
39const COMPACT_AT = 20
40// 모델이나 effort를 누르면 여는 선택 창
41const PICKER = 'total-stats-picker'
42const PICK = 'total-stats-pick'
43const MODELS = [
44 { alias: 'opus', label: 'Opus' },
45 { alias: 'sonnet', label: 'Sonnet' },
46 { alias: 'haiku', label: 'Haiku' },
47 { alias: 'fable', label: 'Fable' },
48]
49const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
50const THEME_KEYS = new Set([
51 'text', 'inactive', 'subtle', 'suggestion', 'remember', 'success', 'error', 'warning', 'merged', 'claude',
52 'permission', 'planMode', 'autoAccept', 'promptBorder', 'bashBorder', 'ide',
53])
54
55const snapshot = atom({ plugin: 'total-stats', key: 'snapshot' } as const, null)
56
57const compact = (n: number) =>
58 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : `${n}`
59
60const turnTokens = (u: ModelUsage) =>
61 u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
62
63// 83000 → 1m23s, 하루가 넘으면 2d5h
64const duration = (ms: number) => {
65 const sec = Math.round(ms / 1000)
66 if (sec < 60) return `${sec}s`
67 const min = Math.floor(sec / 60)
68 if (min < 60) return `${min}m${String(sec % 60).padStart(2, '0')}s`
69 const hour = Math.floor(min / 60)
70 if (hour < 24) return `${hour}h${String(min % 60).padStart(2, '0')}m`
71 return `${Math.floor(hour / 24)}d${hour % 24}h`
72}
73
74// claude-opus-5-5 → Opus 5.5
75const modelName = (id: string) => {
76 const [, family = '', major, minor] = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(id) ?? []
77 return family ? `${family.charAt(0).toUpperCase()}${family.slice(1)} ${major}.${minor}` : id
78}
79
80// 남은 양이 많을수록 초록, 적을수록 빨강
81const levelColor = (left: number): Color =>
82 left > 50 ? 'success' : left > 20 ? 'warning' : 'error'
83
84// 모듈 변수는 리로드 때마다 새로 시작하므로, 토큰은 $.store에 둡니다.
85let loading: Promise<Tokens> | undefined
86let live: Live = { rateLimits: [], contextParts: [] }
87// 설정 파일의 effort가 바뀌었는지 알아보려고 지난번 값을 기억합니다.
88let settingsEffort: string | undefined
89// fast도 같습니다. /fast 결과가 설정 파일보다 정확할 때가 있어서, 설정이 바뀔 때만 따릅니다.
90let settingsFast: boolean | undefined
91
92async function readSettings($: EngineInterface, model: string) {
93 const settings = await $.settings.read()
94 const perModel = (settings.modelSettings as Record<string, { effortLevel?: string }> | undefined)?.[model]
95 return {
96 effort: perModel?.effortLevel ?? (settings.effortLevel as string | undefined),
97 isFast: settings.fastMode === true,
98 }
99}
100
101// 모델·effort·fast를 지금 값으로 맞추고, 바뀐 게 있으면 다시 그립니다.
102async function refresh($: EngineInterface) {
103 const model = await $.session.model()
104 const { effort, isFast } = await readSettings($, model)
105 const isModelChanged = model !== live.model
106 const isEffortChanged = effort !== settingsEffort
107 settingsEffort = effort
108 const nextFast = isFast !== settingsFast ? isFast : live.isFast
109 settingsFast = isFast
110 // effort는 요청마다 실제 값(turn.step)도 들어오므로, 모델이나 설정이 바뀌었을 때만 덮어씁니다.
111 const nextEffort = isModelChanged || isEffortChanged ? effort : live.effort
112 if (!isModelChanged && nextEffort === live.effort && nextFast === live.isFast) return
113 live.model = model
114 live.effort = nextEffort
115 live.isFast = nextFast
116 await publish($)
117}
118
119async function readTokens($: EngineInterface): Promise<Tokens> {
120 const id = await $.session.id()
121 const usage = await $.session.usage()
122 live.context ??= usage.context
123 if (live.rateLimits.length === 0) live.rateLimits = usage.rateLimits
124
125 live.model ??= await $.session.model()
126 const { effort, isFast } = await readSettings($, live.model)
127 settingsEffort ??= effort
128 settingsFast ??= isFast
129 live.effort ??= effort
130 live.isFast ??= isFast
131
132 const keys = (await $.store.keys()).filter(k => k.startsWith(PREFIX))
133 const all = (await Promise.all(keys.map(k => $.store.get(k)))) as Tokens[]
134 const others = all.filter(t => t && t.id !== id).sort((a, b) => b.startedAt - a.startedAt)
135 for (const old of others.slice(KEEP)) await $.store.delete(PREFIX + old.id)
136 return all.find(t => t?.id === id) ?? { id, startedAt: usage.startedAt, last: 0, total: 0, usd: 0 }
137}
138
139function load($: EngineInterface) {
140 loading ??= readTokens($)
141 return loading
142}
143
144// /context처럼 컨텍스트를 항목별로 나눕니다. summary는 API를 부르지 않고 로컬에서 어림합니다.
145async function measureContext($: EngineInterface) {
146 try {
147 const { context } = await $.session.usage({ breakdown: 'summary' })
148 const b = context.breakdown
149 if (!b) return
150 live.contextMax = b.rawMaxTokens
151 live.contextParts = b.categories
152 .filter(c => c.kind === 'used' && c.tokens > 0)
153 .sort((x, y) => y.tokens - x.tokens)
154 .map(c => ({ name: c.name, tokens: c.tokens, color: c.color }))
155 await publish($)
156 } catch {
157 // 나눠 보지 못해도 줄은 그대로 그립니다.
158 }
159}
160
161// 지금 값을 밴드가 읽는 상태에 씁니다. 쓰면 밴드가 다시 그려집니다.
162async function publish($: EngineInterface) {
163 const tokens = await load($)
164 const used = live.context?.percent
165 const fiveHour = live.rateLimits.find(r => r.kind === 'five_hour')
166 const sevenDay = live.rateLimits.find(r => r.kind === 'seven_day')
167 const next: Snapshot = {
168 model: live.model,
169 effort: live.effort,
170 isFast: live.isFast === true,
171 fastBlocked: live.fastBlocked,
172 contextLeft: used === undefined ? undefined : Math.max(0, 100 - used),
173 contextParts: live.contextParts,
174 contextMax: live.contextMax,
175 fiveHourUsed: fiveHour?.percentUsed,
176 fiveHourResetsAt: fiveHour?.resetsAt,
177 sevenDayUsed: sevenDay?.percentUsed,
178 sevenDayResetsAt: sevenDay?.resetsAt,
179 last: tokens.last,
180 total: tokens.total,
181 usd: tokens.usd,
182 lastMs: tokens.lastMs,
183 turns: tokens.turns ?? 0,
184 skills: tokens.skills ?? 0,
185 }
186 await update($, snapshot, () => next)
187}
188
189// /model, /effort를 직접 입력한 것처럼 실행하고, 결과를 바로 줄에 반영합니다.
190async function choose($: EngineInterface, command: 'model' | 'effort', args: string) {
191 const ran = await $.command.run({ command, args })
192 await refresh($)
193 const text = ran?.text?.trim()
194 if (text) await $.ui.toast(text.length > 80 ? `${text.slice(0, 79)}…` : text)
195}
196
197// fast는 크레딧(추가 사용량)을 쓰므로 못 쓰는 환경이 있습니다. 미리 알 방법이 없어서
198// /fast를 실행해 보고, 켜려 했는데 그대로 꺼져 있으면 못 쓰는 환경으로 기억합니다.
199async function toggleFast($: EngineInterface) {
200 const want = live.isFast !== true
201 const ran = await $.command.run({ command: 'fast' })
202 const text = ran?.text?.trim()
203 await afterFast($, want, text)
204 if (text) await $.ui.toast(text.length > 80 ? `${text.slice(0, 79)}…` : text)
205}
206
207// /fast를 버튼으로 눌렀든 직접 입력했든, 결과 문구("Fast mode ON/OFF")와 설정으로 켜졌는지 봅니다.
208async function afterFast($: EngineInterface, want: boolean, text?: string) {
209 const { isFast: inSettings } = await readSettings($, live.model ?? await $.session.model())
210 const isFast = /\bON\b/.test(text ?? '') ? true : /\bOFF\b/.test(text ?? '') ? false : inSettings
211 live.isFast = isFast
212 if (want && !isFast) {
213 // "Fast mode OFF"처럼 이유가 없는 문구면 짐작되는 이유를 대신 적습니다.
214 const isBare = !text || /^fast mode (on|off)\.?$/i.test(text)
215 live.fastBlocked = isBare ? '켜지지 않았어요: 크레딧(추가 사용량)이 없거나 이 요금제에서 못 써요' : text
216 } else if (isFast) {
217 live.fastBlocked = undefined
218 }
219 await publish($)
220}
221
222// ctx·남은 용량을 누르면 /context·/usage를 직접 친 것처럼 실행합니다. 결과는 대화창에 나옵니다.
223async function runCommand($: EngineInterface, command: 'context' | 'usage' | 'compact') {
224 try {
225 await $.command.run({ command })
226 } catch (error) {
227 await $.ui.toast(`/${command}를 실행하지 못했어요: ${error instanceof Error ? error.message : String(error)}`)
228 }
229}
230
231type Elements = ReturnType<EngineInterface['ui']['resolve']>
232
233// 모델 줄과 effort 줄. 입력창 위 메뉴와 선택 창이 같이 씁니다.
234// 입력창 위에서는 숫자 키를 달지 않습니다: 빈 입력창에서 숫자를 치면 그 버튼이 눌리기 때문입니다.
235function choiceRows($: EngineInterface, { Box, Text, Button }: Elements, s: Snapshot | null, where: 'band' | 'pane') {
236 const family = s?.model ? modelName(s.model).split(' ')[0].toLowerCase() : undefined
237 const inPane = where === 'pane'
238 return [
239 <Box key={`${where}-models`} flexDirection="row" gap={1}>
240 <Text dimColor>모델 </Text>
241 {MODELS.map((m, i) => (
242 <Button
243 key={`${where}-model-${m.alias}`} hotkey={inPane ? String(i + 1) : undefined}
244 variant={m.alias === family ? 'primary' : undefined}
245 autoFocus={(inPane && m.alias === family) || undefined}
246 onPress={() => choose($, 'model', m.alias)}
247 >
248 {m.label}
249 </Button>
250 ))}
251 <Button key={`${where}-model-more`} dimColor onPress={() => $.command.run({ command: 'model' })}>더 보기</Button>
252 </Box>,
253 <Box key={`${where}-efforts`} flexDirection="row" gap={1}>
254 <Text dimColor>effort</Text>
255 {EFFORTS.map((level, i) => (
256 <Button
257 key={`${where}-effort-${level}`} hotkey={inPane ? String(i + 5) : undefined}
258 variant={level === s?.effort ? 'primary' : undefined}
259 onPress={() => choose($, 'effort', level)}
260 >
261 {level}
262 </Button>
263 ))}
264 </Box>,
265 <Box key={`${where}-fast`} flexDirection="row" gap={1}>
266 <Text dimColor>fast </Text>
267 <Button
268 key={`${where}-fast-toggle`} hotkey={inPane ? '0' : undefined}
269 variant={s?.isFast ? 'primary' : undefined}
270 onPress={() => toggleFast($)}
271 >
272 {s?.isFast ? '켜짐 → 끄기' : '꺼짐 → 켜기'}
273 </Button>
274 {s?.fastBlocked
275 ? <Text color="error" wrap="truncate-end">{s.fastBlocked}</Text>
276 : <Text dimColor>크레딧(추가 사용량)을 씁니다</Text>}
277 </Box>,
278 ]
279}
280
281async function save($: EngineInterface, change: (t: Tokens) => void) {
282 const tokens = await load($)
283 change(tokens)
284 await $.store.set(PREFIX + tokens.id, tokens)
285}
286
287export const register: Register = on => {
288 loading = undefined
289 live = { rateLimits: [], contextParts: [] }
290 settingsEffort = undefined
291 settingsFast = undefined
292
293 on('session.start', async ($, e, next) => {
294 const ran = await next(e)
295 await publish($)
296 await measureContext($)
297 // /model, /effort, /fast를 이벤트로 못 받는 경우에도 2초 안에 반영되게 확인합니다.
298 $.clock.every(2000, () => refresh($))
299 return ran
300 })
301
302 on('classic.PostModelSwitch', async ($, e, next) => {
303 const ran = await next(e)
304 await refresh($)
305 return ran
306 }).catch(($, e, next) => next(e))
307
308 on('classic.ConfigChange', async ($, e, next) => {
309 const ran = await next(e)
310 await refresh($)
311 return ran
312 }).catch(($, e, next) => next(e))
313
314 // 메인 대화의 요청마다 실제로 쓰인 모델과 effort를 읽습니다.
315 on('turn.step', async function* ($, e, next) {
316 if (e.agentId === undefined && (e.model !== live.model || String(e.effort) !== live.effort)) {
317 live.model = e.model
318 live.effort = e.effort === undefined ? undefined : String(e.effort)
319 await publish($)
320 }
321 return yield* next(e)
322 })
323
324 on('turn.complete', async ($, e, next) => {
325 const ran = await next(e)
326 const usage = ran.usage ?? e.usage
327 if (usage) {
328 const n = turnTokens(usage)
329 // 직전 대화는 메인 대화만, 세션 전체는 서브에이전트까지 셉니다.
330 await save($, t => {
331 if (e.agentId === undefined) t.last = n
332 t.total += n
333 })
334 }
335 // 턴 수와 걸린 시간은 메인 대화만 세고, 컨텍스트 내역도 이때 새로 나눕니다.
336 if (e.agentId === undefined) {
337 await save($, t => {
338 t.turns = (t.turns ?? 0) + 1
339 t.lastMs = e.durationMs
340 })
341 await publish($)
342 await measureContext($)
343 } else if (usage) {
344 await publish($)
345 }
346 return ran
347 })
348
349 // 직접 입력한 /fast도 결과를 보고, 켜려 했는데 안 켜지면 빨갛게 표시합니다.
350 on('command.run', { command: 'fast' }, async ($, e, next) => {
351 const want = live.isFast !== true
352 const ran = await next(e)
353 await afterFast($, want, ran?.text?.trim())
354 return ran
355 })
356
357 // /이름으로 부르든 Skill 도구로 부르든 스킬이 펼쳐질 때마다 셉니다.
358 on('skill.prompt', async ($, e, next) => {
359 const ran = await next(e)
360 await save($, t => { t.skills = (t.skills ?? 0) + 1 })
361 await publish($)
362 return ran
363 })
364
365 on('session.measure', async ($, e, next) => {
366 const ran = await next(e)
367 live.context = e.context
368 live.rateLimits = e.rateLimits
369 if (e.cost) {
370 const usd = e.cost.usd
371 await save($, t => { t.usd = usd })
372 }
373 await publish($)
374 return ran
375 })
376
377 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
378 const s = await read($, snapshot)
379 if (e.props.hasSurvey || s === null) return next(e)
380 // 아래 플러그인이 그린 줄도 함께 보여줍니다.
381 const below = await next(e)
382
383 const elements = $.ui.resolve(e)
384 const { Box, Text, Button } = elements
385 const openPicker = () => $.ui.open({ id: PICKER, title: '모델 · effort · fast', focus: true, closeOnEscape: true, holdToasts: true, rows: 7 })
386 const now = await $.clock.now()
387 const until = (iso?: string) => {
388 const at = iso === undefined ? NaN : Date.parse(iso)
389 return Number.isNaN(at) ? undefined : duration(Math.max(0, at - now))
390 }
391 const sep = <Text dimColor> · </Text>
392 const filled = s.contextLeft === undefined ? 0 : Math.round((s.contextLeft / 100) * GAUGE)
393
394 // 카드는 평소엔 숨어 있다가 마우스를 올리면 줄 바로 위에 펼쳐집니다.
395 // 줄 밖으로 띄우면(absolute) 입력창 위 영역 밖이라 잘려서 보이지 않으므로, 영역 안에 펼칩니다.
396 const card = (scope: string, width: number | undefined, lines: unknown[]) => (
397 <Box
398 width={width} alignSelf="flex-start" flexDirection="column" borderStyle="round" borderColor="suggestion" paddingX={1}
399 display="none" hover={{ scope, display: 'flex' }}
400 >
401 {lines}
402 </Box>
403 )
404
405 const max = s.contextMax ?? 0
406 const parts = s.contextParts.slice(0, CARD_PARTS)
407 const nameWidth = Math.max(8, ...parts.map(p => p.name.length))
408 const ctxCard = parts.length === 0 ? null : card(CTX, nameWidth + 30, [
409 <Text key="title" bold>
410 컨텍스트 {compact(s.contextParts.reduce((n, p) => n + p.tokens, 0))}{max > 0 ? ` / ${compact(max)}` : ''}
411 </Text>,
412 ...parts.map(p => {
413 const share = max > 0 ? p.tokens / max : 0
414 const color = (THEME_KEYS.has(p.color) ? p.color : 'suggestion') as Color
415 const bar = Math.min(GAUGE, Math.max(1, Math.round(share * GAUGE)))
416 return (
417 <Text key={p.name} wrap="truncate-end">
418 <Text>{p.name.padEnd(nameWidth)} </Text>
419 <Text color={color}>{'█'.repeat(bar)}</Text>
420 <Text dimColor>{'░'.repeat(GAUGE - bar)}</Text>
421 <Text bold> {compact(p.tokens).padStart(6)}</Text>
422 <Text dimColor> {Math.round(share * 100)}%</Text>
423 </Text>
424 )
425 }),
426 ])
427
428 const limitLine = (key: string, label: string, used: number, resetsAt?: string) => {
429 const left = Math.max(0, 100 - used)
430 const wait = until(resetsAt)
431 return (
432 <Text key={key} wrap="truncate-end">
433 <Text dimColor>{label} </Text>
434 <Text color={levelColor(left)} bold>{Math.round(left)}% 남음</Text>
435 <Text dimColor> ({Math.round(used)}% 사용)</Text>
436 {wait ? <Text> · 리셋까지 <Text color="suggestion" bold>{wait}</Text></Text> : null}
437 </Text>
438 )
439 }
440 const limitCard = s.fiveHourUsed === undefined ? null : card(LIMIT, 48, [
441 limitLine('five', '5시간', s.fiveHourUsed, s.fiveHourResetsAt),
442 s.sevenDayUsed === undefined
443 ? <Text key="week" dimColor>주간 한도 정보 없음</Text>
444 : limitLine('week', '주간 ', s.sevenDayUsed, s.sevenDayResetsAt),
445 // 구독 요금제에서는 실제로 내는 돈이 아니라 API 가격으로 셈한 값이라, 줄에서 빼고 여기에만 둡니다.
446 ...(s.usd > 0 ? [<Text key="usd" dimColor>이번 세션 API 환산 비용 ${s.usd.toFixed(2)}</Text>] : []),
447 ])
448
449 // 마우스를 알아채려면 ctx와 5h가 각자 맨 바깥 Text여야 해서, 줄을 조각 여러 개로 나눕니다.
450 // 창이 좁으면 줄을 바꾸지 않고 오른쪽 조각부터 잘립니다.
451 return (
452 <Box flexDirection="column">
453 {ctxCard}
454 {limitCard}
455 {s.model ? card(PICK, undefined, choiceRows($, elements, s, 'band')) : null}
456 <Box key="line" flexDirection="row" height={1} overflow="hidden">
457 {/* 모델과 effort에 마우스를 올리면 바로 위에 고를 수 있는 메뉴가 펼쳐지고, 누르면 선택 창이 열립니다. */}
458 {s.model ? (
459 <Button key="model" plain hover={{ scope: PICK, color: 'claude', bold: true }} onPress={openPicker}>
460 {`🧠 ${modelName(s.model)}`}
461 </Button>
462 ) : null}
463 {s.effort ? sep : null}
464 {s.effort ? (
465 <Button key="effort" plain hover={{ scope: PICK, color: 'suggestion', bold: true }} onPress={openPicker}>
466 {s.effort}
467 </Button>
468 ) : null}
469 {sep}
470 {/* fast에 마우스를 올려도 같은 메뉴가 펼쳐지고, 거기서 켜고 끌 수 있습니다. 못 쓰는 환경이면 빨간색입니다. */}
471 {s.fastBlocked
472 ? <Text color="error" bold hover={{ scope: PICK, underline: true }}>⚡fast 불가</Text>
473 : s.isFast
474 ? <Text color="warning" bold hover={{ scope: PICK, underline: true }}>⚡fast</Text>
475 : <Text dimColor hover={{ scope: PICK, underline: true }}>fast off</Text>}
476 {s.contextLeft !== undefined ? sep : null}
477 {s.contextLeft !== undefined ? (
478 // 버튼은 글자색을 못 바꾸므로 이름만 버튼으로 두고, 게이지는 색이 있는 글자로 둡니다.
479 <Button key="ctx" plain dimColor hover={{ scope: CTX, underline: true }} onPress={() => runCommand($, 'context')}>
480 ctx
481 </Button>
482 ) : null}
483 {s.contextLeft !== undefined ? (
484 <Text wrap="truncate-end" hover={{ scope: CTX, underline: true }}>
485 <Text> </Text>
486 <Text color={levelColor(s.contextLeft)}>{'█'.repeat(filled)}</Text>
487 <Text dimColor>{'░'.repeat(GAUGE - filled)}</Text>
488 <Text color={levelColor(s.contextLeft)} bold> {s.contextLeft}%</Text>
489 </Text>
490 ) : null}
491 {s.contextLeft !== undefined && s.contextLeft <= COMPACT_AT
492 ? <Text color="error" bold> ⚠ 곧 가득 참 </Text>
493 : null}
494 {s.contextLeft !== undefined && s.contextLeft <= COMPACT_AT ? (
495 <Button key="compact" variant="primary" onPress={() => runCommand($, 'compact')}>/compact</Button>
496 ) : null}
497 {s.fiveHourUsed !== undefined ? sep : null}
498 {s.fiveHourUsed !== undefined ? (
499 // 쓴 비율 대신 남은 용량으로 보여줍니다.
500 <Button key="usage" plain dimColor hover={{ scope: LIMIT, underline: true }} onPress={() => runCommand($, 'usage')}>
501 남은
502 </Button>
503 ) : null}
504 {s.fiveHourUsed !== undefined ? (
505 <Text wrap="truncate-end" hover={{ scope: LIMIT, underline: true }}>
506 <Text dimColor> 5h </Text>
507 <Text color={levelColor(100 - s.fiveHourUsed)} bold>{Math.round(100 - s.fiveHourUsed)}%</Text>
508 {s.sevenDayUsed !== undefined ? <Text dimColor> 주 </Text> : null}
509 {s.sevenDayUsed !== undefined
510 ? <Text color={levelColor(100 - s.sevenDayUsed)} bold>{Math.round(100 - s.sevenDayUsed)}%</Text>
511 : null}
512 </Text>
513 ) : null}
514 {sep}
515 <Text wrap="truncate-end">
516 <Text dimColor>직전 </Text>
517 <Text bold>{compact(s.last)}</Text>
518 {/* ⏱ 같은 이모지는 터미널마다 폭이 달라 눌려 보이므로 괄호로 씁니다. */}
519 {s.lastMs !== undefined ? <Text color="suggestion"> ({duration(s.lastMs)})</Text> : null}
520 {sep}
521 <Text dimColor>세션 </Text>
522 <Text bold>{s.turns}턴 </Text>
523 <Text bold>{compact(s.total)}</Text>
524 {sep}
525 <Text dimColor>스킬 </Text>
526 <Text bold>{s.skills}</Text>
527 </Text>
528 </Box>
529 {below}
530 </Box>
531 )
532 })
533
534 on('ui.render', { component: 'Pane', requestId: PICKER }, async ($, e) => {
535 const elements = $.ui.resolve(e)
536 const { Box, Text } = elements
537 const s = await read($, snapshot)
538
539 return (
540 <Box flexDirection="column">
541 {choiceRows($, elements, s, 'pane')}
542 <Text dimColor>클릭이나 숫자 키(모델 1~4, effort 5~9, fast 0)로 고르고, Esc로 닫습니다. 지금 쓰는 값은 강조색입니다.</Text>
543 </Box>
544 )
545 })
546}
547types/index.d.ts 33 lines1// /context의 항목 한 줄: 무엇이 컨텍스트를 얼마나 차지하는지
2export type ContextPart = {
3 name: string
4 tokens: number
5 color: string
6}
7
8export type Snapshot = {
9 model?: string
10 effort?: string
11 isFast: boolean
12 fastBlocked?: string
13 contextLeft?: number
14 contextParts: ContextPart[]
15 contextMax?: number
16 fiveHourUsed?: number
17 fiveHourResetsAt?: string
18 sevenDayUsed?: number
19 sevenDayResetsAt?: string
20 last: number
21 total: number
22 usd: number
23 lastMs?: number
24 turns: number
25 skills: number
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 'total-stats': { snapshot: Snapshot | null }
31 }
32}
33