SLOPSHOPPER

statusbar

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

spinnerguardstatusprocessnetworktimer
v0.7.0no licenseupdated 2026-10-13DevooKim/my-claude/plugins/statusbar
A shopper browsing a rack in a slop shop
README

my-claude

개인용 Claude Code 플러그인 마켓플레이스.

설치

Claude Code 프롬프트에서:

/plugin install statusbar --marketplace DevooKim/my-claude
/plugin install prompt-band --marketplace DevooKim/my-claude

마켓플레이스 추가 질문에는 y, 범위는 user를 고르면 됩니다.

플러그인

statusbar

프롬프트 입력창 아래 힌트 줄 밑에 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: /effort 선택 창과 같은 색, xhigh는 반짝임, max는 흐르는 무지개
  • 컨텍스트 사용률과 토큰
  • 5시간·주간 사용 한도 사용률과 초기화까지 남은 시간, Fable 모델이면 Fable 주간 한도
  • 바 색은 남은 비율에 따라 초록 → 노랑(50% 이하) → 주황(25% 이하) → 빨강(10% 이하, !)

git 줄 (저장소 안에서만)

  • 프로젝트[/워크트리] · 브랜치 · 상태를 ·로 나눠 이어 씀. 경로는 굵은 하늘색(#57C7FF), 브랜치는 굵은 회색, 상태는 노랑
  • 상태 기호(starship git_status와 같음): = 충돌, $ 스태시, × 삭제, » 이름 바꿈, ! 수정, + 스테이징, ? 추적 안 함, ⇡ 앞섬, ⇣ 뒤처짐. 깨끗하고 upstream과 같으면 아무것도 없음
  • HEAD가 떨어지면 브랜치 자리에 (커밋), 리베이스·병합·체리픽·revert·bisect 중이면 브랜치 뒤에 (REBASING)처럼 표시
  • 브랜치 PR 번호(gh 로그인 필요): 누르면 기본 브라우저로 PR 페이지가 열림(전체 화면 모드에서 클릭). 색이 상태: draft 회색, 머지할 수 있지만 승인 전(리뷰 전·CI 진행 중 포함) 주황, 승인까지 끝나 머지 가능 초록, 충돌·CI 실패·변경 요청으로 머지 불가 빨강, 병합됨 보라. Claude Code의 PR 표시와 겹치면 /config의 Show PR status footer를 끄면 됨
  • 파일을 바꾸는 도구가 끝날 때마다, 그리고 30초마다 다시 읽음. PR 상태는 브랜치가 바뀌거나, 푸시·PR 명령 뒤, 또는 다른 터미널의 push·pull로 ⇡⇣가 바뀌면 곧바로, 그 밖에는 3분마다(GitHub가 아직 계산 중이면 다음 git 확인 때)

서브에이전트와 할 일(Task)은 Claude Code가 직접 보여 주므로 따로 그리지 않습니다.

테스트: claude plugin test plugins/statusbar

prompt-band

프롬프트 입력창 바로 위 띠(AbovePrompt)의 오른쪽 끝, 엔진의 접기 버튼 [-] 옆에 세션 지표를 그립니다.

                                                        Cache ◕ 93%  160k/172k  [-]
  • 캐시: 메인 스레드의 마지막 응답에서 입력 토큰 가운데 프롬프트 캐시가 읽어 준 비율과 읽은 양/전체. 서브에이전트 응답은 세지 않음
  • 원 기호는 25% 단위로 내림(○ ◔ ◑ ◕ ●, 꽉 찬 원은 100%만), 숫자는 1% 단위
  • 색: 90% 이상 초록, 70% 이상 노랑, 40% 이상 주황, 그 아래 빨강
  • 첫 응답 전이나 설문이 떠 있을 때는 그리지 않음
  • 띠 자리는 하나라서 아래 플러그인이 그린 띠를 받아 그 위에 쌓음: 다른 띠 플러그인과 함께 켜도 둘 다 보임(그 플러그인도 next(e)를 품어야 함)

테스트: claude plugin test plugins/prompt-band

Source 2 files
hooks/register.tsx 1303 lines
1import { 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
1200
types/index.d.ts 67 lines
1export 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