프롬프트 아래 상태 줄: 모델·effort, 컨텍스트와 사용 한도(5시간·주간, Fable 모델이면 Fable 주간 한도)의 사용률을 남은 비율에 따라 색이 바뀌는 프로그레스 바로 표시하고, git 줄(프로젝트·브랜치·워크트리·변경·upstream)을 덧붙임

개인용 Claude Code 플러그인 마켓플레이스.
Claude Code 프롬프트에서:
/plugin install statusbar --marketplace DevooKim/my-claude
/plugin install prompt-band --marketplace DevooKim/my-claude
마켓플레이스 추가 질문에는 y, 범위는 user를 고르면 됩니다.
프롬프트 입력창 아래 힌트 줄 밑에 git 줄과 사용량 줄을 그립니다.
my-claude/fix-bar · worktree-fix-bar · !3+2?1⇡1 · PR #12
✻ Opus 5.5 ●●●○○ high · C ━━━━━━━─── 71% 715k/1M · S ━───────── 13% ↻3h35m W ━───────── 4% ↻5d
git 줄은 starship 프롬프트와 같은 기호와 색으로 그려 셸 프롬프트와 똑같이 보입니다.
사용량 줄
/effort 선택 창과 같은 색, xhigh는 반짝임, max는 흐르는 무지개!)git 줄 (저장소 안에서만)
프로젝트[/워크트리] · 브랜치 · 상태를 ·로 나눠 이어 씀. 경로는 굵은 하늘색(#57C7FF), 브랜치는 굵은 회색, 상태는 노랑git_status와 같음): = 충돌, $ 스태시, × 삭제, » 이름 바꿈, ! 수정, + 스테이징, ? 추적 안 함, ⇡ 앞섬, ⇣ 뒤처짐. 깨끗하고 upstream과 같으면 아무것도 없음(커밋), 리베이스·병합·체리픽·revert·bisect 중이면 브랜치 뒤에 (REBASING)처럼 표시gh 로그인 필요): 누르면 기본 브라우저로 PR 페이지가 열림(전체 화면 모드에서 클릭). 색이 상태: draft 회색, 머지할 수 있지만 승인 전(리뷰 전·CI 진행 중 포함) 주황, 승인까지 끝나 머지 가능 초록, 충돌·CI 실패·변경 요청으로 머지 불가 빨강, 병합됨 보라. Claude Code의 PR 표시와 겹치면 /config의 Show PR status footer를 끄면 됨서브에이전트와 할 일(Task)은 Claude Code가 직접 보여 주므로 따로 그리지 않습니다.
테스트: claude plugin test plugins/statusbar
프롬프트 입력창 바로 위 띠(AbovePrompt)의 오른쪽 끝, 엔진의 접기 버튼 [-] 옆에 세션 지표를 그립니다.
Cache ◕ 93% 160k/172k [-]
읽은 양/전체. 서브에이전트 응답은 세지 않음○ ◔ ◑ ◕ ●, 꽉 찬 원은 100%만), 숫자는 1% 단위next(e)를 품어야 함)테스트: claude plugin test plugins/prompt-band
hooks/register.tsx 1303 lines1import { atom, read, update } from 'claude-code'
2import type {
3 ApiContentBlock,
4 EngineInterface,
5 HttpResponse,
6 Register,
7 SessionContextUsage,
8 SessionRateLimit,
9 SessionUsage,
10 Timer,
11 ToolCallInput,
12 ToolCallResult,
13 TurnStepInput,
14} from 'claude-code'
15
16import type {
17 StatusbarEffort,
18 StatusbarFable,
19 StatusbarGit,
20 StatusbarGitOperation,
21 StatusbarPullRequest,
22 StatusbarSnapshot,
23 StatusbarWindow,
24} from '../types'
25
26// 5시간·주간 한도와 컨텍스트는 엔진이 마지막 응답에서 읽은 값($.session.usage)을 쓴다.
27// Fable 주간 한도는 플러그인에 넘어오지 않으므로 /usage가 읽는 claude.ai 사용량
28// 엔드포인트의 limits[]에서 가져온다(Fable 모델일 때만).
29const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
30const OAUTH_BETA = 'oauth-2025-04-20'
31const TICK_MS = 60_000
32const FETCH_FLOOR_MS = 60_000
33const FABLE_ACTIVE_REFRESH_MS = 5 * 60_000
34const FABLE_IDLE_REFRESH_MS = 30 * 60_000
35const BACKOFF_MS = 5 * 60_000
36const BACKOFF_CAP_MS = 60 * 60_000
37const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']
38// git은 파일을 바꾸는 도구가 끝날 때마다, 그리고 바깥(다른 터미널)에서 바꾼 것도 잡도록 주기적으로 읽는다.
39const GIT_TICK_MS = 30_000
40const GIT_TIMEOUT_MS = 10_000
41// PR 상태는 브랜치가 바뀌거나 푸시·PR 명령이 돌면 곧바로, 아니면 3분마다 gh로 다시 묻는다. GitHub가
42// 머지 가능 여부를 아직 계산하지 못했으면(UNKNOWN) 다음 git 확인(30초 주기) 때 다시 묻는다.
43const PR_REFRESH_MS = 3 * 60_000
44const PR_UNKNOWN_RETRY_MS = 20_000
45const PR_TIMEOUT_MS = 15_000
46// --head는 브랜치 이름만 맞춰 남의 포크에서 온 같은 이름의 PR도 준다: 넉넉히 받아 소유자로 거른다.
47const PR_LIST_LIMIT = 10
48const PR_FIELDS = 'number,url,state,isDraft,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,headRepositoryOwner'
49const PR_COMMAND = /\b(?:gh\s+pr|git\s+push)\b/
50// 끝나면 git 상태를 다시 읽는 도구.
51const WATCHED_TOOLS = [
52 'Bash',
53 'Edit',
54 'Write',
55 'NotebookEdit',
56 'EnterWorktree',
57 'ExitWorktree',
58] as const
59// git 디렉터리에 남는 표시 파일로 진행 중인 작업을 알아본다.
60const GIT_OPERATIONS: readonly (readonly [string, StatusbarGitOperation])[] = [
61 ['rebase-merge', 'rebasing'],
62 ['rebase-apply', 'rebasing'],
63 ['MERGE_HEAD', 'merging'],
64 ['CHERRY_PICK_HEAD', 'cherry-picking'],
65 ['REVERT_HEAD', 'reverting'],
66 ['BISECT_LOG', 'bisecting'],
67]
68
69// 글자와 장식은 사용자의 테마(라이트·다크)를 따르는 테마 키로 쓴다.
70const BRAND = 'claude'
71const MUTED = 'inactive'
72const FAINT = 'subtle'
73// 한 글자 라벨이 서로 구분되도록 각자 테마 색을 입힌다. 그 밖의 라벨은 MUTED.
74const LABEL_COLORS: Readonly<Record<string, string>> = {
75 C: 'claude',
76 S: 'suggestion',
77 W: 'planMode',
78}
79// 바의 색은 남은 비율의 단계: 라이트·다크 양쪽에서 검증된 고정 상태 색이다.
80const STATUS_COLORS = {
81 good: '#0ca30c',
82 warning: '#fab219',
83 serious: '#ec835a',
84 critical: '#d03b3b',
85} as const
86// 채운 칸과 빈 칸은 굵기가 달라서 색이 안 보이는 터미널에서도 양이 읽힌다.
87const FILLED = '━'
88const TRACK = '─'
89// effort 5단계(low…max)를 채워진 원과 빈 원으로 보인다.
90const EFFORT_ON = '●'
91const EFFORT_OFF = '○'
92// /effort 선택 창과 같은 색: high까지는 단색, xhigh는 반짝임, max는 흐르는 무지개.
93const EFFORT_COLORS: Readonly<Record<string, string>> = {
94 low: 'warning',
95 medium: 'success',
96 high: 'permission',
97}
98const SHIMMER_BASE = 'autoAccept'
99const SHIMMER_HIGHLIGHT = '#d0b4ff'
100const RAINBOW = [
101 'rainbow_red',
102 'rainbow_orange',
103 'rainbow_yellow',
104 'rainbow_green',
105 'rainbow_blue',
106 'rainbow_indigo',
107 'rainbow_violet',
108]
109const FRAME_MS = 100
110// 힌트 줄은 화면 너비만 알려 주므로 좌우 여백을 빼고 배치한다.
111const FOOTER_INSET = 4
112const FALLBACK_COLUMNS = 120
113const MIN_COLUMNS = 20
114// 엔진의 힌트 줄과 상태 줄 사이의 빈 줄 수.
115const ROW_GAP = 0
116// git 줄과 사용량 줄 사이에 띄우는 줄 수.
117const GIT_GAP = 0
118// git 줄은 셸 프롬프트(starship)와 같은 모양·색으로 그린다: 경로는 굵은 하늘색, 브랜치는 굵은
119// 회색, 상태는 노란색으로 붙여 쓴다(=충돌 $스태시 ×삭제 »이름 바꿈 !수정 +스테이징 ?추적 안 함
120// ⇡앞섬 ⇣뒤처짐). yellow·gray·green은 터미널 팔레트의 그 색이다.
121const PATH_COLOR = '#57C7FF'
122const BRANCH_COLOR = 'gray'
123const COMMIT_COLOR = 'green'
124const GIT_STATUS_COLOR = 'yellow'
125const MIN_BRANCH_COLUMNS = 12
126// PR 번호의 색이 곧 상태다: draft 회색, 머지할 수 있지만 승인 전이면 주황, 승인까지 끝났으면 초록,
127// 충돌·CI 실패·변경 요청으로 머지할 수 없으면 빨강. 병합된 PR은 보라, 닫힌 PR은 흐리게.
128export function pullRequestColor(pr: StatusbarPullRequest): string {
129 if (pr.state === 'merged') return 'merged'
130 if (pr.state === 'closed') return FAINT
131 if (pr.state === 'draft') return MUTED
132 if (pr.merge === 'conflict' || pr.checks === 'fail' || pr.review === 'changes') return STATUS_COLORS.critical
133 if (pr.review === 'approved' && pr.merge === 'clean' && pr.checks !== 'pending') return STATUS_COLORS.good
134
135 return STATUS_COLORS.serious
136}
137
138const snapshotAtom = atom({ plugin: 'statusbar', key: 'snapshot' } as const, null, {
139 shape: 'v1',
140})
141
142type Seed = { fiveHour?: StatusbarWindow; sevenDay?: StatusbarWindow }
143type Json = Record<string, unknown>
144
145type State = {
146 effort: StatusbarEffort
147 fable: StatusbarFable
148 seed: Seed
149 hasPlan: boolean
150 isLive: boolean
151 isUsedSinceFetch: boolean
152 isFableRequested: boolean
153 isFetching: boolean
154 lastAttemptAt: number
155 lastFetchedAt: number
156 retryAt: number
157 failures: number
158 published: string | undefined
159 ticker: Timer | undefined
160 animator: Timer | undefined
161 isReducedMotion: boolean
162 queue: Promise<void>
163 isQueued: boolean
164 git: StatusbarGit | undefined
165 gitTicker: Timer | undefined
166 // 지금 브랜치의 PR을 찾는 열쇠(저장소 뿌리·origin 소유자·브랜치). 원격이 없거나 HEAD가 떨어지면 없다.
167 prKey: string | undefined
168 pr: { key: string; value?: StatusbarPullRequest; isMergeUnknown: boolean; fetchedAt: number } | undefined
169 isPrStale: boolean
170 isPrFetching: boolean
171 prRetryAt: number
172 prFailures: number
173 gitQueue: Promise<void>
174 isGitQueued: boolean
175}
176
177type GitReading = { root: string; remote: string | null; git: StatusbarGit }
178// PR을 찾을 브랜치와, 그 브랜치를 올린 저장소(origin)의 소유자.
179type PullRequestTarget = { key: string; branch: string; owner: string }
180
181// 애니메이션의 현재 칸. 움직임을 줄인 설정이면 undefined로 멈춘 모습을 그린다.
182type Frame = number | undefined
183
184export type SpanStyle = { color?: string; dimColor?: boolean; bold?: boolean; underline?: boolean }
185// href가 있으면 그 글자들을 누르면 그 주소가 열리는 버튼으로 그린다.
186export type Span = { text: string; style?: SpanStyle; href?: string }
187
188type Variant = { rows: 'one' | 'two' | 'stack'; bar: number; extras: boolean }
189type ResetStyle = 'minutes' | 'hours'
190
191// 넓은 것부터 시도해 줄 너비에 맞는 첫 배치를 쓴다: 한 줄 → 두 줄 → 항목별 줄.
192const VARIANTS: readonly Variant[] = [
193 { rows: 'one', bar: 10, extras: true },
194 { rows: 'one', bar: 8, extras: true },
195 { rows: 'one', bar: 6, extras: true },
196 { rows: 'two', bar: 10, extras: true },
197 { rows: 'two', bar: 8, extras: true },
198 { rows: 'two', bar: 6, extras: true },
199 { rows: 'two', bar: 6, extras: false },
200 { rows: 'stack', bar: 8, extras: true },
201 { rows: 'stack', bar: 5, extras: false },
202]
203
204export const register: Register = on => {
205 const state: State = {
206 effort: { kind: 'unknown' },
207 fable: { kind: 'unknown' },
208 seed: {},
209 hasPlan: true,
210 isLive: true,
211 isUsedSinceFetch: false,
212 isFableRequested: false,
213 isFetching: false,
214 lastAttemptAt: Number.NEGATIVE_INFINITY,
215 lastFetchedAt: Number.NEGATIVE_INFINITY,
216 retryAt: 0,
217 failures: 0,
218 published: undefined,
219 ticker: undefined,
220 animator: undefined,
221 isReducedMotion: false,
222 queue: Promise.resolve(),
223 isQueued: false,
224 git: undefined,
225 gitTicker: undefined,
226 prKey: undefined,
227 pr: undefined,
228 isPrStale: false,
229 isPrFetching: false,
230 prRetryAt: 0,
231 prFailures: 0,
232 gitQueue: Promise.resolve(),
233 isGitQueued: false,
234 }
235
236 on('session.start', async ($, e, next) => {
237 state.isLive = e.isInteractive
238 if (state.isLive) {
239 // 0.1.0이 고정해 둔 한 줄짜리 상태줄을 걷어 낸다.
240 $.ui.status(undefined)
241 await restore($, state)
242 if (state.effort.kind === 'unknown') state.effort = await configuredEffort($)
243 state.isReducedMotion = await prefersReducedMotion($)
244 startLive($, state)
245 }
246
247 return next(e)
248 })
249
250 // 도구의 결과는 손대지 않고 그대로 돌려준다: 파일을 바꾼 도구가 끝나면 git 상태를 다시 읽는다.
251 on('tool.call', { tool: WATCHED_TOOLS }, async ($, e, next) => {
252 const result = await next(e)
253 if (state.isLive) noteTool($, state, e, result)
254
255 return result
256 }).catch(($, e, next) => next(e))
257
258 on('session.measure', ($, e, next) => {
259 state.isUsedSinceFetch = true
260 schedule($, state)
261
262 return next(e)
263 }).catch(($, e, next) => next(e))
264
265 // 메인 스레드의 모델 요청마다 실제로 보내는 effort를 읽는다. 응답 스트림은 손대지 않고
266 // 그대로 넘기며, 이 앞에서는 아무것도 기다리지 않는다.
267 on('turn.step', async function* ($, e, next) {
268 if (e.agentId === undefined) noteStepEffort($, state, e.effort)
269
270 return yield* next(e)
271 })
272
273 // /effort·/model(선택 창 포함)의 결과 줄은 대화에 명령 출력으로 쌓인다: 다음 요청 전에
274 // 바로 반영한다. 사용자 플러그인의 classic.PostModelSwitch는 기본 보안 플러그인이
275 // 건너뛰므로 모델 전환도 이 줄로 알아챈다.
276 on('session.append', { door: ['command', 'notice'] }, ($, e, next) => {
277 const text = textOfBlocks(e.message.content)
278 const level = effortFrom(text)
279 if (isModelSwitch(text)) {
280 $.clock.after(0, () => void switchModel($, state, undefined, level))
281 } else if (level !== undefined) {
282 noteEffort($, state, { kind: 'level', level })
283 }
284
285 return next(e)
286 }).catch(($, e, next) => next(e))
287
288 on('classic.PostModelSwitch', async ($, e, next) => {
289 await switchModel($, state, e.to_model)
290
291 return next(e)
292 }).catch(($, e, next) => next(e))
293
294 // 프롬프트 아래 힌트 줄. 엔진의 그림(next)을 그대로 품으면 엔진은 자기 줄(모드·
295 // 단축키 힌트·필)을 살려 두고, 이 트리는 그 아래 줄에 그린다.
296 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
297 const own = await next(e)
298 const snapshot = await read($, snapshotAtom)
299 if (snapshot === null) {
300 // /clear는 새 세션 id로 넘어가며 세션의 $.state를 비우지만 session.start는 다시
301 // 부르지 않고, 사용자 플러그인의 classic.SessionStart는 기본 보안 플러그인이 건너뛴다.
302 // 그래서 이미 쓴 스냅숏이 비어 읽히면 그리기 밖(타이머)에서 다시 세운다.
303 if (state.isLive && state.published !== undefined) {
304 state.published = undefined
305 $.clock.after(0, () => startLive($, state))
306 }
307
308 return own
309 }
310 const { Box, Text, Button } = $.ui.resolve(e)
311 const columns = Math.max(MIN_COLUMNS, (e.viewport?.columns ?? FALLBACK_COLUMNS) - FOOTER_INSET)
312 const now = await $.clock.now()
313 const frame = state.isReducedMotion ? undefined : Math.floor(now / FRAME_MS)
314 const draw = (part: Span) =>
315 part.style === undefined ? part.text : <Text {...part.style}>{part.text}</Text>
316 // href가 있는 조각은 누르면 그 주소를 브라우저로 여는 버튼이다. 링크(OSC 8)는 터미널에 따라
317 // 주소를 글자로 덧붙이므로 쓰지 않는다. 버튼은 Text 안에 못 들어가 그런 줄은 가로 Box로 그린다.
318 const drawRow = (row: Span[]) => {
319 const runs = linkRuns(row)
320 if (runs.every(run => run.href === undefined)) return <Text wrap="truncate-end">{row.map(draw)}</Text>
321
322 return (
323 <Box flexDirection="row">
324 {runs.map(run => {
325 const { href } = run
326 if (href === undefined) return <Text>{run.parts.map(draw)}</Text>
327
328 return (
329 <Button plain key={`open:${href}`} onPress={() => void openPage($, href)}>
330 {run.parts.map(draw)}
331 </Button>
332 )
333 })}
334 </Box>
335 )
336 }
337
338 // 엔진의 힌트 줄 아래 ROW_GAP 줄을 띄우고 상태 줄을 그린다.
339 return (
340 <Box flexDirection="column">
341 {own}
342 <Box flexDirection="column" marginTop={ROW_GAP}>
343 {gitRows(snapshot.git, columns).map(drawRow)}
344 <Box flexDirection="column" marginTop={snapshot.git ? GIT_GAP : 0}>
345 {usageRows(snapshot, columns, frame).map(drawRow)}
346 </Box>
347 </Box>
348 </Box>
349 )
350 })
351}
352
353// 주소를 기본 브라우저로 연다: macOS는 open, 그 밖은 xdg-open.
354async function openPage($: EngineInterface, url: string): Promise<void> {
355 for (const opener of ['open', 'xdg-open']) {
356 try {
357 const { exitCode } = await $.process.run([opener, url])
358 if (exitCode === 0) return
359 } catch {
360 // 다음 방법으로 넘어간다.
361 }
362 }
363 $.ui.log(`statusbar: could not open ${url}`, { to: 'debug' })
364}
365
366// 이어진 조각을 href별로 묶는다: 같은 href끼리, href 없는 것끼리.
367function linkRuns(row: readonly Span[]): { href?: string; parts: Span[] }[] {
368 const runs: { href?: string; parts: Span[] }[] = []
369 for (const part of row) {
370 const last = runs.at(-1)
371 if (last !== undefined && last.href === part.href) last.parts.push(part)
372 else runs.push(part.href === undefined ? { parts: [part] } : { href: part.href, parts: [part] })
373 }
374
375 return runs
376}
377
378async function restore($: EngineInterface, state: State): Promise<void> {
379 // 다시 불러와도 effort와 Fable 값을 잃지 않도록 마지막 스냅숏에서 되살린다.
380 const previous = await read($, snapshotAtom)
381 if (previous === null) return
382 state.effort = previous.effort
383 if (previous.fable !== undefined) state.fable = previous.fable
384 if (previous.fableFetchedAt !== undefined) state.lastFetchedAt = previous.fableFetchedAt
385 if (previous.git !== undefined) state.git = previous.git
386}
387
388// 타이머를 새로 걸고 스냅숏을 다시 쓴다. 몇 번 불러도 같은 모습이 된다.
389function startLive($: EngineInterface, state: State): void {
390 state.ticker?.cancel()
391 state.ticker = $.clock.every(TICK_MS, () => void refresh($, state))
392 state.gitTicker?.cancel()
393 state.gitTicker = $.clock.every(GIT_TICK_MS, () => void refreshGit($, state))
394 state.animator?.cancel()
395 state.animator = undefined
396 syncAnimation($, state)
397 state.published = undefined
398 schedule($, state)
399 $.clock.after(0, () => void refreshGit($, state))
400}
401
402// 첫 요청 전에는 보낼 effort를 알 길이 없으므로 엔진이 읽는 설정에서 미리 짐작한다:
403// 환경 변수 → 모델별 설정(modelSettings) → effortLevel. 첫 요청이 실제 값으로 고친다.
404async function configuredEffort($: EngineInterface, model?: string): Promise<StatusbarEffort> {
405 try {
406 const [fromEnv, settings, current] = await Promise.all([
407 $.env.get('CLAUDE_CODE_EFFORT_LEVEL'),
408 $.settings.read(),
409 model === undefined ? $.session.model() : Promise.resolve(model),
410 ])
411 const modelSettings = isRecord(settings.modelSettings) ? settings.modelSettings[current] : undefined
412 const perModel = isRecord(modelSettings) ? modelSettings.effortLevel : undefined
413 const level = [fromEnv, perModel, settings.effortLevel].find(
414 (value): value is string => typeof value === 'string' && EFFORT_LEVELS.includes(value),
415 )
416
417 return level === undefined ? { kind: 'unknown' } : { kind: 'level', level }
418 } catch (error) {
419 $.ui.log(`statusbar: effort setting not read: ${describe(error)}`, { to: 'debug' })
420
421 return { kind: 'unknown' }
422 }
423}
424
425// 바뀐 모델의 effort는 설정에서 다시 짐작한다. 같은 줄이 effort도 정했으면 그 값을 쓴다.
426async function switchModel(
427 $: EngineInterface,
428 state: State,
429 model?: string,
430 level?: string,
431): Promise<void> {
432 try {
433 const current = model ?? (await $.session.model())
434 state.effort = level === undefined ? await configuredEffort($, current) : { kind: 'level', level }
435 if (isFable(current)) state.isFableRequested = true
436 syncAnimation($, state)
437 schedule($, state)
438 } catch (error) {
439 $.ui.log(`statusbar: model switch not noted: ${describe(error)}`, { to: 'debug' })
440 }
441}
442
443function noteStepEffort($: EngineInterface, state: State, effort: TurnStepInput['effort']): void {
444 try {
445 noteEffort($, state, effort === undefined ? { kind: 'none' } : { kind: 'level', level: String(effort) })
446 } catch (error) {
447 // 상태 줄의 실패가 모델 응답 스트림에 번지지 않게 여기서 끊는다.
448 $.ui.log(`statusbar: effort not noted: ${describe(error)}`, { to: 'debug' })
449 }
450}
451
452function noteEffort($: EngineInterface, state: State, effort: StatusbarEffort): void {
453 if (JSON.stringify(effort) === JSON.stringify(state.effort)) return
454 state.effort = effort
455 syncAnimation($, state)
456 schedule($, state)
457}
458
459// xhigh·max일 때만 100ms마다 힌트 줄을 다시 그려 반짝임과 무지개를 흐르게 한다.
460function syncAnimation($: EngineInterface, state: State): void {
461 const { effort } = state
462 const isAnimated = effort.kind === 'level' && (effort.level === 'xhigh' || effort.level === 'max')
463 const shouldRun = state.isLive && !state.isReducedMotion && isAnimated
464 if (shouldRun && state.animator === undefined) {
465 state.animator = $.clock.every(FRAME_MS, () => $.ui.invalidate('ui.render'))
466 }
467 if (!shouldRun && state.animator !== undefined) {
468 state.animator.cancel()
469 state.animator = undefined
470 }
471}
472
473async function prefersReducedMotion($: EngineInterface): Promise<boolean> {
474 try {
475 const settings: Record<string, unknown> = await $.settings.read()
476
477 return settings.prefersReducedMotion === true
478 } catch {
479 return false
480 }
481}
482
483function schedule($: EngineInterface, state: State): void {
484 if (state.isLive) $.clock.after(0, () => void refresh($, state))
485}
486
487function refresh($: EngineInterface, state: State): Promise<void> {
488 if (state.isQueued) return state.queue
489 state.isQueued = true
490 state.queue = state.queue.then(() => {
491 state.isQueued = false
492
493 return runRefresh($, state)
494 })
495
496 return state.queue
497}
498
499async function runRefresh($: EngineInterface, state: State): Promise<void> {
500 try {
501 const { model, usage, now } = await publish($, state)
502 if (isFetchDue(state, model, usage, now)) {
503 void fetchPlanUsage($, state, now).then(() => refresh($, state))
504 }
505 } catch (error) {
506 $.ui.log(`statusbar: refresh failed: ${describe(error)}`, { to: 'debug' })
507 }
508}
509
510async function publish($: EngineInterface, state: State) {
511 const [model, usage, now, current] = await Promise.all([
512 $.session.model(),
513 $.session.usage(),
514 $.clock.now(),
515 read($, snapshotAtom),
516 ])
517 const snapshot = snapshotOf(state, model, usage, now)
518 // 카운트다운은 분 단위라 같은 분 안에서는 다시 쓰지 않는다. 다만 /clear로 스냅숏이
519 // 비었으면 같은 값이라도 다시 쓴다(신호보다 비우기가 늦어도 다음 갱신이 되살린다).
520 const key = JSON.stringify({ ...snapshot, now: Math.floor(now / 60_000) })
521 if (key !== state.published || current === null) {
522 state.published = key
523 await update($, snapshotAtom, () => snapshot)
524 }
525
526 return { model, usage, now }
527}
528
529function snapshotOf(
530 state: State,
531 model: string,
532 usage: SessionUsage,
533 now: number,
534): StatusbarSnapshot {
535 const fiveHour = pickWindow(usage.rateLimits, 'five_hour') ?? state.seed.fiveHour
536 const sevenDay = pickWindow(usage.rateLimits, 'seven_day') ?? state.seed.sevenDay
537
538 const git = gitOf(state)
539
540 return {
541 model,
542 effort: state.effort,
543 context: contextOf(usage.context),
544 now,
545 ...(fiveHour ? { fiveHour } : {}),
546 ...(sevenDay ? { sevenDay } : {}),
547 ...(state.hasPlan ? { fable: state.fable } : {}),
548 ...(Number.isFinite(state.lastFetchedAt) ? { fableFetchedAt: state.lastFetchedAt } : {}),
549 ...(git ? { git } : {}),
550 }
551}
552
553function isFetchDue(state: State, model: string, usage: SessionUsage, now: number): boolean {
554 if (!state.hasPlan || state.isFetching || now < state.retryAt) return false
555 if (now - state.lastAttemptAt < FETCH_FLOOR_MS) return false
556 // 새 세션은 첫 응답 전까지 헤더 값이 없으니 한 번 채워 둔다.
557 const hasNoReading = state.lastFetchedAt === Number.NEGATIVE_INFINITY
558 if (!isFable(model)) return usage.rateLimits.length === 0 && hasNoReading
559 if (state.isFableRequested || state.fable.kind === 'unknown') return true
560 const { fable } = state
561 const resetsAt = fable.kind === 'reading' ? Date.parse(fable.window.resetsAt ?? '') : Number.NaN
562 if (now >= resetsAt && state.lastFetchedAt < resetsAt) return true
563 const age = now - state.lastFetchedAt
564
565 return (state.isUsedSinceFetch && age >= FABLE_ACTIVE_REFRESH_MS) || age >= FABLE_IDLE_REFRESH_MS
566}
567
568async function fetchPlanUsage($: EngineInterface, state: State, now: number): Promise<void> {
569 state.isFetching = true
570 state.isFableRequested = false
571 state.lastAttemptAt = now
572 try {
573 // 핸들은 발급 시점의 토큰을 담으므로 토큰이 갱신된 뒤의 401을 피하려고 매번 받는다.
574 const auth = await $.session.authorize()
575 if (auth === null || auth.kind !== 'bearer') {
576 state.hasPlan = false
577 $.ui.log('statusbar: no claude.ai login, so no plan limits to read', { to: 'debug' })
578 return
579 }
580 const response = await $.http.fetch(USAGE_URL, {
581 headers: { 'anthropic-beta': OAUTH_BETA },
582 auth: auth.handle,
583 })
584 if (!response.ok) {
585 state.failures += 1
586 state.retryAt = now + backoffAfter(response, state.failures)
587 $.ui.log(`statusbar: usage answered ${response.status}, next try at ${state.retryAt}`, {
588 to: 'debug',
589 })
590 return
591 }
592 const body: unknown = JSON.parse(response.text)
593 if (!isRecord(body)) throw new Error('the usage answer is not an object')
594 state.seed = seedOf(body)
595 state.fable = fableOf(body.limits)
596 state.lastFetchedAt = now
597 state.isUsedSinceFetch = false
598 state.failures = 0
599 state.retryAt = 0
600 $.ui.log(`statusbar: usage ${summarize(body)}`, { to: 'debug' })
601 } catch (error) {
602 state.failures += 1
603 state.retryAt = now + Math.min(BACKOFF_MS * 2 ** (state.failures - 1), BACKOFF_CAP_MS)
604 $.ui.log(`statusbar: usage fetch failed: ${describe(error)}`, { to: 'debug' })
605 } finally {
606 state.isFetching = false
607 }
608}
609
610function refreshGit($: EngineInterface, state: State): Promise<void> {
611 if (!state.isLive) return Promise.resolve()
612 if (state.isGitQueued) return state.gitQueue
613 state.isGitQueued = true
614 state.gitQueue = state.gitQueue.then(() => {
615 state.isGitQueued = false
616
617 return runGitRefresh($, state)
618 })
619
620 return state.gitQueue
621}
622
623// 지금 브랜치의 PR을 찾았으면 git 값에 붙인다.
624function gitOf(state: State): StatusbarGit | undefined {
625 const { git, pr, prKey } = state
626 if (git === undefined || prKey === undefined || pr?.key !== prKey || pr.value === undefined) return git
627
628 return { ...git, pr: pr.value }
629}
630
631async function runGitRefresh($: EngineInterface, state: State): Promise<void> {
632 try {
633 const [reading, now] = await Promise.all([readGit($), $.clock.now()])
634 const target = pullRequestTargetOf(reading)
635 // 다른 터미널에서 push·pull해도 upstream과의 차이(⇡⇣)가 바뀌므로 PR 상태를 곧바로 다시 묻는다.
636 const previous = state.git
637 if (previous !== undefined && JSON.stringify(previous.upstream) !== JSON.stringify(reading?.git.upstream)) {
638 state.isPrStale = true
639 state.prRetryAt = 0
640 }
641 state.git = reading?.git
642 state.prKey = target?.key
643 if (target !== undefined && isPullRequestDue(state, now)) {
644 void fetchPullRequest($, state, target, now).then(() => schedule($, state))
645 }
646 schedule($, state)
647 } catch (error) {
648 // 잠깐의 실패(시간 초과 등)로 줄이 깜빡이지 않도록 마지막 값을 둔다.
649 $.ui.log(`statusbar: git status not read: ${describe(error)}`, { to: 'debug' })
650 }
651}
652
653// 저장소 밖이거나 git이 상태를 못 읽으면 undefined: git 줄을 그리지 않는다. 다른 git 명령과
654// index.lock을 다투지 않도록 선택적 잠금을 끄고 읽는다.
655async function readGit($: EngineInterface): Promise<GitReading | undefined> {
656 const repo = await $.session.repo()
657 if (repo === null) return undefined
658 const [status, paths] = await Promise.all([
659 readStatus($),
660 runGit($, ['rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir', '--show-toplevel']).catch(
661 () => undefined,
662 ),
663 ])
664 if (status === undefined) return undefined
665 const [gitDir = '', commonDir = '', top = ''] = (paths ?? '').split('\n')
666 // 연결된 워크트리는 자기 git 디렉터리가 공용 디렉터리와 다르다.
667 const isWorktree = gitDir !== '' && commonDir !== '' && top !== '' && gitDir !== commonDir
668 const operation = gitDir === '' ? undefined : await operationIn($, gitDir)
669
670 return {
671 root: repo.root,
672 remote: repo.remote,
673 git: {
674 project: baseName(repo.root),
675 ...(isWorktree ? { worktree: baseName(top) } : {}),
676 ...(operation !== undefined ? { operation } : {}),
677 ...gitStatusOf(status),
678 },
679 }
680}
681
682// 원격이 있고 브랜치 위에 있을 때만 PR을 찾는다.
683function pullRequestTargetOf(reading: GitReading | undefined): PullRequestTarget | undefined {
684 const branch = reading?.git.branch
685 const owner = remoteOwner(reading?.remote ?? null)
686 if (reading === undefined || branch === undefined || owner === undefined) return undefined
687
688 return { key: `${reading.root}\n${owner}\n${branch}`, branch, owner }
689}
690
691// https://github.com/<owner>/<repo>.git, git@github.com:<owner>/<repo>.git, ssh://git@host/<owner>/<repo>.
692export function remoteOwner(remote: string | null): string | undefined {
693 const match = remote === null ? null : /[/:]([^/:]+)\/[^/:]+?(?:\.git)?\/?$/.exec(remote)
694
695 return match?.[1]
696}
697
698function isPullRequestDue(state: State, now: number): boolean {
699 const { prKey, pr } = state
700 if (prKey === undefined || state.isPrFetching || now < state.prRetryAt) return false
701 if (pr?.key !== prKey || state.isPrStale) return true
702
703 return now - pr.fetchedAt >= (pr.isMergeUnknown ? PR_UNKNOWN_RETRY_MS : PR_REFRESH_MS)
704}
705
706// 지금 브랜치를 head로 하고 그 head가 origin 소유자의 것인 PR 가운데 가장 최근 것(닫힌 것·병합된
707// 것 포함). gh가 없거나 로그인 전이거나 GitHub 저장소가 아니면 실패로 보고 물러났다가 다시 묻는다.
708async function fetchPullRequest($: EngineInterface, state: State, target: PullRequestTarget, now: number): Promise<void> {
709 state.isPrFetching = true
710 state.isPrStale = false
711 try {
712 const { exitCode, stdout, stderr } = await $.process.run(
713 ['gh', 'pr', 'list', '--head', target.branch, '--state', 'all', '--limit', String(PR_LIST_LIMIT), '--json', PR_FIELDS],
714 { timeoutMs: PR_TIMEOUT_MS, env: { GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1' } },
715 )
716 if (exitCode !== 0) throw new Error(stderr.trim().split('\n')[0] || `gh exited ${exitCode}`)
717 const row = ownRow(JSON.parse(stdout), target.owner)
718 const value = row === undefined ? undefined : pullRequestOf(row)
719 state.pr = {
720 key: target.key,
721 fetchedAt: now,
722 isMergeUnknown: row?.state === 'OPEN' && row.mergeable === 'UNKNOWN',
723 ...(value ? { value } : {}),
724 }
725 state.prFailures = 0
726 state.prRetryAt = 0
727 } catch (error) {
728 state.prFailures += 1
729 state.prRetryAt = now + Math.min(BACKOFF_MS * 2 ** (state.prFailures - 1), BACKOFF_CAP_MS)
730 $.ui.log(`statusbar: pull request not read: ${describe(error)}`, { to: 'debug' })
731 } finally {
732 state.isPrFetching = false
733 }
734}
735
736function ownRow(rows: unknown, owner: string): Json | undefined {
737 return (Array.isArray(rows) ? rows.filter(isRecord) : []).find(row => {
738 const head = row.headRepositoryOwner
739
740 return isRecord(head) && typeof head.login === 'string' && head.login.toLowerCase() === owner.toLowerCase()
741 })
742}
743
744// `gh pr list --json`의 한 줄을 상태로 줄인다.
745export function pullRequestOf(row: Json): StatusbarPullRequest | undefined {
746 const state =
747 row.state === 'MERGED' ? 'merged' : row.state === 'CLOSED' ? 'closed' : row.state === 'OPEN' ? (row.isDraft === true ? 'draft' : 'open') : undefined
748 if (state === undefined || typeof row.number !== 'number' || typeof row.url !== 'string') return undefined
749 const base: StatusbarPullRequest = { number: row.number, url: row.url, state }
750 if (state === 'merged' || state === 'closed') return base
751 const merge =
752 row.mergeable === 'CONFLICTING' || row.mergeStateStatus === 'DIRTY'
753 ? 'conflict'
754 : row.mergeStateStatus === 'BEHIND'
755 ? 'behind'
756 : row.mergeStateStatus === 'CLEAN' || row.mergeStateStatus === 'HAS_HOOKS'
757 ? 'clean'
758 : undefined
759 const checks = checksOf(row.statusCheckRollup)
760 const review =
761 row.reviewDecision === 'APPROVED'
762 ? 'approved'
763 : row.reviewDecision === 'CHANGES_REQUESTED'
764 ? 'changes'
765 : row.reviewDecision === 'REVIEW_REQUIRED'
766 ? 'required'
767 : undefined
768
769 return { ...base, ...(merge ? { merge } : {}), ...(checks ? { checks } : {}), ...(review ? { review } : {}) }
770}
771
772// 검사 하나라도 실패면 실패, 아니면 하나라도 도는 중이면 진행 중, 모두 끝나 통과면 통과.
773function checksOf(rollup: unknown): StatusbarPullRequest['checks'] {
774 const results = (Array.isArray(rollup) ? rollup.filter(isRecord) : []).map(check => {
775 // CheckRun은 status·conclusion, StatusContext는 state를 준다.
776 const value = String(check.conclusion ?? check.state ?? '')
777 if (check.status !== undefined && check.status !== 'COMPLETED') return 'pending'
778 if (['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(value)) return 'pass'
779 if (['PENDING', 'EXPECTED', 'QUEUED', 'IN_PROGRESS', ''].includes(value)) return 'pending'
780
781 return 'fail'
782 })
783 if (results.length === 0) return undefined
784 if (results.includes('fail')) return 'fail'
785
786 return results.includes('pending') ? 'pending' : 'pass'
787}
788
789// 스태시 수(--show-stash)는 git 2.35부터라 그 전 git이면 빼고 다시 읽는다.
790async function readStatus($: EngineInterface): Promise<string | undefined> {
791 const args = ['status', '--porcelain=v2', '--branch']
792
793 return (await runGit($, [...args, '--show-stash'])) ?? (await runGit($, args))
794}
795
796async function runGit($: EngineInterface, args: readonly string[]): Promise<string | undefined> {
797 const { exitCode, stdout } = await $.process.run(['git', '--no-optional-locks', ...args], {
798 timeoutMs: GIT_TIMEOUT_MS,
799 })
800
801 return exitCode === 0 ? stdout : undefined
802}
803
804// 워크트리의 git 디렉터리는 작업 폴더 밖이라 파일 접근이 막힐 수 있다: 그때는 진행 중 표시만 뺀다.
805async function operationIn($: EngineInterface, gitDir: string): Promise<StatusbarGitOperation | undefined> {
806 try {
807 const found = await Promise.all(GIT_OPERATIONS.map(([marker]) => $.fs.exists(`${gitDir}/${marker}`)))
808
809 return GIT_OPERATIONS.find((_, index) => found[index] === true)?.[1]
810 } catch {
811 return undefined
812 }
813}
814
815// `git status --porcelain=v2 --branch --show-stash`를 starship의 git_status처럼 센다. 각 항목의 XY는
816// 스테이징(X)과 작업 트리(Y)의 상태다.
817export function gitStatusOf(text: string): Omit<StatusbarGit, 'project' | 'worktree' | 'operation'> {
818 const changes = { conflicted: 0, stashed: 0, deleted: 0, renamed: 0, modified: 0, staged: 0, untracked: 0 }
819 let oid = ''
820 let head = ''
821 let upstream: StatusbarGit['upstream']
822 for (const line of text.split('\n')) {
823 const ab = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line)
824 const stash = /^# stash (\d+)$/.exec(line)
825 const xy = line.slice(2, 4)
826 if (line.startsWith('# branch.oid ')) oid = line.slice('# branch.oid '.length)
827 else if (line.startsWith('# branch.head ')) head = line.slice('# branch.head '.length)
828 else if (ab) upstream = { ahead: Number(ab[1]), behind: Number(ab[2]) }
829 else if (stash) changes.stashed = Number(stash[1])
830 else if (line.startsWith('1 ') || line.startsWith('2 ')) {
831 if (line.startsWith('2 ')) changes.renamed += 1
832 if (xy.includes('D')) changes.deleted += 1
833 if (xy[1] === 'M' || xy[1] === 'A') changes.modified += 1
834 if (xy[0] === 'M' || xy[0] === 'A' || xy[0] === 'T') changes.staged += 1
835 } else if (line.startsWith('u ')) changes.conflicted += 1
836 else if (line.startsWith('? ')) changes.untracked += 1
837 }
838 const isDetached = head === '(detached)' || head === ''
839
840 return {
841 ...(isDetached ? { commit: oid.slice(0, 7) } : { branch: head }),
842 ...(upstream ? { upstream } : {}),
843 changes,
844 }
845}
846
847// 파일을 바꿨을 수 있는 도구(읽기 전용이 아닌 호출)가 끝나면 git 상태를 다시 읽는다. 푸시나 PR
848// 명령은 실패로 끝나도(gh가 GitHub 오류를 받고도 PR은 만들어짐) PR 상태를 곧바로 다시 묻는다.
849function noteTool($: EngineInterface, state: State, e: ToolCallInput, result: ToolCallResult): void {
850 const operation = isRecord(result.result) ? result.result.gitOperation : undefined
851 const touchesPr =
852 (e.tool === 'Bash' && PR_COMMAND.test(e.command)) ||
853 (isRecord(operation) && (operation.pr !== undefined || operation.push !== undefined))
854 if (touchesPr) {
855 state.isPrStale = true
856 state.prRetryAt = 0
857 }
858 const isWrite = result.deny === undefined && result.isError !== true && result.isReadOnly !== true
859 if (touchesPr || isWrite) $.clock.after(0, () => void refreshGit($, state))
860}
861
862// git 줄(저장소 안에서만) 아래에 사용량 줄(좁으면 여러 줄)을 둔다.
863export function layoutRows(snapshot: StatusbarSnapshot, columns: number, frame?: Frame): Span[][] {
864 return [...gitRows(snapshot.git, columns), ...usageRows(snapshot, columns, frame)]
865}
866
867function usageRows(snapshot: StatusbarSnapshot, columns: number, frame: Frame): Span[][] {
868 let rows: Span[][] = []
869 for (const variant of VARIANTS) {
870 rows = composeRows(snapshot, variant, frame)
871 if (rows.every(row => widthOf(row) <= columns)) return rows
872 }
873
874 return rows
875}
876
877// 셸 프롬프트처럼 이어 쓰고 · 로 나눈다: 프로젝트[/워크트리] · 브랜치 (진행 중 작업) · 상태 · PR 상태.
878// 넘치면 브랜치 이름을 줄이고, 그래도 넘치면 PR 상태를 뺀다.
879function gitRows(git: StatusbarGit | undefined, columns: number): Span[][] {
880 if (git === undefined) return []
881 const head = git.branch ?? git.commit ?? '?'
882 let row: Span[] = []
883 for (const hasPr of [true, false]) {
884 row = gitSpans(git, hasPr)
885 const overflow = widthOf(row) - columns
886 if (overflow <= 0) return [row]
887 row = gitSpans(git, hasPr, Math.max(MIN_BRANCH_COLUMNS, displayWidth(head) - overflow))
888 if (widthOf(row) <= columns) return [row]
889 }
890
891 return [row]
892}
893
894function gitSpans(git: StatusbarGit, hasPr: boolean, branchColumns = Number.POSITIVE_INFINITY): Span[] {
895 const place = git.worktree === undefined ? git.project : `${git.project}/${git.worktree}`
896 const status = gitStatusText(git)
897 const groups = [
898 [span(place, { color: PATH_COLOR, bold: true })],
899 [
900 // HEAD가 떨어지면 starship의 git_commit처럼 (커밋)을 브랜치 자리에 둔다.
901 git.branch === undefined
902 ? span(`(${git.commit ?? '?'})`, { color: COMMIT_COLOR, bold: true })
903 : span(fitText(git.branch, branchColumns), { color: BRANCH_COLOR, bold: true }),
904 ...(git.operation === undefined
905 ? []
906 : [span(` (${git.operation.toUpperCase()})`, { color: GIT_STATUS_COLOR, bold: true })]),
907 ],
908 status === '' ? [] : [span(status, { color: GIT_STATUS_COLOR })],
909 hasPr && git.pr !== undefined ? pullRequestSpans(git.pr) : [],
910 ]
911
912 return joinSpans(
913 groups.filter(group => group.length > 0),
914 [span(' '), span('·', { color: FAINT }), span(' ')],
915 )
916}
917
918// PR 번호만 상태 색으로 칠하고, 누르면 PR 페이지가 열린다.
919function pullRequestSpans(pr: StatusbarPullRequest): Span[] {
920 return [
921 span('PR ', { color: MUTED }, pr.url),
922 span(`#${pr.number}`, { color: pullRequestColor(pr), bold: true, underline: true }, pr.url),
923 ]
924}
925
926// starship의 $all_status$ahead_behind와 같은 순서·기호로 붙여 쓴다. 깨끗하고 upstream과 같으면 빈 글자.
927export function gitStatusText(git: Pick<StatusbarGit, 'changes' | 'upstream'>): string {
928 const { conflicted, stashed, deleted, renamed, modified, staged, untracked } = git.changes
929 const marks: readonly [number, string][] = [
930 [conflicted, '='],
931 [stashed, '$'],
932 [deleted, '×'],
933 [renamed, '»'],
934 [modified, '!'],
935 [staged, '+'],
936 [untracked, '?'],
937 ]
938 const ahead = git.upstream?.ahead ?? 0
939 const behind = git.upstream?.behind ?? 0
940
941 return [
942 ...marks.filter(([count]) => count > 0).map(([count, mark]) => `${mark}${count}`),
943 ...(ahead > 0 ? [`⇡${ahead}`] : []),
944 ...(behind > 0 ? [`⇣${behind}`] : []),
945 ].join('')
946}
947
948// 너비(칸)에 맞게 자르고 끝에 …을 붙인다. 한글은 두 칸으로 센다.
949export function fitText(text: string, columns: number): string {
950 if (displayWidth(text) <= columns) return text
951 if (columns < 1) return ''
952 let kept = ''
953 let width = 0
954 for (const char of text) {
955 const next = width + displayWidth(char)
956 if (next > columns - 1) break
957 kept += char
958 width = next
959 }
960
961 return `${kept}…`
962}
963
964function composeRows(snapshot: StatusbarSnapshot, variant: Variant, frame: Frame): Span[][] {
965 const head = headSpans(snapshot, frame)
966 const context = contextMeter(snapshot, variant)
967 const limits = limitMeters(snapshot, variant)
968 const separator = [span(' '), span('·', { color: FAINT }), span(' ')]
969 const joined = joinSpans(limits, [span(' ')])
970 const rows =
971 variant.rows === 'one'
972 ? [joinSpans(limits.length > 0 ? [head, context, joined] : [head, context], separator)]
973 : variant.rows === 'two'
974 ? [joinSpans([head, context], separator), joined]
975 : [head, context, ...limits]
976
977 return rows.map(row => row.filter(part => part.text !== '')).filter(row => row.length > 0)
978}
979
980function headSpans(snapshot: StatusbarSnapshot, frame: Frame): Span[] {
981 return [
982 span('✻ ', { color: BRAND }),
983 span(prettyModel(snapshot.model), { bold: true }),
984 ...effortSpans(snapshot.effort, frame),
985 ]
986}
987
988// 채워진 원과 이름에 단계 색을 입히고, 빈 원은 흐리게 둔다. 효과는 두 부분을 이어서 흐른다.
989export function effortSpans(effort: StatusbarEffort, frame?: Frame): Span[] {
990 if (effort.kind === 'none') return []
991 if (effort.kind === 'unknown') return [span(' effort ?', { color: MUTED })]
992 const lit = EFFORT_LEVELS.indexOf(effort.level) + 1
993 if (lit === 0) return [span(` ${effort.level}`)]
994 const filled = EFFORT_ON.repeat(lit)
995 const off = EFFORT_OFF.repeat(EFFORT_LEVELS.length - lit)
996 const paint = painterFor(effort.level, filled.length + effort.level.length, frame)
997 const painted = (text: string, offset: number) =>
998 [...text].map((char, index) => span(char, paint(offset + index)))
999
1000 return [
1001 span(' '),
1002 ...painted(filled, 0),
1003 ...(off === '' ? [] : [span(off, { color: MUTED })]),
1004 span(' '),
1005 ...painted(effort.level, filled.length),
1006 ]
1007}
1008
1009// 선택 창과 같은 규칙: xhigh는 (frame % (길이+4))번째 글자만 밝게, max는 글자마다 (위치+frame)번째 무지개색.
1010function painterFor(level: string, length: number, frame: Frame): (index: number) => SpanStyle {
1011 if (level === 'max') {
1012 const shift = frame ?? 0
1013
1014 return index => ({ color: RAINBOW[(index + shift) % RAINBOW.length] ?? SHIMMER_BASE, bold: true })
1015 }
1016 if (level === 'xhigh') {
1017 const lit = frame === undefined ? -1 : frame % (length + 4)
1018
1019 return index => ({ color: index === lit ? SHIMMER_HIGHLIGHT : SHIMMER_BASE, bold: true })
1020 }
1021 const color = EFFORT_COLORS[level] ?? BRAND
1022
1023 return () => ({ color, bold: true })
1024}
1025
1026function contextMeter(snapshot: StatusbarSnapshot, variant: Variant): Span[] {
1027 const { tokens, window, percent } = snapshot.context
1028 const size = window > 0 ? formatTokens(window) : '?'
1029 if (tokens === undefined) return meter('C', 0, MUTED, '—', `—/${size}`, variant)
1030 const used = Math.round(percent ?? (window > 0 ? (tokens / window) * 100 : 0))
1031
1032 return usageMeter('C', used, `${formatTokens(tokens)}/${size}`, variant)
1033}
1034
1035function limitMeters(snapshot: StatusbarSnapshot, variant: Variant): Span[][] {
1036 const { fiveHour, sevenDay, fable, now } = snapshot
1037 const meters: Span[][] = []
1038 if (fiveHour) meters.push(windowMeter('S', fiveHour, now, variant, 'minutes'))
1039 if (sevenDay) meters.push(windowMeter('W', sevenDay, now, variant, 'hours'))
1040 if (fable && isFable(snapshot.model)) {
1041 if (fable.kind === 'unknown') meters.push(meter('Fable', 0, MUTED, '?', undefined, variant))
1042 if (fable.kind === 'absent') meters.push(meter('Fable', 0, MUTED, '—', undefined, variant))
1043 if (fable.kind === 'reading') {
1044 // 주간 한도와 같은 시각에 초기화되면 카운트다운은 주간 쪽에만 보인다.
1045 const hasOwnReset = !isSameInstant(fable.window.resetsAt, sevenDay?.resetsAt)
1046 meters.push(windowMeter('Fable', fable.window, now, variant, hasOwnReset ? 'hours' : undefined))
1047 }
1048 }
1049
1050 return meters
1051}
1052
1053function windowMeter(
1054 label: string,
1055 window: StatusbarWindow,
1056 now: number,
1057 variant: Variant,
1058 resetStyle: ResetStyle | undefined,
1059): Span[] {
1060 const reset = resetStyle === undefined ? undefined : resetIn(window.resetsAt, now, resetStyle)
1061
1062 return usageMeter(label, Math.round(window.percentUsed), reset && `↻${reset}`, variant)
1063}
1064
1065// 반올림한 사용률 하나에서 숫자·채움·색·경고 표시를 모두 끌어내 서로 어긋나지 않게 한다.
1066function usageMeter(label: string, used: number, extra: string | undefined, variant: Variant): Span[] {
1067 const status = statusOf(Math.max(0, 100 - used))
1068
1069 return meter(label, used / 100, STATUS_COLORS[status], `${used}%`, extra, variant, status === 'critical')
1070}
1071
1072// 남은 비율로 고르는 상태 단계: 50% 이하 주의, 25% 이하 경고, 10% 이하 위험.
1073export function statusOf(remaining: number): keyof typeof STATUS_COLORS {
1074 if (remaining <= 10) return 'critical'
1075 if (remaining <= 25) return 'serious'
1076 if (remaining <= 50) return 'warning'
1077
1078 return 'good'
1079}
1080
1081function meter(
1082 label: string,
1083 ratio: number,
1084 color: string,
1085 value: string,
1086 extra: string | undefined,
1087 variant: Variant,
1088 isCritical = false,
1089): Span[] {
1090 const width = variant.bar
1091 const filled = ratio <= 0 ? 0 : Math.min(width, Math.max(1, Math.round(ratio * width)))
1092
1093 return [
1094 span(`${label} `, { color: LABEL_COLORS[label] ?? MUTED }),
1095 span(FILLED.repeat(filled), { color }),
1096 span(TRACK.repeat(width - filled), { color, dimColor: true }),
1097 span(` ${value}`, { bold: true }),
1098 ...(isCritical ? [span(' !', { color: STATUS_COLORS.critical, bold: true })] : []),
1099 ...(extra !== undefined && variant.extras ? [span(` ${extra}`, { color: MUTED })] : []),
1100 ]
1101}
1102
1103function span(text: string, style?: SpanStyle, href?: string): Span {
1104 return { text, ...(style !== undefined ? { style } : {}), ...(href !== undefined ? { href } : {}) }
1105}
1106
1107function joinSpans(groups: readonly Span[][], separator: readonly Span[]): Span[] {
1108 return groups.flatMap((group, index) => (index === 0 ? group : [...separator, ...group]))
1109}
1110
1111function widthOf(row: readonly Span[]): number {
1112 return row.reduce((sum, part) => sum + displayWidth(part.text), 0)
1113}
1114
1115// 한글·한자·전각 문자는 터미널에서 두 칸을 차지한다.
1116export function displayWidth(text: string): number {
1117 let width = 0
1118 for (const char of text) {
1119 const code = char.codePointAt(0) ?? 0
1120 const isWide =
1121 (code >= 0x1100 && code <= 0x115f) ||
1122 (code >= 0x2e80 && code <= 0xa4cf) ||
1123 (code >= 0xac00 && code <= 0xd7a3) ||
1124 (code >= 0xf900 && code <= 0xfaff) ||
1125 (code >= 0xfe30 && code <= 0xfe4f) ||
1126 (code >= 0xff00 && code <= 0xff60) ||
1127 (code >= 0xffe0 && code <= 0xffe6)
1128 width += isWide ? 2 : 1
1129 }
1130
1131 return width
1132}
1133
1134// 5시간 창은 분까지(3h54m), 주간 창은 하루 이상이면 일만(5d), 하루 미만이면 시간만(23h) 보인다.
1135export function resetIn(
1136 resetsAt: string | undefined,
1137 now: number,
1138 style: ResetStyle = 'minutes',
1139): string | undefined {
1140 if (resetsAt === undefined) return undefined
1141 const at = Date.parse(resetsAt)
1142 if (!Number.isFinite(at)) return undefined
1143 if (at <= now) return '곧'
1144 const minutes = Math.ceil((at - now) / 60_000)
1145 const days = Math.floor(minutes / 1440)
1146 const hours = Math.floor((minutes % 1440) / 60)
1147 if (days > 0) return style === 'hours' ? `${days}d` : `${days}d${hours}h`
1148 if (hours === 0) return `${minutes}m`
1149
1150 return style === 'hours' ? `${hours}h` : `${hours}h${minutes % 60}m`
1151}
1152
1153function isSameInstant(a: string | undefined, b: string | undefined): boolean {
1154 if (a === undefined || b === undefined) return false
1155
1156 return Math.abs(Date.parse(a) - Date.parse(b)) < 60_000
1157}
1158
1159export function formatTokens(count: number): string {
1160 if (count >= 999_500) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
1161 if (count >= 1000) return `${Math.round(count / 1000)}k`
1162
1163 return String(count)
1164}
1165
1166export function prettyModel(id: string): string {
1167 const base = id.replace(/\[[^\]]*\]$/, '')
1168 const named = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/.exec(base)
1169 if (named) return `${capitalize(named[1])} ${version(named[2], named[3])}`
1170 const legacy = /^claude-(\d+)(?:-(\d{1,2}))?-([a-z]+)(?:-\d{8})?$/.exec(base)
1171 if (legacy) return `${capitalize(legacy[3])} ${version(legacy[1], legacy[2])}`
1172
1173 return base === '' ? '?' : base
1174}
1175
1176export function isFable(model: string): boolean {
1177 return /fable/i.test(model)
1178}
1179
1180// /effort가 남기는 두 문장: "Set effort level to max", 상한에 걸리면 "...; set to 'high' instead".
1181export function effortFrom(text: string | undefined): string | undefined {
1182 if (text === undefined) return undefined
1183 const match =
1184 /\beffort '[a-z]+' exceeds the cap\b[^;]*; set to '([a-z]+)' instead/i.exec(text) ??
1185 /\bset effort level to ([a-z]+)/i.exec(text)
1186 const level = match?.[1]?.toLowerCase()
1187
1188 return level !== undefined && EFFORT_LEVELS.includes(level) ? level : undefined
1189}
1190
1191// /model이 남기는 문장: "Set model to `Sonnet 5.5` and saved as your default for new sessions".
1192export function isModelSwitch(text: string): boolean {
1193 return /\bset model to\b/i.test(text)
1194}
1195
1196function textOfBlocks(blocks: readonly ApiContentBlock[]): string {
1197 return blocks
1198 .map(block => {
1199 const text: unknown = 'text' in block ? block.text : undefined
1200types/index.d.ts 67 lines1export type StatusbarWindow = { percentUsed: number; resetsAt?: string }
2
3export type StatusbarEffort =
4 | { kind: 'unknown' }
5 | { kind: 'none' }
6 | { kind: 'level'; level: string }
7
8export type StatusbarFable =
9 | { kind: 'unknown' }
10 | { kind: 'absent' }
11 | { kind: 'reading'; window: StatusbarWindow }
12
13// 지금 브랜치 PR의 번호·주소와 상태.
14export type StatusbarPullRequest = {
15 number: number
16 url: string
17 state: 'open' | 'draft' | 'merged' | 'closed'
18 merge?: 'clean' | 'conflict' | 'behind'
19 checks?: 'pass' | 'fail' | 'pending'
20 review?: 'approved' | 'changes' | 'required'
21}
22
23export type StatusbarGitOperation = 'rebasing' | 'merging' | 'cherry-picking' | 'reverting' | 'bisecting'
24
25// starship의 git_status가 세는 것과 같다: 스태시 수, 그리고 바뀐 파일을 종류별로.
26export type StatusbarGitChanges = {
27 conflicted: number
28 stashed: number
29 deleted: number
30 renamed: number
31 modified: number
32 staged: number
33 untracked: number
34}
35
36// 세션이 선 저장소의 한 시점. branch가 없으면 HEAD가 떨어져 있고 commit이 그 자리를 채운다.
37// upstream이 없으면 추적하는 원격 브랜치가 없거나 사라졌다.
38export type StatusbarGit = {
39 project: string
40 worktree?: string
41 branch?: string
42 commit?: string
43 operation?: StatusbarGitOperation
44 upstream?: { ahead: number; behind: number }
45 changes: StatusbarGitChanges
46 pr?: StatusbarPullRequest
47}
48
49// 상태 줄이 그리는 한 시점의 값. fable이 없으면 요금제 한도가 없는 세션이다.
50export type StatusbarSnapshot = {
51 model: string
52 effort: StatusbarEffort
53 context: { tokens?: number; window: number; percent?: number }
54 fiveHour?: StatusbarWindow
55 sevenDay?: StatusbarWindow
56 fable?: StatusbarFable
57 fableFetchedAt?: number
58 git?: StatusbarGit
59 now: number
60}
61
62declare module 'claude-code' {
63 interface PluginState {
64 statusbar: { snapshot: Shaped<StatusbarSnapshot | null> }
65 }
66}
67