입력창 위 한 줄: 컨텍스트 사용량, 사용 한도, 메인·서브에이전트 모델과 effort. 서브에이전트 이름을 누르면 작업 내용 창이 열립니다

Claude Code mod 모음입니다. mod는 함수 훅으로 만든 플러그인입니다.
| mod | 하는 일 | 명령 |
|---|---|---|
token-usage | 입력창 위 한 줄에 컨텍스트(대화 기억) 사용량, 5시간·7일 사용 한도, 메인 모델과 effort를 보여줍니다. 실행 중인 서브에이전트는 ①②③ 번호와 색으로 구분해 한 줄씩 나오고, 각 줄에는 모델, 걸린 시간(⏱), ctx, 지금 보는 파일이 붙습니다. 이름을 누르면 그 서브에이전트의 작업 내용이 창으로 열립니다(👁 표시, Esc로 닫기). | /token-usage 펼치기/접기, /token-usage theme |
work-alerts | 오래 걸린 작업이 끝났을 때, 테스트·검사가 실패했을 때, 한도가 찼을 때 알림 창과 소리로 알려줍니다. | /task-alert 소리 켜기/끄기, /alerts 현재 설정, /alerts test |
compact-handoff | 할 일이 있을 때 진행 상황(▶ 2/5 …)을 보여줍니다. 대화가 압축돼도 작업을 이어가도록 메모도 남깁니다. | /work, /handoff |
/config세 mod의 설정은 모두 Claude Code의 /config 메뉴에 항목으로 나옵니다. 메뉴에서 바꾸면 그 자리에서 바로 적용됩니다.
/task-alert, /work band off 같은 명령도 같은 항목을 바꿉니다. 그래서 메뉴와 명령의 설정이 어긋나지 않습니다.
Claude Code 안에서 하나씩 설치합니다.
/plugin install token-usage --marketplace parkyountaek/claude-mods
/plugin install work-alerts --marketplace parkyountaek/claude-mods
/plugin install compact-handoff --marketplace parkyountaek/claude-mods
폴더를 직접 받아 쓰고 고치고 싶다면 다음처럼 합니다.
git clone https://github.com/parkyountaek/claude-mods ~/.claude/mods
그다음 ~/.claude/settings.json의 env에 경로를 등록합니다.
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/token-usage:~/.claude/mods/work-alerts:~/.claude/mods/compact-handoff" } }
sudo sh ~/.claude/mods/install-root.sh
theme.json 하나를 함께 씁니다.claude plugin validate <mod>
claude plugin test <mod>hooks/register.tsx 925 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, SessionMessage, SessionRateLimit, TurnUsage } from 'claude-code'
3
4import type { AgentRow, Context, Limit, Tokens, Usage } from '../types'
5
6import type { Slot } from './theme'
7import { applyThemeArgs, palette, parseTheme, PRESETS, SLOTS, themePath } from './theme'
8import type { Palette, ThemeFile } from './theme'
9
10const KEEP_TURNS = 200
11const KEEP_AGENTS = 40
12
13const EMPTY: Usage = {
14 main: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0 },
15 model: null,
16 turns: [],
17 agents: [],
18 context: null,
19 isBandHidden: false,
20}
21
22const usage = atom({ plugin: 'token-usage', key: 'usageV3' } as const, EMPTY)
23
24async function loadTheme($: EngineInterface): Promise<ThemeFile> {
25 const path = themePath(await $.env.get('HOME'), await $.env.get('CLAUDE_MODS_THEME_FILE'))
26 return parseTheme(await $.fs.read(path).catch(() => ''))
27}
28
29const THEME_HELP: Record<string, string> = {
30 default: '터미널 테마 색을 그대로 사용',
31 colorblind: '색각 이상에도 구분되는 색 (Okabe-Ito)',
32 ocean: '청록·파랑 계열, 어두운 배경에 맞춤',
33 vivid: '선명한 원색 계열',
34}
35
36async function themeCommand($: EngineInterface, words: string[]): Promise<{ text: string; theme?: ThemeFile }> {
37 if (words[0] === 'theme' && !words[1]) {
38 // No name: let the person pick with the keyboard (arrows + Enter).
39 const names = Object.keys(PRESETS)
40 const picked = await $.ui
41 .ask('어떤 색상 테마를 쓸까요?', {
42 header: '테마',
43 options: names.map(n => `${n} — ${THEME_HELP[n] ?? ''}`),
44 })
45 .catch(() => '')
46 const name = names.find(n => picked.startsWith(n))
47 if (!name) return { text: '테마를 바꾸지 않았습니다.' }
48 words = ['theme', name]
49 }
50 const r = applyThemeArgs(await loadTheme($), words)
51 if (r.next) {
52 const path = themePath(await $.env.get('HOME'), await $.env.get('CLAUDE_MODS_THEME_FILE'))
53 const isSaved = await $.fs.write(path, `${JSON.stringify(r.next, null, 2)}\n`).then(() => true, () => false)
54 // Still applied to this session when the file cannot be written.
55 if (!isSaved) return { text: `${r.text}\n(테마 파일 ${path} 을 저장하지 못해 이번 세션에만 적용됩니다)`, theme: r.next }
56 }
57 return { text: r.text, theme: r.next }
58}
59
60// Before the first request of this load: the session's figures and the configured model.
61async function freshen($: EngineInterface) {
62 const [now, settings] = await Promise.all([
63 $.session.usage().catch(() => null),
64 $.settings.read().catch(() => ({}) as Record<string, unknown>),
65 ])
66 if (!now) return
67 await update($, usage, u => ({
68 ...u,
69 context: toContext(now.context),
70 limits: toLimits(now.rateLimits),
71 model: u.model ?? (typeof settings.model === 'string' ? settings.model : null),
72 effort: u.effort ?? (typeof settings.effortLevel === 'string' ? settings.effortLevel : undefined),
73 }))
74}
75
76const themeFrom = (u: Usage): Palette => palette((u.theme as ThemeFile | undefined) ?? { preset: 'default', overrides: {} })
77
78// ---------------------------------------------------------------- formatting
79
80export const fmt = (n: number): string => {
81 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`
82 if (n >= 10_000) return `${Math.round(n / 1_000)}k`
83 if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
84 return String(n)
85}
86
87// "claude-opus-5-5[1m]" -> "Opus 5.5 1M", "claude-haiku-4-5-20251001" -> "Haiku 4.5"
88export const modelName = (id: string): string => {
89 const wide = /\[1m\]/i.test(id) ? ' 1M' : ''
90 const m = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?/i.exec(id)
91 if (!m) return id.replace(/\[1m\]/i, '') + wide
92 const family = m[1] ?? ''
93 const name = family.charAt(0).toUpperCase() + family.slice(1)
94 return `${name} ${m[2]}${m[3] ? `.${m[3]}` : ''}${wide}`
95}
96
97const fit = (s: string, width: number) => {
98 const cells = [...s].reduce((n, ch) => n + (/[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\uff00-\uff60]/.test(ch) ? 2 : 1), 0)
99 return s + ' '.repeat(Math.max(0, width - cells))
100}
101
102const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
103
104const inputOf = (t: Tokens) => t.input + t.cacheRead + t.cacheWrite
105
106// ---------------------------------------------------------------- glyph charts
107
108const EIGHTHS = '▏▎▍▌▋▊▉'
109
110// A horizontal bar at eighth-cell resolution, padded with a faint track.
111export const fineBar = (percent: number, width: number): string => {
112 const eighths = Math.round((Math.max(0, Math.min(100, percent)) / 100) * width * 8)
113 const full = Math.floor(eighths / 8)
114 const part = eighths % 8
115 return ('█'.repeat(full) + (part ? EIGHTHS[part - 1] ?? '' : '')).padEnd(width, '·')
116}
117
118// Cell counts for a stacked bar; every non-zero segment keeps at least one cell.
119export const stack = (parts: readonly number[], width: number, peak: number): number[] =>
120 parts.map(p => (p > 0 ? Math.max(1, Math.round((p / Math.max(1, peak)) * width)) : 0))
121
122// ---------------------------------------------------------------- context state
123
124// Icon, label and theme color together, so the state is never color alone.
125export const contextState = (percent: number) =>
126 percent >= 85
127 ? { icon: '●', label: '곧 압축', tone: 'danger' as const }
128 : percent >= 60
129 ? { icon: '◕', label: '주의', tone: 'warn' as const }
130 : percent >= 30
131 ? { icon: '◑', label: '여유', tone: 'ok' as const }
132 : { icon: '◔', label: '여유', tone: 'ok' as const }
133
134const toLimits = (r: readonly SessionRateLimit[]): Limit[] =>
135 r.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt }))
136
137export const limitLabel = (kind: string): string =>
138 kind === 'five_hour' ? '5시간' : kind === 'seven_day' ? '7일' : kind === 'spend_limit' ? '지출 한도' : kind
139
140// Time until a window resets, compact: "3d 4h", "2h", "25m".
141export const resetsIn = (resetsAt: string | undefined, now: number): string => {
142 if (!resetsAt) return ''
143 const min = Math.round((Date.parse(resetsAt) - now) / 60_000)
144 if (Number.isNaN(min)) return ''
145 if (min < 1) return '↻곧'
146 if (min < 60) return `↻${min}m`
147 const h = Math.floor(min / 60)
148 if (h < 24) return `↻${h}h`
149 return h % 24 ? `↻${Math.floor(h / 24)}d ${h % 24}h` : `↻${Math.floor(h / 24)}d`
150}
151
152// A subagent's window: the main one when it runs the same model, else by its id.
153export const windowFor = (model: string, main: Usage): number =>
154 model && model === main.model && main.context ? main.context.window : /\[1m\]|-1m\b/i.test(model) ? 1_000_000 : 200_000
155
156// Live subagents grouped by model and effort: [["Haiku 4.5", 2, "low"], ...].
157export const agentModels = (agents: readonly AgentRow[]): [string, number, string | undefined][] => {
158 const groups = new Map<string, [string, number, string | undefined]>()
159 for (const a of agents) {
160 if (!isLive(a)) continue
161 const name = a.model ? modelName(a.model) : '?'
162 const key = `${name}|${a.effort ?? ''}`
163 const g = groups.get(key)
164 groups.set(key, g ? [name, g[1] + 1, a.effort] : [name, 1, a.effort])
165 }
166 return [...groups.values()]
167}
168
169type Tone = 'ok' | 'warn' | 'danger' | 'cache'
170
171// Each subagent keeps one number and color for the whole session (its order of first appearance),
172// so its row above the prompt and its row in /token-usage are easy to match.
173const CIRCLED = '①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳'
174const AGENT_TONES: readonly Slot[] = ['accent', 'warn', 'ok', 'input', 'output', 'cache']
175export const agentTag = (index: number): { mark: string; tone: Slot } => ({
176 mark: CIRCLED[index] ?? `(${index + 1})`,
177 tone: AGENT_TONES[index % AGENT_TONES.length] ?? 'accent',
178})
179
180const AGENT_ICON: Record<string, { icon: string; tone: Tone }> = {
181 pending: { icon: '○', tone: 'cache' },
182 running: { icon: '◐', tone: 'warn' },
183 waiting: { icon: '◐', tone: 'warn' },
184 idle: { icon: '◌', tone: 'cache' },
185 completed: { icon: '✓', tone: 'ok' },
186 failed: { icon: '✗', tone: 'danger' },
187 killed: { icon: '✗', tone: 'danger' },
188}
189
190const isLive = (a: AgentRow) => ['pending', 'running', 'waiting'].includes(a.status)
191// A teammate waiting for a message: still around, not working.
192const isIdle = (a: AgentRow) => a.status === 'idle'
193const u0Live = (u: Usage) => u.agents.some(isLive)
194
195export const STATUS_KO: Record<string, string> = {
196 pending: '대기',
197 running: '실행 중',
198 waiting: '실행 중',
199 idle: '메시지 기다리는 중',
200 completed: '끝남',
201 failed: '실패',
202 killed: '중단됨',
203}
204
205// 1분 12초, 45초, 1시간 3분
206export const elapsed = (ms: number): string => {
207 const sec = Math.max(0, Math.floor(ms / 1000))
208 if (sec < 60) return `${sec}초`
209 const min = Math.floor(sec / 60)
210 if (min < 60) return sec % 60 ? `${min}분 ${sec % 60}초` : `${min}분`
211 return min % 60 ? `${Math.floor(min / 60)}시간 ${min % 60}분` : `${Math.floor(min / 60)}시간`
212}
213
214export const agentElapsed = (a: AgentRow, now: number): string | undefined =>
215 a.startedAt === undefined ? undefined : elapsed((a.endedAt ?? now) - a.startedAt)
216
217// Children right under their parent, each with its depth, so a spawned subagent sits
218// indented below the one that spawned it.
219export const nested = (rows: readonly AgentRow[]): { a: AgentRow; depth: number }[] => {
220 const ids = new Set(rows.map(r => r.id))
221 const out: { a: AgentRow; depth: number }[] = []
222 const walk = (parent: string | undefined, depth: number) => {
223 for (const r of rows) {
224 const p = r.parentId !== undefined && ids.has(r.parentId) ? r.parentId : undefined
225 if (p === parent && !out.some(o => o.a === r) && depth < 6) {
226 out.push({ a: r, depth })
227 walk(r.id, depth + 1)
228 }
229 }
230 }
231 walk(undefined, 0)
232 return out
233}
234
235// A turn's end reason as the agent's final status.
236export const endStatus = (reason: string): string =>
237 reason === 'aborted' ? 'killed' : reason === 'error' || reason === 'refusal' ? 'failed' : 'completed'
238
239// ---------------------------------------------------------------- state updates
240
241const toContext = (c: SessionContextUsage): Context => ({
242 tokens: c.tokens,
243 window: c.window,
244 percent: c.percent,
245})
246
247const add = <T extends Tokens>(a: T, t: TurnUsage): T => ({
248 ...a,
249 input: a.input + t.input_tokens,
250 output: a.output + t.output_tokens,
251 cacheRead: a.cacheRead + t.cache_read_input_tokens,
252 cacheWrite: a.cacheWrite + t.cache_creation_input_tokens,
253})
254
255export const addMainTurn = (u: Usage, t: TurnUsage): Usage => ({
256 ...u,
257 model: t.model,
258 main: { ...add(u.main, t), turns: u.main.turns + 1 },
259 turns: [...u.turns, add({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, model: t.model }, t)].slice(
260 -KEEP_TURNS,
261 ),
262})
263
264const blankAgent = (id: string): AgentRow => ({
265 id,
266 type: 'agent',
267 description: '',
268 model: '',
269 status: 'running',
270 runs: 0,
271 input: 0,
272 output: 0,
273 cacheRead: 0,
274 cacheWrite: 0,
275})
276
277// A new row takes the next seq, so marks never shift. Trimming drops the oldest rows that
278// have ended and are not being viewed; running ones always stay.
279export const upsertAgent = (u: Usage, id: string, change: (a: AgentRow) => AgentRow, now?: number): Usage => {
280 const found = u.agents.find(a => a.id === id)
281 const seq = u.nextSeq ?? u.agents.length
282 const next = change(found ?? { ...blankAgent(id), seq, startedAt: now })
283 const agents = found ? u.agents.map(a => (a.id === id ? next : a)) : [...u.agents, next]
284 let extra = agents.length - KEEP_AGENTS
285 const kept = agents.filter(a => !(extra > 0 && !isLive(a) && !isIdle(a) && a.id !== u.viewing && extra-- > 0))
286 return { ...u, agents: kept, nextSeq: found ? (u.nextSeq ?? u.agents.length) : seq + 1 }
287}
288
289export const tagOf = (a: AgentRow | undefined, u: Usage) => agentTag(a ? (a.seq ?? u.agents.indexOf(a)) : 0)
290
291export const addAgentRun = (u: Usage, id: string, t: TurnUsage): Usage =>
292 upsertAgent(u, id, a => ({ ...add(a, t), model: t.model, runs: a.runs + 1 }))
293
294const statusText = (u: Usage): string | undefined => {
295 const parts: string[] = []
296 const pct = u.context?.percent
297 if (pct !== undefined) parts.push(`${contextState(pct).icon} ${pct}%`)
298 if (u.model) parts.push(`메인 ${modelName(u.model)}${u.effort ? ` ${u.effort}` : ''}`)
299 const all = u.agents.reduce(
300 (s, a) => ({ i: s.i + inputOf(a), o: s.o + a.output }),
301 { i: inputOf(u.main), o: u.main.output },
302 )
303 if (all.i + all.o > 0) parts.push(`↑${fmt(all.i)} ↓${fmt(all.o)}`)
304 const five = u.limits?.find(l => l.kind === 'five_hour')
305 if (five) parts.push(`⏳${Math.round(five.percentUsed)}% ${resetsIn(five.resetsAt, Date.now())}`.trimEnd())
306 const live = u.agents.filter(isLive).length
307 if (live) parts.push(`🤖${live}`)
308 return parts.length ? parts.join(' · ') : undefined
309}
310
311const publish = async ($: EngineInterface) => {
312 $.ui.status(statusText(await read($, usage)))
313}
314
315// Fill in an agent's type, task and status from the engine's roster.
316const syncAgents = async ($: EngineInterface) => {
317 const roster = await $.agent.list()
318 await update($, usage, u => ({
319 ...u,
320 agents: u.agents.map(a => {
321 const info = roster.find(r => r.id === a.id)
322 // Gone from the roster while still marked running: it ended without telling us.
323 if (!info && isLive(a) && known.has(a.id)) return { ...a, status: 'killed', endedAt: a.endedAt ?? Date.now() }
324 return info
325 ? {
326 ...a,
327 type: info.type,
328 parentId: info.parentId,
329 description: info.description,
330 status: info.status,
331 endedAt: ['completed', 'failed', 'killed'].includes(info.status) ? (a.endedAt ?? Date.now()) : a.endedAt,
332 }
333 : a
334 }),
335 }))
336}
337
338const AGENT_PANE = 'agent-view'
339// `viewing` set to this shows every subagent of the session to pick from.
340const AGENT_LIST = '*'
341
342// What a tool call looks at, in a few words: "Read stockbot/cli.py", "Bash grep -n add_parser".
343export const activityOf = (tool: string, input: Record<string, unknown>): string => {
344 const key = ['file_path', 'path', 'pattern', 'command', 'url', 'query', 'description', 'prompt'].find(
345 k => typeof input[k] === 'string' && input[k] !== '',
346 )
347 const arg = key ? String(input[key]).split('\n')[0] ?? '' : ''
348 const short = key === 'file_path' || key === 'path' ? arg.split('/').slice(-2).join('/') : arg
349 return clip(`${tool} ${short}`.trim(), 60)
350}
351
352type Line = { kind: 'ask' | 'say' | 'tool' | 'result'; text: string; arg?: string; isError?: boolean }
353
354// A subagent's messages as the main transcript draws them: "> prompt", "● reply",
355// "● Read(path)" and its "⎿ result" under it, one entry per screen row.
356export const transcriptLines = (messages: readonly SessionMessage[], width: number): Line[] => {
357 const out: Line[] = []
358 const wrap = (text: string, prefix: string, max: number) =>
359 text
360 .split('\n')
361 .filter(l => l.trim())
362 .slice(0, max)
363 .map((l, i) => `${i === 0 ? prefix : ' '.repeat(prefix.length)}${clip(l, width - prefix.length)}`)
364 for (const m of messages) {
365 const text = m.text.trim()
366 if (m.role === 'user') {
367 if (text) for (const t of wrap(text, '> ', 4)) out.push({ kind: 'ask', text: t })
368 continue
369 }
370 if (text) for (const t of wrap(text, '● ', 6)) out.push({ kind: 'say', text: t })
371 for (const t of m.toolUses) {
372 const act = activityOf(t.tool, t.input)
373 out.push({ kind: 'tool', text: t.tool, arg: clip(act.slice(t.tool.length).trim(), width - t.tool.length - 6) })
374 const res = t.text?.split('\n').find(l => l.trim())
375 out.push({
376 kind: 'result',
377 text: res === undefined ? (t.result === undefined ? '실행 중…' : '완료') : clip(res.trim(), width - 6),
378 isError: t.isError === true,
379 })
380 }
381 }
382 return out
383}
384
385const ctxPercent = (a: AgentRow, u: Usage): number | undefined =>
386 a.ctxTokens ? Math.min(100, Math.round((a.ctxTokens / windowFor(a.model, u)) * 100)) : undefined
387
388// Engine forks (compaction, memory) and workflow agents carry ids the roster never lists:
389// only ids the roster knows get a row, so no row is left "running" forever.
390// A miss is rechecked after a few seconds, in case the roster was still catching up.
391const known = new Set<string>()
392const ignored = new Map<string, number>()
393const RECHECK_MS = 3000
394const isTracked = async ($: EngineInterface, id: string): Promise<boolean> => {
395 if (known.has(id)) return true
396 const now = await $.clock.now()
397 const missedAt = ignored.get(id)
398 if (missedAt !== undefined && now - missedAt < RECHECK_MS) return false
399 const roster = await $.agent.list().catch(() => [])
400 for (const r of roster) known.add(r.id)
401 if (known.has(id)) ignored.delete(id)
402 else ignored.set(id, now)
403 return known.has(id)
404}
405
406// Redraw once a second while a subagent runs, so its elapsed time counts up.
407let isTicking = false
408const tick = async ($: EngineInterface) => {
409 if (isTicking) return
410 isTicking = true
411 try {
412 while ((await read($, usage)).agents.some(isLive)) {
413 await $.clock.sleep(1000)
414 $.ui.invalidate('ui.render')
415 }
416 } catch {
417 // No clock (a test, a host without timers): the time still updates on each event.
418 } finally {
419 isTicking = false
420 }
421}
422
423// A press on a subagent's row opens its transcript in a pane; pressing the one shown closes it.
424type View = { columns?: number; rows?: number } | undefined
425
426// Opened focused and near full size, so it reads as the main view switching to that agent.
427const toggleAgentView = async ($: EngineInterface, id: string, view: View, isTab = false) => {
428 const u = await read($, usage)
429 if (u.viewing === id) {
430 // A tab already shown stays; the row's name toggles the pane shut.
431 if (isTab) return
432 await update($, usage, x => ({ ...x, viewing: undefined }))
433 await $.ui.close({ id: AGENT_PANE })
434 return
435 }
436 const a = u.agents.find(x => x.id === id)
437 const tag = tagOf(a, u)
438 await update($, usage, x => ({ ...x, viewing: id }))
439 const cols = view?.columns ?? 100
440 const placed = await $.ui.open({
441 id: AGENT_PANE,
442 title: id === AGENT_LIST ? '서브에이전트 목록' : `${tag.mark} ${a?.description || a?.type || '서브에이전트'}`,
443 focus: true,
444 closeOnEscape: true,
445 rows: Math.max(6, (view?.rows ?? 30) - 8),
446 columns: Math.max(20, Math.min(cols - 2, cols - 20)),
447 })
448 if (!placed.isPlaced) {
449 await update($, usage, x => ({ ...x, viewing: undefined }))
450 await $.ui.close({ id: AGENT_PANE }).catch(() => undefined)
451 $.ui.toast('창을 열 자리가 없습니다. 터미널을 넓히고 다시 눌러 주세요.', { timeoutMs: 5000 })
452 }
453}
454
455// ---------------------------------------------------------------- hooks
456
457type Opts = { isBandOn: boolean; hasResetTimes: boolean; agentRows: number; hasActivity: boolean; theme?: string }
458export const optsFrom = (o: Readonly<Record<string, unknown>>): Opts => ({
459 isBandOn: o.band !== false,
460 hasResetTimes: o.resetTimes === true,
461 agentRows: o.agentRows === '끄기' ? 0 : Number(o.agentRows ?? 5) || 5,
462 hasActivity: o.agentActivity !== false,
463 theme: typeof o.theme === 'string' && o.theme in PRESETS ? o.theme : undefined,
464})
465
466// Settings come from /config (userConfig); a change there reloads the module.
467export const register: Register = (on, options) => {
468 const opts = optsFrom(options)
469 const themeOf = (u: Usage): Palette => (opts.theme ? palette({ preset: opts.theme, overrides: {} }) : themeFrom(u))
470
471 on('session.start', async ($, e, next) => {
472 await $.command.register({
473 name: 'token-usage',
474 description: '입력창 위 사용량 자세히 펼치기/접기 (컨텍스트, 사용 한도, 모델, 서브에이전트). 설정은 /config',
475 argumentHint: '[open|close|band on|off|theme [name]|color <slot> <#hex>]',
476 })
477 const theme = await loadTheme($)
478 await update($, usage, u => ({ ...u, theme }))
479 await freshen($)
480 await publish($)
481 return next(e)
482 })
483
484 on('command.run', { command: 'token-usage' }, async ($, e) => {
485 const arg = e.args.trim()
486 const words = arg.split(/\s+/)
487 if (words[0] === 'theme' || words[0] === 'color') {
488 if (opts.theme) return { text: `/config 의 token-usage 색 테마가 "${opts.theme}"(으)로 정해져 있어 이 명령으로는 바뀌지 않습니다. /config 에서 '파일 설정 따름'으로 바꾼 뒤 다시 해 주세요.` }
489 const r = await themeCommand($, words)
490 if (r.theme) {
491 const theme = r.theme
492 await update($, usage, u => ({ ...u, theme }))
493 }
494 return { text: r.text }
495 }
496 if (arg === 'band off' || arg === 'band on') {
497 const r = await $.config.set({ key: 'token-usage.band', value: arg === 'band on' }).catch(() => ({ deny: 'error' }))
498 if ('deny' in r && r.deny !== undefined) await update($, usage, u => ({ ...u, isBandHidden: arg === 'band off' }))
499 return { text: arg === 'band off' ? '사용량 줄을 숨겼습니다.' : '사용량 줄을 보여줍니다.' }
500 }
501 if (arg === 'close' || arg === 'open' || arg === '') {
502 await freshen($)
503 const isExpanded = arg === 'open' || (arg === '' && !(await read($, usage)).isExpanded)
504 await update($, usage, u => ({ ...u, isExpanded, isBandHidden: false }))
505 return { text: isExpanded ? '사용량을 자세히 펼쳤습니다. /token-usage 를 한 번 더 입력하면 접힙니다.' : '사용량 자세히 보기를 접었습니다.' }
506 }
507 return { text: '사용법: /token-usage (펼치기/접기), /token-usage band on|off, /token-usage theme. 나머지 설정은 /config' }
508 })
509
510 on('session.measure', async ($, e, next) => {
511 await update($, usage, u => {
512 return { ...u, context: toContext(e.context), limits: toLimits(e.rateLimits) }
513 })
514 if ((await read($, usage)).agents.some(isLive)) await syncAgents($).catch(() => undefined)
515 await publish($)
516 return next(e)
517 })
518
519 // The pane closed (Esc, its close mark, or a press): nothing is viewed any more.
520 on('ui.close', async ($, e, next) => {
521 const r = await next(e)
522 if (e.id === AGENT_PANE) await update($, usage, u => ({ ...u, viewing: undefined }))
523 return r
524 }).catch(($, e, next) => next(e))
525
526 // /clear starts over: rows of the old conversation would never finish.
527 on('session.end', async ($, e, next) => {
528 if (e.reason === 'clear') {
529 known.clear()
530 ignored.clear()
531 await update($, usage, u => ({ ...EMPTY, theme: u.theme, limits: u.limits, model: u.model, effort: u.effort }))
532 }
533 return next(e)
534 })
535
536 // Every model request names its model: the live model of main and of each subagent.
537 on('turn.step', async function* ($, e, next) {
538 const agentId = e.agentId
539 if (agentId !== undefined && !(await isTracked($, agentId).catch(() => false))) return yield* next(e)
540 const now = await $.clock.now()
541 await update($, usage, u =>
542 agentId === undefined
543 ? { ...u, model: e.model, effort: e.effort === undefined ? u.effort : String(e.effort) }
544 : upsertAgent(u, agentId, a => ({
545 ...a,
546 model: e.model,
547 effort: e.effort === undefined ? a.effort : String(e.effort),
548 status: isLive(a) || a.runs === 0 ? 'running' : a.status,
549 }), now),
550 )
551 if (agentId !== undefined) void tick($)
552 if (agentId !== undefined && e.index === 0) {
553 await syncAgents($)
554 await publish($)
555 }
556 const r = yield* next(e)
557 const used = r.usage
558 if (agentId !== undefined && used) {
559 const ctxTokens = used.input_tokens + used.cache_read_input_tokens + used.cache_creation_input_tokens
560 await update($, usage, u => upsertAgent(u, agentId, a => ({ ...a, ctxTokens })))
561 }
562 return r
563 })
564
565 on('tool.call', async ($, e, next) => {
566 const agentId = e.agentId
567 if (agentId !== undefined && (await isTracked($, agentId))) {
568 const activity = activityOf(e.tool, e as unknown as Record<string, unknown>)
569 const now = await $.clock.now()
570 await update($, usage, u => upsertAgent(u, agentId, a => ({ ...a, activity }), now))
571 void tick($)
572 }
573 return next(e)
574 }).catch(($, e, next) => next(e))
575
576 on('turn.complete', async ($, e, next) => {
577 const done = await next(e)
578 const t = e.usage
579 const agentId = e.agentId
580 if (agentId !== undefined && !(await isTracked($, agentId).catch(() => false))) return done
581 if (t) {
582 await update($, usage, u =>
583 agentId === undefined ? addMainTurn(u, t) : addAgentRun(u, agentId, t),
584 )
585 }
586 if (agentId !== undefined) {
587 await syncAgents($).catch(() => undefined)
588 // A subagent's turn ending is its run ending, even if the roster has not caught up yet.
589 // A teammate goes idle instead, and the roster says so.
590 const now = await $.clock.now()
591 const status = endStatus(e.reason)
592 await update($, usage, u =>
593 upsertAgent(u, agentId, a => (isLive(a) ? { ...a, status, endedAt: a.endedAt ?? now } : a)),
594 )
595 const u = await read($, usage)
596 const a = u.agents.find(x => x.id === agentId)
597 if (a && u.viewing === agentId) {
598 const tag = tagOf(a, u)
599 const how = a.status === 'completed' ? '끝남' : a.status === 'idle' ? '메시지 기다리는 중' : `멈춤 (${STATUS_KO[a.status] ?? a.status})`
600 $.ui.toast(`${tag.mark} ${a.description || a.type} ${how}: 결과는 열린 창에 남아 있습니다`, { timeoutMs: 6000 })
601 }
602 } else {
603 const theme = await loadTheme($)
604 await update($, usage, u => ({ ...u, theme }))
605 // A subagent stopped without its own turn.complete is caught here.
606 if (u0Live(await read($, usage))) await syncAgents($).catch(() => undefined)
607 }
608 await publish($)
609 return done
610 })
611
612 // ------------------------------------------------------------ band
613
614 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
615 const u = await read($, usage)
616 const below = await next(e)
617 if (e.props.hasSurvey || u.isBandHidden || (!opts.isBandOn && !u.isExpanded)) return below
618
619 const { Box, Button, Text } = $.ui.resolve(e)
620 const P = themeOf(u)
621 const cols = e.viewport?.columns ?? 80
622 const now = await $.clock.now()
623 const pct = u.context?.percent
624 const st = pct !== undefined ? contextState(pct) : null
625 const limits = u.limits ?? []
626 const live = u.agents.filter(a => isLive(a) || isIdle(a))
627 const isNarrow = cols < 80
628 const sep = <Text dimColor> │ </Text>
629 const short = (kind: string) => (kind === 'five_hour' ? '5h' : kind === 'seven_day' ? '7d' : limitLabel(kind))
630 const effortText = (x?: string) => (x ? <Text dimColor> {x}</Text> : null)
631 // A subagent's request often carries no effort; say so rather than leave a gap.
632 const agentEffort = (a: AgentRow) =>
633 a.effort ? <Text color={P.accent}> effort {a.effort}</Text> : a.model ? <Text dimColor> effort 없음</Text> : null
634
635 const line = (
636 <Text>
637 <Text>🧠 </Text>
638 {st && pct !== undefined ? (
639 <Text>
640 <Text bold color={P[st.tone]}>
641 {pct}%
642 </Text>
643 <Text color={P[st.tone]}> {fineBar(pct, 12)}</Text>
644 </Text>
645 ) : (
646 <Text dimColor>응답 전</Text>
647 )}
648 {limits.length > 0 && (
649 <Text>
650 {sep}⏳{' '}
651 {limits.map((l, i) => (
652 <Text>
653 {i > 0 && <Text dimColor> · </Text>}
654 <Text dimColor>{short(l.kind)} </Text>
655 <Text bold color={P[contextState(l.percentUsed).tone]}>
656 {Math.round(l.percentUsed)}%
657 </Text>
658 {opts.hasResetTimes && <Text dimColor> {resetsIn(l.resetsAt, now)}</Text>}
659 </Text>
660 ))}
661 </Text>
662 )}
663 {u.model && (
664 <Text>
665 {sep}
666 {live.length > 0 && <Text dimColor>메인 </Text>}
667 <Text bold color={P.accent}>
668 {modelName(u.model).replace(/ 1M$/, '')}
669 </Text>
670 {effortText(u.effort)}
671 </Text>
672 )}
673 </Text>
674 )
675
676 // One row per running subagent, so each model sits next to its own job.
677 const MAX_AGENT_ROWS = opts.agentRows
678 const viewing = u.viewing
679 const nameWidth = isNarrow ? 14 : 24
680 const agentRows = [
681 // The agent being viewed keeps its row after it finishes, until its pane is closed.
682 ...nested([...live.slice(0, MAX_AGENT_ROWS), ...u.agents.filter(x => x.id === viewing && !live.slice(0, MAX_AGENT_ROWS).includes(x))]).map(({ a, depth }) => {
683 const tag = tagOf(a, u)
684 const cp = ctxPercent(a, u)
685 const isViewing = a.id === viewing
686 const took = agentElapsed(a, now)
687 return (
688 <Box key={`agent:${a.id}`}>
689 <Text color={P.accent}>{isViewing ? '👁 ' : ' '}</Text>
690 {depth > 0 && <Text dimColor>{`${' '.repeat(depth - 1)}└ `}</Text>}
691 <Text bold color={P[tag.tone]}>{tag.mark} </Text>
692 <Button
693 key={`agent:${a.id}`}
694 plain
695 label={fit(clip(a.description || a.type, nameWidth), nameWidth)}
696 onPress={() => toggleAgentView($, a.id, e.viewport)}
697 />
698 <Text> </Text>
699 <Text bold>{a.model ? modelName(a.model).replace(/ 1M$/, '') : '?'}</Text>
700 {agentEffort(a)}
701 {!isLive(a) && (
702 <Text color={a.status === 'completed' ? P.ok : isIdle(a) ? P.cache : P.danger}>
703 {' '}
704 {a.status === 'completed' ? '✓' : isIdle(a) ? '◌' : '✗'} {STATUS_KO[a.status] ?? a.status}
705 </Text>
706 )}
707 {took && <Text dimColor> │ ⏱ {took}</Text>}
708 <Text dimColor> │ ctx </Text>
709 {cp !== undefined ? (
710 <Text color={P[contextState(cp).tone]}>
711 {isNarrow ? '' : `${fineBar(cp, 6)} `}
712 {cp}%
713 </Text>
714 ) : (
715 <Text dimColor>-</Text>
716 )}
717 {opts.hasActivity && !isNarrow && a.activity && <Text dimColor> │ {clip(a.activity, Math.max(10, cols - nameWidth - 62))}</Text>}
718 </Box>
719 )
720 }),
721 ...(MAX_AGENT_ROWS > 0 && live.length > MAX_AGENT_ROWS
722 ? [
723 <Box key="agent:more">
724 <Text>{viewing === AGENT_LIST ? '👁 ' : ' '}</Text>
725 <Button
726 key="agent:more"
727 plain
728 label={`🤖 외 ${live.length - MAX_AGENT_ROWS}개 더 보기`}
729 onPress={() => toggleAgentView($, AGENT_LIST, e.viewport)}
730 />
731 </Box>,
732 ]
733 : []),
734 ]
735
736 if (!u.isExpanded) {
737 return (
738 <Box flexDirection="column">
739 {line}
740 {agentRows}
741 {below}
742 </Box>
743 )
744 }
745
746 const W = 12
747 const all = [u.main, ...u.agents]
748 const sum = (k: keyof Tokens) => all.reduce((s, t) => s + t[k], 0)
749 const totals: Tokens = { input: sum('input'), output: sum('output'), cacheRead: sum('cacheRead'), cacheWrite: sum('cacheWrite') }
750 const cachePct = inputOf(totals) ? Math.round((totals.cacheRead / inputOf(totals)) * 100) : 0
751 const row = (label: string, value: number, tone: string, detail: string) => (
752 <Text>
753 <Text>{' '}{fit(label, 11)}</Text>
754 <Text color={tone}>{fineBar(value, W)}</Text>
755 <Text bold> {String(Math.round(value)).padStart(3)}%</Text>
756 <Text dimColor> {detail}</Text>
757 </Text>
758 )
759 const agentsShown = [...live, ...u.agents.filter(a => !isLive(a)).reverse()].slice(0, 6)
760 const modelCell = (model: string, effort?: string) => fit(`${model ? modelName(model) : '?'} ${effort ?? '(effort 없음)'}`, 30)
761
762 return (
763 <Box flexDirection="column">
764 {line}
765 {st && pct !== undefined && u.context && row('컨텍스트', pct, P[st.tone], `${fmt(u.context.tokens ?? 0)}/${fmt(u.context.window)} ${st.label}`)}
766 {limits.map(l => row(`${limitLabel(l.kind)} 한도`, l.percentUsed, P[contextState(l.percentUsed).tone], resetsIn(l.resetsAt, now)))}
767 {row('캐시 적중', cachePct, P.ok, `↑${fmt(inputOf(totals))} ↓${fmt(totals.output)}`)}
768 <Text dimColor>{' ─ 모델 '.padEnd(Math.min(60, cols - 4), '─')}</Text>
769 <Text>
770 <Text>{' '}</Text>
771 <Text color={P.accent}>● </Text>
772 <Text bold>{fit('메인', 20)}</Text>
773 <Text color={P.accent}>{modelCell(u.model ?? '', u.effort)}</Text>
774 {st && pct !== undefined ? (
775 <Text>
776 <Text color={P[st.tone]}>{fineBar(pct, 8)}</Text>
777 <Text bold> {String(pct).padStart(3)}%</Text>
778 </Text>
779 ) : (
780 <Text dimColor>{'·'.repeat(8)} -</Text>
781 )}
782 <Text dimColor> ↓{fmt(u.main.output)}</Text>
783 </Text>
784 {agentsShown.map(a => {
785 const as = AGENT_ICON[a.status] ?? { icon: '·', tone: 'cache' as const }
786 const win = windowFor(a.model, u)
787 const cp = a.ctxTokens ? Math.min(100, Math.round((a.ctxTokens / win) * 100)) : undefined
788 return (
789 <Text>
790 <Text>{' '}</Text>
791 <Text color={P[as.tone]}>{as.icon} </Text>
792 <Text bold color={P[tagOf(a, u).tone]}>{fit(`${tagOf(a, u).mark} ${clip(a.description || a.type, 16)}`, 20)}</Text>
793 <Text color={P.warn}>{modelCell(a.model, a.effort)}</Text>
794 {cp !== undefined ? (
795 <Text>
796 <Text color={P[contextState(cp).tone]}>{fineBar(cp, 8)}</Text>
797 <Text bold> {String(cp).padStart(3)}%</Text>
798 </Text>
799 ) : (
800 <Text dimColor>{'·'.repeat(8)} -</Text>
801 )}
802 <Text dimColor>
803 {' '}↓{fmt(a.output)}
804 </Text>
805 </Text>
806 )
807 })}
808 <Text dimColor>
809 {' '}테마 {(u.theme as ThemeFile | undefined)?.preset ?? 'default'} · /token-usage theme · 접기: /token-usage
810 </Text>
811 {below}
812 </Box>
813 )
814 })
815
816
817 // ------------------------------------------------------------ agent pane
818
819 on('ui.render', { component: 'Pane', requestId: AGENT_PANE }, async ($, e) => {
820 const { Box, Text } = $.ui.resolve(e)
821 const u = await read($, usage)
822 const P = themeOf(u)
823 const { Button } = $.ui.resolve(e)
824 const width = Math.max(20, (e.props.bodyColumns ?? e.viewport?.columns ?? 60) - 2)
825 const view = e.viewport
826 // Running first, then the latest finished: every subagent stays one press away.
827 const nowMs = await $.clock.now()
828 const ordered = [...u.agents.filter(isLive), ...u.agents.filter(x => !isLive(x)).reverse()]
829 const tabs = (
830 <Box key="tabs" flexWrap="wrap">
831 <Button key="tab:*" plain label={u.viewing === AGENT_LIST ? '[목록]' : ' 목록 '} onPress={() => toggleAgentView($, AGENT_LIST, view, true)} />
832 {ordered.slice(0, 12).map(x => {
833 const t = tagOf(x, u)
834 const isOn = x.id === u.viewing
835 return (
836 <Button
837 key={`tab:${x.id}`}
838 plain
839 label={`${isOn ? '[' : ' '}${t.mark}${isLive(x) ? '' : '✓'} ${clip(x.description || x.type, 10)}${isOn ? ']' : ' '}`}
840 onPress={() => toggleAgentView($, x.id, view, true)}
841 />
842 )
843 })}
844 </Box>
845 )
846 if (u.viewing === AGENT_LIST) {
847 return (
848 <Box flexDirection="column">
849 {tabs}
850 <Text dimColor>{'─'.repeat(Math.min(width, 80))}</Text>
851 {ordered.length === 0 && <Text dimColor>아직 서브에이전트가 없습니다.</Text>}
852 {nested(ordered).map(({ a: x, depth }) => {
853 const t = tagOf(x, u)
854 const xp = ctxPercent(x, u)
855 return (
856 <Box key={`row:${x.id}`}>
857 {depth > 0 && <Text dimColor>{`${' '.repeat(depth - 1)}└ `}</Text>}
858 <Text bold color={P[t.tone]}>{t.mark} </Text>
859 <Button key={`pick:${x.id}`} plain label={fit(clip(x.description || x.type, 24), 24)} onPress={() => toggleAgentView($, x.id, view, true)} />
860 <Text bold> {x.model ? modelName(x.model).replace(/ 1M$/, '') : '?'}</Text>
861 <Text dimColor> effort {x.effort ?? '없음'}</Text>
862 <Text color={isLive(x) ? P.warn : x.status === 'completed' ? P.ok : isIdle(x) ? P.cache : P.danger}> {STATUS_KO[x.status] ?? x.status}</Text>
863 {agentElapsed(x, nowMs) && <Text dimColor> · ⏱ {agentElapsed(x, nowMs)}</Text>}
864 <Text dimColor> · ctx {xp !== undefined ? `${xp}%` : '-'}</Text>
865 {x.activity && <Text dimColor> · {clip(x.activity, Math.max(10, width - 70))}</Text>}
866 </Box>
867 )
868 })}
869 </Box>
870 )
871 }
872 const a = u.agents.find(x => x.id === u.viewing)
873 if (!a) return <Box flexDirection="column">{tabs}<Text dimColor>위 줄에서 서브에이전트 이름을 누르면 여기에 그 작업 내용이 나옵니다.</Text></Box>
874 const tag = tagOf(a, u)
875 const cp = ctxPercent(a, u)
876 const found = await $.session.messages({ agentId: a.id }).catch(() => ({ deny: 'error' }) as const)
877 const lines = transcriptLines('deny' in found ? [] : found, width)
878 const room = Math.max(3, (e.viewport?.rows ?? 24) - 6)
879 const STATUS = STATUS_KO
880 const took = agentElapsed(a, await $.clock.now())
881 const isLiveNow = isLive(a)
882 return (
883 <Box flexDirection="column">
884 {tabs}
885 <Text>
886 <Text backgroundColor={P[tag.tone]} color={P.chipText} bold>
887 {` ${tag.mark} ${a.description || a.type} `}
888 </Text>
889 <Text bold> {a.model ? modelName(a.model) : '?'}</Text>
890 <Text dimColor> · effort {a.effort ?? '없음'}</Text>
891 <Text color={isLiveNow ? P.warn : P.ok}> · {STATUS[a.status] ?? a.status}</Text>
892 {took && <Text dimColor> · ⏱ {took}</Text>}
893 <Text dimColor> · ctx {cp !== undefined ? `${cp}%` : '-'} · Esc: 메인으로</Text>
894 </Text>
895 {'deny' in found && <Text dimColor>이 서브에이전트의 기록을 읽을 수 없습니다.</Text>}
896 {lines.slice(-room).map(l =>
897 l.kind === 'ask' ? (
898 <Text dimColor>{l.text}</Text>
899 ) : l.kind === 'tool' ? (
900 <Text>
901 <Text color={P.ok}>● </Text>
902 <Text bold>{l.text}</Text>
903 {l.arg && <Text>({l.arg})</Text>}
904 </Text>
905 ) : l.kind === 'result' ? (
906 <Text color={l.isError ? P.danger : undefined} dimColor={!l.isError}>
907 {' ⎿ '}
908 {l.text}
909 </Text>
910 ) : (
911 <Text>{l.text}</Text>
912 ),
913 )}
914 {isLiveNow ? (
915 <Text color={P.warn}>✻ 작업 중…</Text>
916 ) : (
917 <Text color={a.status === 'completed' ? P.ok : P.danger}>
918 {a.status === 'completed' ? `✓ 작업이 끝났습니다${took ? ` (${took} 걸림)` : ''}.` : isIdle(a) ? '◌ 메시지를 기다리는 중입니다.' : `✗ 작업이 멈췄습니다 (${STATUS[a.status] ?? a.status}).`} 다른 서브에이전트는 위 탭에서, Esc는 메인으로.
919 </Text>
920 )}
921 </Box>
922 )
923 })
924}
925hooks/theme.ts 100 lines1// One color theme shared by the token-usage and compact-handoff mods.
2// Kept in ~/.claude/mods/theme.json so either mod's command changes both.
3
4export type Slot = 'ok' | 'warn' | 'danger' | 'input' | 'output' | 'cache' | 'accent' | 'chipText'
5export type Palette = Record<Slot, string>
6export type ThemeFile = { preset: string; overrides: Partial<Palette> }
7
8export const SLOTS: readonly Slot[] = ['ok', 'warn', 'danger', 'input', 'output', 'cache', 'accent', 'chipText']
9
10export const PRESETS: Record<string, Palette> = {
11 // The terminal theme's own colors: follows /theme light or dark.
12 default: {
13 ok: 'success',
14 warn: 'warning',
15 danger: 'error',
16 input: 'suggestion',
17 output: 'claude',
18 cache: 'inactive',
19 accent: 'permission',
20 chipText: 'inverseText',
21 },
22 // Okabe-Ito: distinguishable with common color-vision deficiencies.
23 colorblind: {
24 ok: '#009E73',
25 warn: '#E69F00',
26 danger: '#D55E00',
27 input: '#56B4E9',
28 output: '#CC79A7',
29 cache: '#999999',
30 accent: '#0072B2',
31 chipText: '#000000',
32 },
33 ocean: {
34 ok: '#2DD4BF',
35 warn: '#FBBF24',
36 danger: '#F87171',
37 input: '#60A5FA',
38 output: '#C084FC',
39 cache: '#64748B',
40 accent: '#38BDF8',
41 chipText: '#0B1220',
42 },
43 vivid: {
44 ok: '#22C55E',
45 warn: '#F59E0B',
46 danger: '#EF4444',
47 input: '#3B82F6',
48 output: '#F97316',
49 cache: '#6B7280',
50 accent: '#A855F7',
51 chipText: '#000000',
52 },
53}
54
55export const palette = (t: ThemeFile): Palette => {
56 const base = PRESETS[t.preset] ?? PRESETS.default
57 return { ...(base as Palette), ...t.overrides }
58}
59
60const isColor = (v: string) => /^#[0-9a-fA-F]{6}$/.test(v) || /^[a-zA-Z]+$/.test(v)
61
62export const DEFAULT_THEME: ThemeFile = { preset: 'default', overrides: {} }
63
64// CLAUDE_MODS_THEME_FILE lets another account (root) share this user's theme file.
65export const themePath = (home: string | undefined, file?: string) => file || `${home ?? '~'}/.claude/mods/theme.json`
66
67export const parseTheme = (text: string): ThemeFile => {
68 try {
69 const raw = JSON.parse(text) as Partial<ThemeFile>
70 return { preset: raw.preset ?? 'default', overrides: raw.overrides ?? {} }
71 } catch {
72 return DEFAULT_THEME
73 }
74}
75
76const show = (t: ThemeFile) =>
77 `theme: ${t.preset} (${Object.keys(PRESETS).join(' | ')})\n` +
78 SLOTS.map(s => ` ${s.padEnd(8)} ${palette(t)[s]}${t.overrides[s] ? ' (override)' : ''}`).join('\n')
79
80// `theme`, `theme <preset>`, `color <slot> <#hex|theme-key>`, `color reset`.
81// Returns the text to show and, when it changed, the theme to save.
82export const applyThemeArgs = (now: ThemeFile, args: readonly string[]): { text: string; next?: ThemeFile } => {
83 const [verb, a, b] = args
84 if (verb === 'theme') {
85 if (!a) return { text: show(now) }
86 if (!PRESETS[a]) return { text: `"${a}" 테마는 없습니다. 고를 수 있는 테마: ${Object.keys(PRESETS).join(', ')}` }
87 const next = { preset: a, overrides: {} }
88 return { text: show(next), next }
89 }
90 if (a === 'reset') {
91 const next = { ...now, overrides: {} }
92 return { text: show(next), next }
93 }
94 if (!a || !b || !SLOTS.includes(a as Slot) || !isColor(b)) {
95 return { text: `사용법: color <${SLOTS.join('|')}> <#RRGGBB 색 코드>, 되돌리기: color reset` }
96 }
97 const next = { ...now, overrides: { ...now.overrides, [a]: b } }
98 return { text: show(next), next }
99}
100types/index.d.ts 63 lines1export type Tokens = {
2 input: number
3 output: number
4 cacheRead: number
5 cacheWrite: number
6}
7
8export type TurnRow = Tokens & { model: string }
9
10export type AgentRow = Tokens & {
11 id: string
12 // Order of first appearance in the session: its ①② mark and color, never reused.
13 seq?: number
14 // When it was first seen and when its run ended (ms), for the elapsed time.
15 startedAt?: number
16 endedAt?: number
17 type: string
18 description: string
19 model: string
20 status: string
21 runs: number
22 effort?: string
23 // Input of its latest request: how full its own context window is.
24 ctxTokens?: number
25 // Its latest tool call, as "Read docs/spec.md": what it is looking at now.
26 activity?: string
27 // The subagent that spawned this one; absent when the main loop did.
28 parentId?: string
29}
30
31export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
32
33export type Context = { tokens?: number; window: number; percent?: number }
34
35export type Usage = {
36 // Main loop only; subagents are kept per agent in `agents`.
37 main: Tokens & { turns: number }
38 model: string | null
39 effort?: string
40 turns: TurnRow[]
41 agents: AgentRow[]
42 context: Context | null
43 isBandHidden: boolean
44 // Subscription rate-limit windows (five_hour, seven_day); absent before the first reading.
45 limits?: Limit[]
46 // Color theme, as ~/.claude/mods/theme.json holds it.
47 theme?: { preset: string; overrides: Record<string, string> }
48 // The band above the prompt drawn in detail (toggled by /token-usage).
49 isExpanded?: boolean
50 // The subagent whose transcript the agent pane shows, while that pane is open.
51 viewing?: string
52 // The next subagent's seq.
53 nextSeq?: number
54}
55
56declare module 'claude-code' {
57 interface PluginState {
58 'token-usage': {
59 usageV3: Usage
60 }
61 }
62}
63