SLOPSHOPPER

usage-line

statusline-usage.sh 상태 줄을 mod로 다시 그린다

newbandtoastprocesstimer
v0.1.0no licenseupdated 2026-10-12jaeseongs95/claude-code-mods/usage-line
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-line
› 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 app │ Opus 5.5 [ status ] [ context ] [ compact ] ctx 49% 97k/200k │ $0.42 │ 5h 31% →09:53 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
app │ Opus 5.5 [ status ] [ context ] [ compact ] ctx 49% 97k/200k │ $0.42 │ 5h 31% →09:53
README

claude-code-mods

Claude Code mod 플러그인 두 개와, 이를 설치하는 마켓플레이스 local-mods를 담은 저장소입니다.

  • usage-line: 입력창 바로 위에 작업 상태와 사용량을 두 줄 띠로 보여 줍니다.
  • korean-ui: 화면에 보이는 영문을 한국어로 바꿉니다. 아래 절 참고.

usage-line

~/.claude/statusline-usage.sh 상태 줄에 나오던 정보를 mod API로 다시 그립니다. 스크립트를 실행하지는 않으며, 스크립트에 있던 vim 모드 표시는 mod API로 읽을 수 없어 뺐습니다.

mod(function hooks) API는 early access 기능이라 Claude Code 릴리스에 따라 바뀔 수 있습니다.

화면 구성

proj on  main [+1 ?3]  │  Opus 5.5 ⚡high  [status] [context] [diff] [compact]
  ctx 42% 84k/200k · cache 87%  │  $1.23  │  5h 30% →14:00 · 7d 12% · Fable 5%

1줄: 어디서, 무엇으로

  • 폴더 이름과 git 브랜치. 브랜치 뒤 괄호에는 스테이징(+), 수정(!), 미추적(?), ahead(⇡), behind(⇣) 개수를 0이 아닌 것만 표시합니다. detached 상태면 커밋 해시 앞 7자리를 보여 줍니다.
  • 모델 표시 이름(claude-opus-5-5 → Opus 5.5)과 effort. effort는 마지막 모델 요청 값을 쓰고, 아직 요청이 없으면 settings의 modelSettings[모델 ID].effortLevel, 그것도 없으면 effortLevel을 씁니다.
  • [status], [context] 버튼은 /status, /context를, [compact] 버튼은 이어서 작업할 수 있게 요약하라는 지시문을 붙여 /compact를 실행합니다. 응답 중에 누르면 응답이 끝난 뒤 실행됩니다.
  • 필요할 때만 보이는 버튼: 스테이징·수정한 파일이 있으면 [diff](/diff), 5h나 7d 사용률이 70% 이상이면 [usage](/usage)가 [compact] 앞에 나타납니다. 미추적 파일만 있을 때는 [diff]를 보이지 않습니다.
  • 버튼은 폭이 좁아도 줄어들지 않고, 대신 폴더·모델 쪽 글자가 …로 잘립니다. 버튼을 [ label ]로 그리면 폴더·모델에 40칸이 남지 않을 만큼 좁을 때는 [compact]를 뺀 버튼을 테두리 없는 흐린 라벨로 그립니다. 마우스를 올리거나 포커스가 가면 진하게 보입니다.

2줄: 얼마나

  • ctx: 컨텍스트 사용률과 토큰 수(사용/창 크기)
  • cache: 프롬프트 캐시 적중률. 이 대화의 메인 스레드 요청에서 누적한 cache_read 토큰을 전체 입력 토큰으로 나눈 값입니다. /clear로 세션 id가 바뀌거나 Claude Code를 새로 시작하면(resume 포함) 0부터 다시 셉니다.
  • 세션 비용(USD)
  • 5h(초기화 시각 포함), 7d 사용 한도와 모델별 주간 한도. 모델별 한도는 상태 줄 입력에 없어서 Claude Code가 ~/.claude.json에 캐시해 둔 값을 읽으며, 다른 계정의 캐시이거나 24시간 넘게 갱신되지 않았으면 표시하지 않습니다.

라벨과 부가 정보는 흐리게, 값은 기본 글자색으로 그립니다. 사용률은 한도에 가까울 때만 색을 바꿉니다.

항목노랑굵은 빨강
ctx60% 이상80% 이상
5h, 7d, 모델별 한도70% 이상90% 이상

띠에 두 줄을 그릴 자리가 없으면 한 줄로 합칩니다.

갱신 시점

  • 세션 시작 시, 그리고 그 뒤 10초마다(대기 중이거나 Claude 밖에서 git 상태가 바뀐 경우 반영)
  • 사용량이 다시 측정될 때(session.measure)
  • 메인 스레드의 턴이 끝날 때

git 정보는 git status --porcelain=v2 --branch 한 번으로 읽으므로 git이 PATH에 있어야 합니다. git 저장소가 아닌 폴더에서는 git 구간을 표시하지 않습니다.

korean-ui

플러그인 API가 바꿀 수 있게 열어 둔 화면 요소만 한국어로 바꿉니다.

위치원래바뀐 뒤
Claude 답변·질문·작업 목록영어로 답할 때가 있음시스템 프롬프트에 지시를 붙여 한국어로 답하게 함
작업 중 표시Sauteing…궁리하는 중…
턴 종료 줄✻ Baked for 3s✻ 3초 동안 궁리함
입력창 아래 힌트? for shortcuts, esc to interrupt? 단축키, esc 중단
모드 표시plan mode on계획 모드 켜짐
접힌 도구 묶음Read 3 files, ran 2 shell commands파일 3개 읽음, 명령 2개 실행 (ctrl+o 펼치기)
/config 메뉴 항목Auto-compact, Show tips자동 압축, 팁 표시, 사전에 없는 항목은 모델 번역
/ 자동완성 명령 설명Start a new session …빈 컨텍스트로 새 세션 시작 …
대화 중 엔진 안내 줄Conversation compacted, Session resumed without branch: main대화를 압축함, 그 밖의 문장은 모델 번역
백그라운드 작업 알림 줄Task … completed 같은 요약모델 번역
스킬·플러그인 명령 설명, 사전에 없는 기본 명령 설명/ 메뉴의 영문 설명모델 번역

번역은 두 단계로 합니다.

  1. 고정 사전: 입력창 힌트, 스피너, 모드 표시, /config 항목, 기본 명령 설명처럼 문구가 정해진 곳은 register.tsx·dict.ts의 사전으로 바로 바꿉니다. 사전 문구는 Claude Code 2.1.296 실행 파일에서 확인했습니다. 키 이름 뒤에 동작이 붙는 힌트(esc to close, / to search)는 키 + 동작 규칙으로 바꿉니다.
  2. 모델 번역: 안내 줄, 작업 알림, 스킬·플러그인 명령 설명, 사전에 없는 기본 명령 설명과 /config 항목 이름처럼 종류가 많거나 값이 섞여 드는 문장은 haiku 모델로 번역합니다. 결과는 $.store에 최근 500개까지 남겨 다시 쓰므로 같은 문장은 한 번만 번역합니다.
  3. 명령 설명과 /config 항목 이름은 세션이 시작되고 3초 뒤 한 번에 번역합니다(10개씩 묶어 요청). 처음 설치한 세션이나 새 스킬·새 버전의 문구가 생겼을 때만 토큰이 듭니다.
  4. 안내 줄과 작업 알림은 처음 나올 때 번역이 끝날 때까지 1~3초 늦게 표시될 수 있습니다. 번역이 실패하면 원문을 그대로 보여 주고 다음에 다시 시도합니다.
  5. 모델이 읽는 대화 내용은 바꾸지 않습니다. 안내 줄은 모델이 읽지 않는 행이고, 작업 알림은 화면에 그리는 글자만 바꿉니다.

사전에도 없고 모델 번역 대상도 아닌 문장은 섞인 문장이 되지 않도록 원문 그대로 둡니다. 코드, 명령어, 경로, 식별자는 번역하지 않습니다.

바꾸지 못하는 것: 시작 로고, 권한 확인 창, 질문 창의 고정 버튼(Other, Submit), /status·/usage 창, /config의 탭 이름과 설정값, 도구 실행 줄(Bash(...))과 결과 요약(Read 120 lines). 이 화면들은 플러그인 API에 바꿀 수 있는 지점이 없습니다.

/config 항목 이름과 기본 명령 설명은 Claude Code 2.1.296에서 뽑은 영문을 키로 씁니다. 버전이 바뀌어 사전에 없는 문구가 생기면 모델 번역으로 채우고, 번역을 고정하고 싶을 때만 korean-ui/hooks/dict.ts에 추가합니다. 입력창 힌트·스피너·모드 표시는 자주 다시 그려지는 짧은 문구라 사전으로만 바꾸므로, 새 문구는 영문으로 남습니다.

한국어 팁

스피너 아래 Tip: 줄은 플러그인 훅이 닿지 않아, Claude Code의 spinnerTipsOverride 설정으로 바꿉니다. korean-ui/tips.json에 2.1.291·2.1.296의 기본 팁을 옮긴 54개가 있습니다. 맥 전용, 기업·외부 클라우드 전용, 그때그때 값이 바뀌는 팁은 뺐습니다. ~/.claude/settings.json에 다음을 넣으면 기본 영어 팁 대신 이 목록이 팁: 머리말로 나옵니다.

"spinnerTipsOverride": {
  "tipsFile": "~/.claude/mods/korean-ui/tips.json",
  "excludeDefault": true,
  "label": "팁"
}

팁은 파일에서 바로 읽으므로 저장소를 git pull하면 갱신됩니다. Claude Code에 새 팁이 생기면 이 파일에 직접 추가해야 합니다(최대 200개, 각 500자).

설치

저장소를 내려받아 로컬 마켓플레이스로 추가한 뒤 플러그인을 설치합니다.

git clone https://github.com/jaeseongs95/claude-code-mods.git ~/.claude/mods
claude plugin marketplace add ~/.claude/mods
claude plugin install usage-line@local-mods
claude plugin install korean-ui@local-mods

필요한 플러그인만 설치해도 됩니다.

구조

.claude-plugin/marketplace.json   마켓플레이스 local-mods 정의
usage-line/
  .claude-plugin/plugin.json      플러그인 매니페스트
  hooks/hooks.json                훅 모듈 목록
  hooks/register.tsx              띠 그리기와 갱신 로직
  hooks/usage-line.test.ts        테스트
  types/index.d.ts                state 타입 선언
korean-ui/
  .claude-plugin/plugin.json      플러그인 매니페스트
  hooks/hooks.json                훅 모듈 목록
  hooks/register.tsx              화면 요소별 한글화 훅
  hooks/dict.ts                   /config 항목 이름·명령 설명 사전
  hooks/translate.ts              모델 번역 프롬프트와 응답 해석
  hooks/register.test.ts          테스트
  tips.json                       spinnerTipsOverride용 한국어 팁 목록

각 플러그인의 .claude-plugin/types/는 Claude Code가 mod를 불러올 때 생성하는 API 타입 선언이라 저장소에 포함하지 않습니다.

검사와 테스트

claude plugin validate usage-line
claude plugin test usage-line
claude plugin validate korean-ui
claude plugin test korean-ui
Source 2 files
hooks/register.tsx 310 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Cache, Seg } from '../types'
5
6const band = atom({ plugin: 'usage-line', key: 'rows' } as const, [] as Seg[][])
7// 상황에 따라 [status] [context] 와 [compact] 사이에 끼우는 버튼 이름.
8const extra = atom({ plugin: 'usage-line', key: 'buttons' } as const, [] as string[])
9
10// ~/.claude/statusline-usage.sh 의 정보를 두 줄 띠로 그린다. 스크립트에 있던 vim 모드는 mod API로 읽을 수 없어 뺐다.
11//   1줄 "어디서·무엇으로": 폴더 · git · 모델 ⚡effort  (+ [status] [context] [diff?] [usage?] [compact] 버튼)
12//   2줄 "얼마나": 컨텍스트 · 캐시 | 비용 | 사용 한도(5h · 7d · 모델별)
13// 색 규칙: 라벨과 부가 정보는 흐리게, 값은 기본 글자색. 한도에 가까워질 때만 노랑·빨강으로 눈에 띄게 한다.
14// 정체성(폴더·브랜치·모델·effort)만 고유 색을 쓴다.
15
16export type Git = { branch: string; staged: number; modified: number; untracked: number; ahead: number; behind: number }
17export type Line = {
18  dir: string
19  git?: Git
20  ctxPct?: number
21  ctxTokens?: number
22  ctxWindow?: number
23  cost?: number
24  cacheHit?: number
25  five?: { pct: number; reset?: string }
26  seven?: number
27  scoped: { name: string; pct: number }[]
28  model?: string
29  effort?: string
30}
31
32const LAVENDER = '#af87ff', ORANGE = '#ff8700'
33const s = (text: string, color?: string, bold?: boolean): Seg => ({ text, color, bold })
34const dim = (text: string): Seg => ({ text, dim: true })
35
36// pct 가 warn 이상이면 노랑, crit 이상이면 굵은 빨강.
37const pct = (label: string, v: number, warn: number, crit: number, note?: string): Seg[] => [
38  dim(`${label} `),
39  { text: `${Math.round(v)}%`, color: v >= crit ? 'red' : v >= warn ? 'yellow' : undefined, bold: v >= crit },
40  ...(note ? [dim(` ${note}`)] : []),
41]
42const join = (items: Seg[][], sep: Seg) => items.filter(i => i.length).flatMap((g, i) => (i ? [sep, ...g] : g))
43const DOT = dim(' · '), BAR = dim('  │  ')
44
45export function humanTokens(n: number): string {
46  if (n >= 1e6) {
47    const m = Math.floor(n / 1e6), f = Math.floor((n % 1e6) / 1e5)
48    return f ? `${m}.${f}M` : `${m}M`
49  }
50  return n >= 1000 ? `${Math.floor(n / 1000)}k` : String(n)
51}
52
53// $.session.model() 은 ID(claude-opus-5-5)를 준다. 스크립트처럼 표시 이름(Opus 5.5)으로 바꾸고, 모르는 형식이면 그대로 둔다.
54export function modelName(id: string): string {
55  const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[.+\])?$/.exec(id)
56  if (!m) return id
57  const [, family = '', major, minor, suffix] = m
58  return `${family[0]?.toUpperCase()}${family.slice(1)} ${major}${minor ? `.${minor}` : ''}${suffix ? ` ${suffix}` : ''}`
59}
60
61const EFFORT: Record<string, Omit<Seg, 'text'>> = {
62  low: { color: 'gray' },
63  medium: { color: 'cyan' },
64  high: { color: 'yellow' },
65  xhigh: { color: ORANGE },
66  max: { color: 'red', bold: true },
67}
68
69export function format(d: Line): Seg[][] {
70  const loc = [s(d.dir, 'cyan', true)]
71  if (d.git) {
72    const g = d.git
73    loc.push(dim(' on '), s(` ${g.branch}`, 'magenta', true))
74    const parts = [
75      g.staged && s(`+${g.staged}`, 'green'),
76      g.modified && s(`!${g.modified}`, 'yellow'),
77      g.untracked && s(`?${g.untracked}`, 'red'),
78      g.ahead && s(`⇡${g.ahead}`, 'cyan'),
79      g.behind && s(`⇣${g.behind}`, 'magenta'),
80    ].filter((p): p is Seg => !!p)
81    if (parts.length) loc.push(dim(' ['), ...parts.flatMap((p, i) => (i ? [s(' '), p] : [p])), dim(']'))
82  }
83
84  const model = d.model ? [s(d.model, LAVENDER)] : []
85  if (d.effort) model.push(...(model.length ? [s(' ')] : []), { ...(EFFORT[d.effort] ?? {}), text: `⚡${d.effort}` })
86
87  const tokens = d.ctxTokens !== undefined && d.ctxWindow !== undefined ? `${humanTokens(d.ctxTokens)}/${humanTokens(d.ctxWindow)}` : undefined
88  const context = join([
89    pct('ctx', d.ctxPct ?? 0, 60, 80, tokens),
90    d.cacheHit !== undefined ? [dim('cache '), s(`${d.cacheHit}%`)] : [],
91  ], DOT)
92  const limits = join([
93    d.five ? pct('5h', d.five.pct, 70, 90, d.five.reset && `→${d.five.reset}`) : [],
94    d.seven !== undefined ? pct('7d', d.seven, 70, 90) : [],
95    ...d.scoped.map(x => pct(x.name, x.pct, 70, 90)),
96  ], DOT)
97
98  return [
99    join([loc, model], BAR),
100    join([context, [s(`$${(d.cost ?? 0).toFixed(2)}`)], limits], BAR),
101  ]
102}
103
104// 필요할 때만 보이는 버튼: 커밋 안 한 변경이 있으면 [diff], 5h·7d 한도가 노랑(70%) 이상이면 [usage].
105export function extraButtons(d: Line): string[] {
106  const near = Math.max(d.five?.pct ?? 0, d.seven ?? 0) >= 70
107  return [...(d.git && d.git.staged + d.git.modified ? ['diff'] : []), ...(near ? ['usage'] : [])]
108}
109
110// git status --porcelain=v2 --branch 한 번으로 브랜치·스테이징·수정·미추적·ahead/behind를 모두 읽는다.
111export function parseGit(out: string): Git | undefined {
112  const g: Git = { branch: '', staged: 0, modified: 0, untracked: 0, ahead: 0, behind: 0 }
113  let oid = ''
114  for (const l of out.split('\n')) {
115    if (l.startsWith('# branch.head ')) g.branch = l.slice(14)
116    else if (l.startsWith('# branch.oid ')) oid = l.slice(13)
117    else if (l.startsWith('# branch.ab ')) {
118      const m = /\+(\d+) -(\d+)/.exec(l)
119      if (m) { g.ahead = Number(m[1]); g.behind = Number(m[2]) }
120    } else if (/^[12u] /.test(l)) {
121      if (l[2] !== '.') g.staged++
122      if (l[3] !== '.') g.modified++
123    } else if (l.startsWith('? ')) g.untracked++
124  }
125  if (!g.branch) return undefined
126  if (g.branch === '(detached)') g.branch = oid.slice(0, 7)
127  return g
128}
129
130const hhmm = (iso?: string) => {
131  if (!iso) return undefined
132  const t = new Date(iso)
133  return isNaN(+t) ? undefined : `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`
134}
135
136// 모델별 주간 창: 상태 줄 입력에 없어서 Claude Code가 ~/.claude.json 에 캐시해 둔 값을 읽는다.
137// 다른 계정 캐시거나 24시간 넘게 갱신이 없으면 버린다(스크립트와 같은 규칙).
138async function readScoped($: EngineInterface) {
139  const home = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
140  try {
141    const j = JSON.parse((await $.fs.read(`${home}/.claude.json`)) as string)
142    const cu = j.cachedUsageUtilization, uuid = j.oauthAccount?.accountUuid ?? ''
143    if (!cu || (cu.accountUuid ?? uuid) !== uuid || (await $.clock.now()) -(cu.fetchedAtMs ?? 0) >= 864e5) return []
144    return (cu.utilization?.limits ?? [])
145      .filter((r: any) => r?.kind === 'weekly_scoped' && typeof r.scope?.model?.display_name === 'string' && typeof r.percent === 'number')
146      .map((r: any) => ({ name: r.scope.model.display_name as string, pct: r.percent as number }))
147  } catch {
148    return []
149  }
150}
151
152// 프롬프트 캐시 적중률 = 이 대화의 메인 스레드 요청 누적 cache_read / 전체 입력 토큰.
153// 리로드 뒤에도 남게 state 에 두고, 대화가 바뀌면(/clear 는 session.start 없이 세션 id만 바뀐다) 0부터 센다.
154// 새 프로세스(resume 포함)에서는 state 가 비어 있어 0부터 센다.
155const cacheAcc = atom({ plugin: 'usage-line', key: 'cache' } as const, null as Cache | null)
156
157export function addUsage(prev: Cache | null, sessionId: string, u: { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }): Cache {
158  const base = prev?.sessionId === sessionId ? prev : { sessionId, read: 0, total: 0 }
159  return {
160    sessionId,
161    read: base.read + u.cache_read_input_tokens,
162    total: base.total + u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens,
163  }
164}
165
166export const hitRatio = (c: Cache | null, sessionId: string) =>
167  c?.sessionId === sessionId && c.total > 0 ? Math.round((c.read / c.total) * 100) : undefined
168
169// effort 는 모델 요청(turn.step)이 있어야 알 수 있다. 그 값은 리로드 뒤에도 남게 state 에 두고,
170// 아직 요청이 없으면 settings 의 modelSettings[모델 ID].effortLevel(없으면 effortLevel)로 채운다.
171const lastEffort = atom({ plugin: 'usage-line', key: 'effort' } as const, null as string | null)
172
173async function settingsEffort($: EngineInterface, modelId: string) {
174  const s = (await $.settings.read({})) as {
175    modelSettings?: Record<string, { effortLevel?: unknown } | undefined>
176    effortLevel?: unknown
177  }
178  const v = s.modelSettings?.[modelId]?.effortLevel ?? s.effortLevel
179  return typeof v === 'string' ? v : undefined
180}
181
182async function refresh($: EngineInterface) {
183  const cwd = await $.session.cwd()
184  const [usage, model, git, scoped] = await Promise.all([
185    $.session.usage(),
186    $.session.model(),
187    $.process
188      .run(['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch', '-uall'], { cwd })
189      .then(r => (r.exitCode === 0 ? parseGit(r.stdout) : undefined), () => undefined),
190    readScoped($),
191  ])
192  const effort = (await read($, lastEffort)) ?? (await settingsEffort($, model))
193  const cacheHit = hitRatio(await read($, cacheAcc), await $.session.id())
194  const rate = (k: string) => usage.rateLimits.find(r => r.kind === k)
195  const five = rate('five_hour'), seven = rate('seven_day')
196  const d: Line = {
197    dir: cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd,
198    git,
199    ctxPct: usage.context.percent,
200    ctxTokens: usage.context.tokens,
201    ctxWindow: usage.context.window,
202    cost: usage.cost?.usd,
203    cacheHit,
204    five: five && { pct: five.percentUsed, reset: hhmm(five.resetsAt) },
205    seven: seven?.percentUsed,
206    scoped,
207    model: modelName(model),
208    effort,
209  }
210  await update($, band, () => format(d))
211  await update($, extra, () => extraButtons(d))
212}
213
214const REFRESH_MS = 10_000
215// 버튼을 [ label ] 로 그려도 첫 줄 폴더·모델에 남겨 둘 최소 칸 수.
216const MIN_TOP = 40
217// [compact] 버튼이 /compact 뒤에 붙이는 요약 지시.
218export const COMPACT_PROMPT =
219  '현재 상황을 유지하고 이어서 작업할 수 있게 요약해 줘. 사용자의 요청 원문과 목표, 확정된 결정과 그 이유, ' +
220  '수정한 파일 경로와 핵심 변경, 실행한 검증과 결과(실패 메시지 포함), 진행 중인 작업과 남은 할 일, ' +
221  '사용자가 준 제약과 피드백은 빠짐없이 남기고, 끝난 탐색 과정과 긴 도구 출력은 결론만 남겨.'
222
223export const register: Register = on => {
224  on('session.start', async ($, e, next) => {
225    const r = await next(e)
226    await refresh($)
227    // 이벤트가 없어도(대기 중, Claude 밖의 git 변경) 10초마다 다시 읽는다. 앞선 갱신이 안 끝났으면 건너뛴다.
228    // 리로드하면 엔진이 이전 타이머를 버리므로 따로 cancel 하지 않는다.
229    let busy = false
230    $.clock.every(REFRESH_MS, () => {
231      if (busy) return
232      busy = true
233      refresh($).finally(() => (busy = false))
234    })
235    return r
236  })
237
238  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
239    const rows = await read($, band)
240    if (!rows.length) return next(e)
241    const buttons = await read($, extra)
242    // 띠에 두 줄 자리가 없으면 한 줄로 합친다.
243    const [top = [], bottom = []] = e.props.maxRows >= 2 ? rows : [join(rows, BAR)]
244    const { Box, Button, Text } = $.ui.resolve(e)
245    const draw = (segs: Seg[]) => (
246      <Text wrap="truncate-end">
247        {segs.map(g => (
248          <Text color={g.color} bold={g.bold} dimColor={g.dim}>
249            {g.text}
250          </Text>
251        ))}
252      </Text>
253    )
254    // 버튼은 첫 줄 끝에 줄지 않게(flexShrink 0) 두고, 폭이 모자라면 폴더·모델 쪽이 …로 잘린다.
255    // 버튼까지 같이 줄면 "[ status ]"가 세로로 접힌다. [ label ] 로 그리면 폴더·모델에 MIN_TOP 칸이
256    // 안 남는 좁은 폭에서는 compact 말고는 테두리 없이 흐린 라벨로 그린다(포인터·포커스 아래선 진하게).
257    // 응답 중에 누르면 응답이 끝난 뒤 열린다.
258    const run = (command: string, args?: string) =>
259      $.command.run({ command, args }).catch(err => $.ui.toast(`/${command} 실행 실패: ${err}`))
260    const labels = ['status', 'context', ...buttons]
261    const framed = [...labels, 'compact'].reduce((n, l) => n + l.length + 5, 1)
262    const slim = e.props.bodyColumns - framed < MIN_TOP ? ({ plain: true, dimColor: true } as const) : {}
263    return (
264      // 위 대화와 붙어 보이지 않게 한 줄 띄운다. 자리가 모자라면 띄우지 않는다.
265      <Box flexDirection="column" marginTop={e.props.maxRows > (bottom.length ? 2 : 1) ? 1 : 0}>
266        <Box>
267          <Box flexShrink={1} minWidth={0}>{draw(top)}</Box>
268          <Box flexShrink={0}>
269            <Text> </Text>
270            {labels.map(c => (
271              <Box key={c} paddingLeft={1}>
272                <Button key={c} label={c} {...slim} onPress={() => void run(c)} />
273              </Box>
274            ))}
275            <Text> </Text>
276            <Button key="compact" label="compact" onPress={() => void run('compact', COMPACT_PROMPT)} />
277          </Box>
278        </Box>
279        {bottom.length ? <Box paddingLeft={2}>{draw(bottom)}</Box> : null}
280      </Box>
281    )
282  })
283
284  // 메인 스레드 요청만 본다: effort 와 프롬프트 캐시 누적값.
285  on('turn.step', async function* ($, e, next) {
286    const r = yield* next(e)
287    if (e.agentId === undefined) {
288      if (typeof e.effort === 'string') await update($, lastEffort, () => e.effort as string)
289      if (r.usage) {
290        const usage = r.usage, id = await $.session.id()
291        await update($, cacheAcc, c => addUsage(c, id, usage))
292      }
293    }
294    return r
295  })
296
297  on('session.measure', async ($, e, next) => {
298    const r = await next(e)
299    await refresh($)
300    return r
301  })
302
303  // 턴 중 파일이 바뀌면 git 구간을 갱신한다.
304  on('turn.complete', async ($, e, next) => {
305    const r = await next(e)
306    if (e.agentId === undefined) await refresh($)
307    return r
308  })
309}
310
types/index.d.ts 9 lines
1export type Seg = { text: string; color?: string; bold?: boolean; dim?: boolean }
2export type Cache = { sessionId: string; read: number; total: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'usage-line': { rows: Seg[][]; buttons: string[]; effort: string | null; cache: Cache | null }
7  }
8}
9