SLOPSHOPPER

token-usage

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

newpanebandguardcommandtoast
v0.2.0no licenseupdated 2026-10-06parkyountaek/claude-mods/token-usage
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-usage
│ ┃ agent-view ✕ › 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 │ │ › /token-usage │ ⎿ token-usage: 사용량을 자세히 펼쳤습니다. /token-usage 를 한 번 더 입력하면 접힙니다. │ │ 🧠 49% █████▉······ │ ⏳ 5h 31% │ Opus 5.5 컨텍스트 █████▉······ 49% 97k/200k 여유 5시간 한도 ███▊········ 31% 캐시 적중 ███████████▏ 93% ↑97k ↓1.5k ─ 모델 ───────────────────────────────────────────────────── ● 메인 Opus 5.5 (effort 없음) ███▉···· 49% ↓1.5k 테마 default · /token-usage theme · 접기: /token-usage ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ token-usage: ◑ 49% · 메인 Opus 5.5 · ↑97k ↓1.5k · ⏳31%

Draws

Band
🧠 49% █████▉······ │ ⏳ 5h 31% │ Opus 5.5 컨텍스트 █████▉······ 49% 97k/200k 여유 5시간 한도 ███▊········ 31% 캐시 적중 ███████████▏ 93% ↑97k ↓1.5k ─ 모델 ───────────────────────────────────────────────────── ● 메인 Opus 5.5 (effort 없음) ███▉···· 49% ↓1.5k 테마 default · /token-usage theme · 접기: /token-usage ⟨Claude Code's own drawing⟩
Pane · agent-view
목록 위 줄에서 서브에이전트 이름을 누르면 여기에 그 작업 내용이 나옵니다.
README

claude-mods

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 메뉴에 항목으로 나옵니다. 메뉴에서 바꾸면 그 자리에서 바로 적용됩니다.

  • token-usage: 입력창 위 사용량 줄, 사용 한도 초기화 시간, 서브에이전트 줄 수(3/5/8/끄기), 서브에이전트가 지금 보는 것, 색 테마
  • work-alerts: 작업 알림, 알림음, macOS 알림 창, 긴 작업 기준(30초/60초/2분/5분), 서브에이전트 완료 알림(알림 창만/알림 창과 소리/끄기)
  • compact-handoff: 할 일 진행 줄, 압축 때 이어가기 메모

/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" } }

root 계정에서도 쓰기

sudo sh ~/.claude/mods/install-root.sh
  • root 설정에 이 폴더의 mod 경로를 추가합니다. 기존 설정은 먼저 백업하고, 이미 등록돼 있으면 아무것도 바꾸지 않습니다.
  • 파일을 복사하지 않으므로 mod를 고치면 root에도 바로 적용됩니다.
  • 테마는 theme.json 하나를 함께 씁니다.
  • root 계정에서는 소리와 macOS 알림이 나오지 않을 수 있습니다. 알림 창은 그대로 뜹니다.

개발

claude plugin validate <mod>
claude plugin test <mod>
Source 3 files
hooks/register.tsx 925 lines
1import { 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}
925
hooks/theme.ts 100 lines
1// 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}
100
types/index.d.ts 63 lines
1export 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