모델, 컨텍스트, 요금, 사용 한도(Fable 포함), 브랜치를 입력창 아래에 표시합니다. /dashboard-style과 /dashboard-theme으로 모양과 색을 바꿉니다

<img alt="claude-code-mods 로고" src="./docs/images/logo.png" width="160">
<h1 align="center">claude-code-mods</h1>
<a href="./LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-111111?style=flat-square"></a> <a href="https://claude.com/claude-code"><img alt="Claude Code 2.1.299" src="https://img.shields.io/badge/Claude%20Code-2.1.299-111111?style=flat-square"></a> <a href="#설치"><img alt="marketplace oobg" src="https://img.shields.io/badge/marketplace-oobg-111111?style=flat-square"></a> <img alt="5 mods" src="https://img.shields.io/badge/mods-5-D97757?style=flat-square"> <a href="https://github.com/oobg/claude-code-mods/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/oobg/claude-code-mods?style=flat-square&color=111111"></a>
<a href="#주요-기능"><b>주요 기능</b></a> | <a href="#설치"><b>설치</b></a> | <a href="#모드-목록"><b>모드 목록</b></a> | <a href="#상세-문서"><b>상세 문서</b></a> | <a href="https://oobg.github.io/claude-code-mods/"><b>문서 사이트</b></a>
claude-code-mods는 Claude Code의 입력창과 도구 결과에 기능을 추가하는 모드 5개를 모은 저장소입니다. 입력창 위에서는 다음 요청을 제안하고 자주 쓰는 문구 프리셋을 표시하며, 입력창 아래에서는 세션 상태를 보여 줍니다. Finder에서 복사한 파일을 붙여넣으면 파일 이름을 @경로 멘션으로 바꾸고, Cmd+Z로 입력 내용을 되돌릴 수 있습니다. 넓은 터미널에서는 Edit와 Write 결과의 diff를 좌우로 비교할 수 있습니다. 모드는 필요한 것만 골라 설치하면 되고, 따로 설치할 프로그램은 없습니다.
<table class="cards"> <tr> <td width="50%" valign="top"> <a href="./docs/next-prompts.md"><picture> <img alt="next-prompts: 턴이 끝나면 다음에 보낼 만한 요청이 하나씩 나타난다" src="./docs/images/showcase/suggest-light.gif" width="100%"> <sub>턴이 끝나면 다음에 보낼 만한 요청을 2~3개 제안합니다. <code>$1</code>을 입력하고 스페이스를 누르거나 번호를 클릭하면 문구가 입력창에 들어가고, 문구를 클릭하면 바로 보냅니다</sub> </td> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="quick-replies: 입력창에 $1을 치고 스페이스를 누르면 $1이 1번 프리셋 문구로 바뀐다" src="./docs/images/showcase/preset-light.gif" width="100%"> <sub>자주 쓰는 문구를 전역 또는 저장소별 프리셋으로 저장합니다. 입력창에 <code>$4</code>를 치고 스페이스를 누르면 해당 문구로 바뀝니다</sub> </td> </tr> <tr> <td width="50%" valign="top"> <a href="./docs/dashboard.md"><picture> <img alt="dashboard: 컨텍스트 게이지가 차면서 색이 바뀐다" src="./docs/images/showcase/gauge-light.gif" width="100%"> <sub>모델, 컨텍스트, 사용 한도, 브랜치를 입력창 아래 두 줄에 표시합니다. 게이지가 찰수록 색이 노란색, 주황색, 빨간색 순으로 바뀝니다</sub> </td> <td width="50%" valign="top"> <a href="./docs/key-assist.md"><picture> <img alt="key-assist: 붙여넣은 파일 이름이 @경로로 바뀐다" src="./docs/images/showcase/paste-light.gif" width="100%"> <sub>Finder에서 복사한 파일을 붙여넣으면 파일 이름이 <code>@경로</code>로 바뀝니다. Cmd+Z로 입력 내용을 되돌릴 수 있습니다</sub> </td> </tr> <tr> <td colspan="2" valign="top"> <a href="./docs/split-diff.md"><picture> <img alt="split-diff: 터미널 폭이 120칸 이상이면 좌우 diff, 미만이면 원래 세로 diff로 전환된다" src="./docs/images/showcase/split-diff-light.gif" width="100%"> <sub>터미널 폭이 120칸 이상이면 Edit와 Write 결과의 diff에서 원래 내용은 왼쪽에, 바뀐 내용은 오른쪽에 표시합니다. 바뀐 단어는 더 밝은 색으로 강조합니다</sub> </td> </tr> </table>
$2를 입력하고 스페이스를 누르면 2번 문구가 그 자리에 들어갑니다. $12처럼 두 자리 번호도 쓸 수 있습니다.@경로 멘션이 입력됩니다.이 저장소가 oobg 마켓플레이스입니다. 먼저 Claude Code 입력창에서 마켓플레이스를 등록합니다.
/plugin marketplace add oobg/claude-code-mods
그다음 필요한 모드만 골라 설치합니다.
/plugin install next-prompts@oobg
/plugin install quick-replies@oobg
/plugin install dashboard@oobg
/plugin install key-assist@oobg
/plugin install split-diff@oobg
설치한 모드는 Claude Code를 다시 시작하거나 /reload-plugins를 실행하면 적용됩니다. 설치 여부는 /plugin 화면에서 확인하거나 터미널에서 claude plugin list를 실행해 확인할 수 있습니다.
Claude Code를 시작하면 quick-replies의 기본 프리셋과 dashboard가 바로 표시됩니다. next-prompts의 제안은 첫 턴이 끝난 뒤에 나타납니다.
| 모드 | 기능 | 위치 |
|---|---|---|
| next-prompts | 턴이 끝나면 다음에 보낼 만한 요청을 2~3개 제안합니다 | 입력창 위 |
| quick-replies | 자주 쓰는 문구를 전역 프리셋이나 저장소별 프리셋에 저장합니다 | 입력창 위 |
| dashboard | 모델, 컨텍스트, 사용 한도, 브랜치를 두 줄로 표시합니다 | 입력창 아래 |
| key-assist | 붙여넣은 파일 이름을 @경로로 바꾸고, Cmd+Z로 입력한 내용을 되돌립니다 | 입력창 |
| split-diff | 넓은 터미널에서 Edit와 Write 결과의 diff를 좌우로 나눠 표시합니다 | 도구 결과 |
next-prompts와 quick-replies를 함께 쓰면 입력창 위에 표시되는 두 목록의 번호가 이어집니다. 다음 제안은 $1부터, 프리셋은 그다음 번호부터 시작합니다.
<table class="cards"> <tr> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="입력창에 $2를 치고 스페이스를 누르면 $2가 2번 문구로 바뀐다" src="./docs/images/showcase/substitute-light.gif" width="100%"> <sub>입력창에 <code>$2</code>를 치고 스페이스를 누르면 <code>$2</code>가 2번 문구로 바뀝니다. 문장 중간에서도 사용할 수 있습니다</sub> </td> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="번호를 클릭하면 입력창 내용이 그 문구로 바뀐다" src="./docs/images/showcase/pick-light.gif" width="100%"> <sub>번호(<code>$2:</code>)를 클릭하면 입력창의 기존 내용을 모두 2번 문구로 바꿉니다</sub> </td> </tr> <tr> <td width="50%" valign="top"> <a href="./docs/next-prompts.md"><picture> <img alt="문구를 클릭하면 그 문구만 바로 보내진다" src="./docs/images/showcase/send-light.gif" width="100%"> <sub>문구를 클릭하면 해당 문구만 바로 보냅니다. 입력창에 적어 둔 내용은 그대로 남습니다</sub> </td> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="$e를 입력하면 프리셋 편집 화면이 열린다" src="./docs/images/showcase/editor-light.gif" width="100%"> <sub>입력창에 <code>$e</code>를 입력하면 프리셋 편집 화면이 열립니다</sub> </td> </tr> <tr> <td width="50%" valign="top"> <a href="./docs/quick-replies.md"><picture> <img alt="$0을 입력하면 프리셋을 한 줄로 접고 다시 입력하면 펼친다" src="./docs/images/showcase/fold-light.gif" width="100%"> <sub>입력창에 <code>$0</code>을 입력하면 프리셋을 한 줄로 접고, 다시 입력하면 펼칩니다</sub> </td> <td width="50%"></td> </tr> </table>
입력창 맨 앞이나 공백 뒤에 $를 입력하고 번호와 스페이스를 쳐야 문구로 바뀌며, 바뀐 내용은 ctrl+_로 되돌릴 수 있습니다.
먼저 마켓플레이스 목록을 갱신합니다.
/plugin marketplace update oobg
그다음 /plugin 화면에서 설치한 모드를 업데이트하거나, 터미널에서 claude plugin update <모드>@oobg를 실행합니다. 업데이트한 모드는 Claude Code를 다시 시작하면 적용됩니다.
모드를 삭제하려면 다음 명령을 실행합니다.
/plugin uninstall next-prompts@oobg
/plugin uninstall quick-replies@oobg
/plugin uninstall dashboard@oobg
/plugin uninstall key-assist@oobg
/plugin uninstall split-diff@oobg
[!NOTE] key-assist를 삭제해도 키 설정에 추가된
cmd+z항목은 남습니다. 필요하지 않으면~/.claude/keybindings.json에서 직접 지웁니다.
| 증상 | 해결 |
|---|---|
| 설치 목록에는 있는데 모드가 표시되지 않습니다 | Claude Code를 다시 시작합니다 |
| next-prompts 제안이 나오지 않습니다 | 첫 턴이 끝나야 제안이 나옵니다. 답 본문이 120자 미만이면 제안하지 않습니다 |
| Cmd+Z가 동작하지 않습니다 | Command 키 입력을 전달하는 터미널인지 확인합니다. 아니면 ctrl+_를 씁니다 |
| Ctrl+Z를 눌렀더니 Claude Code가 멈췄습니다 | Ctrl+Z는 터미널에서 실행 중인 프로세스를 일시 중지합니다. fg를 입력하면 다시 실행됩니다 |
모드별 자세한 해결 방법은 각 상세 문서의 "문제 해결" 절에서 확인할 수 있습니다.
각 모드의 상세 문서는 문서 사이트에서도 볼 수 있습니다.
| 문서 | 내용 |
|---|---|
| next-prompts | 제안이 나오는 조건, 나타나는 방식, 모델 호출과 사용량 |
| quick-replies | 프리셋 범위와 번호, 접기와 자동 제목, 편집 화면 키, 저장 데이터 |
| dashboard | 항목 설명, 표시 형태와 색상, /usage 조회, 참고 사항 |
| key-assist | 파일 붙여넣기 동작, Cmd+Z 키 설정, 제한 사항 |
| split-diff | 좌우 표시 조건, 줄 맞추기, 단어 강조, 제한 사항 |
Copyright (c) 2026 oobg
이 프로젝트는 MIT 라이선스에 따라 배포됩니다.
hooks/register.tsx 427 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Segment, Snapshot, Style, Theme } from '../types'
5import { launchPermissionMode, lastPermissionMode, modeFromHint, pullFor, withSection } from './footer'
6import { GAPS, STYLES, THEME_NAMES, buildLayout, parseUsageLimits } from './lines'
7import type { PolledLimit } from './lines'
8
9// 바닥 틀 안의 순서. 다른 구역이 생기면 이 숫자로 위아래를 정한다.
10const ORDER = 20
11// 세션 시간, 요금 한도 같은 값이 메시지 없이도 바뀌므로 이 간격으로 다시 그린다.
12const REFRESH_MS = 30_000
13
14// 마지막으로 모은 세션 값. 모양은 그릴 때 화면 너비를 보고 고른다.
15const snapshot = atom({ plugin: 'dashboard', key: 'snapshot' } as const, null as Snapshot | null)
16const permissionMode = atom({ plugin: 'dashboard', key: 'permissionMode' } as const, null as string | null)
17// 모델 요청에 실린 effort. 모드가 effort를 따로 읽을 길이 없어서, 메인 대화의 요청이 나갈 때 받아 둔다. 첫 요청 전에는 null.
18const effort = atom({ plugin: 'dashboard', key: 'effort' } as const, null as string | null)
19// 대시보드 모양과 이번 주 Fable 사용량. 둘 다 저장해 두고 다음 세션에도 쓴다.
20// 세션 상태는 플러그인을 다시 불러와도 남는다. 0.4.0 전 값('style')이 남아 있어도 쓰지 않도록 이름을 바꿨다.
21const style = atom({ plugin: 'dashboard', key: 'layoutStyle' } as const, null as Style | null)
22const fableWeek = atom({ plugin: 'dashboard', key: 'fableWeek' } as const, null as number | null)
23const theme = atom({ plugin: 'dashboard', key: 'theme' } as const, null as Theme | null)
24
25// 0.4.0에서 기본 모양이 gauge로 바뀌었다. 그 전 버전에서 고른 모양('style')은 무시하고 새 키로 저장한다.
26const STYLE_KEY = 'style.v2'
27const FABLE_KEY = 'fableWeek'
28// /usage에서 읽은 한도들. 첫 응답 전에는 Claude Code가 5시간, 주간 한도를 모르므로 이 값으로 그린다.
29const POLLED_KEY = 'polledLimits'
30const polledLimits = atom({ plugin: 'dashboard', key: 'polledLimits' } as const, null as PolledLimit[] | null)
31const STYLE_COMMAND = 'dashboard-style'
32const THEME_KEY = 'theme'
33const THEME_COMMAND = 'dashboard-theme'
34// Fable 한도는 모드가 받는 한도 값에 없어서, 따로 띄운 Claude Code에 /usage를 물어 읽는다.
35// --safe-mode는 훅, 플러그인, MCP를 띄우지 않고, --no-session-persistence는 기록 파일을 남기지 않는다. 모델은 부르지 않는다.
36const USAGE_ARGS = ['-p', '/usage', '--safe-mode', '--no-session-persistence']
37const USAGE_EVERY_MS = 10 * 60_000
38const STYLE_NAMES: Record<Style, string> = {
39 gauge: '게이지',
40 calm: '차분한 두 줄',
41 emoji: '이모지 두 줄',
42 compact: '한 줄',
43}
44
45async function currentStyle($: EngineInterface): Promise<Style> {
46 const held = (await read($, style)) ?? ((await $.store.get(STYLE_KEY)) as Style | undefined)
47 return held !== undefined && held !== null && STYLES.includes(held) ? held : 'gauge'
48}
49
50async function currentTheme($: EngineInterface): Promise<Theme> {
51 const held = (await read($, theme)) ?? ((await $.store.get(THEME_KEY)) as Theme | undefined)
52 return held !== undefined && held !== null && THEME_NAMES.includes(held) ? held : 'everforest'
53}
54
55async function saveUsage($: EngineInterface, limits: PolledLimit[]): Promise<void> {
56 if (limits.length === 0) {
57 return
58 }
59 await update($, polledLimits, () => limits)
60 await $.store.set(POLLED_KEY, limits)
61 const fable = limits.find(l => l.kind === 'fable')
62 if (fable !== undefined) {
63 await update($, fableWeek, () => fable.percentUsed)
64 await $.store.set(FABLE_KEY, fable.percentUsed)
65 }
66}
67
68async function currentPolled($: EngineInterface): Promise<PolledLimit[]> {
69 return (await read($, polledLimits)) ?? (((await $.store.get(POLLED_KEY)) as PolledLimit[] | undefined) ?? [])
70}
71
72// 뒤에서 /usage를 돌려 Fable 사용량을 새로 읽는다. 실패하면 이전 값을 둔다.
73async function pollFable($: EngineInterface): Promise<void> {
74 // PATH에 claude가 없을 수 있어서, 기본 설치 경로도 차례로 본다.
75 const home = (await $.env.get('HOME')) ?? ''
76 for (const bin of ['claude', `${home}/.local/bin/claude`, '/opt/homebrew/bin/claude', '/usr/local/bin/claude']) {
77 try {
78 const { exitCode, stdout } = await $.process.run([bin, ...USAGE_ARGS], { timeoutMs: 30_000 })
79 if (exitCode === 0) {
80 await saveUsage($, parseUsageLimits(stdout, await $.clock.now()))
81 await refresh($)
82 }
83 return
84 } catch {
85 // 이 경로에 없으면 다음 경로를 본다.
86 }
87 }
88}
89
90async function currentFable($: EngineInterface): Promise<number | null> {
91 return (await read($, fableWeek)) ?? (((await $.store.get(FABLE_KEY)) as number | undefined) ?? null)
92}
93
94// 이 세션의 기록 파일 끝부분을 읽는다. 기록은 세션을 연 폴더 이름 아래 저장되는데, 현재 폴더는 세션 중에 바뀔 수 있다.
95// 프로젝트 루트, 현재 폴더 순서로 읽어 보고, 둘 다 없으면 기록 폴더 전체에서 세션 id로 찾는다.
96// 기록 파일은 금방 4MB(모드의 파일 읽기 한도)를 넘으므로, tail로 마지막 부분만 가져온다.
97async function readTranscript($: EngineInterface): Promise<{ path: string; text: string } | null> {
98 const home = (await $.env.get('HOME')) ?? ''
99 const id = await $.session.id()
100 const base = `${home}/.claude/projects`
101 const candidates = [await $.session.root(), await $.session.cwd()].map(
102 dir => `${base}/${dir.replace(/[^A-Za-z0-9]/g, '-')}/${id}.jsonl`,
103 )
104 try {
105 for (const entry of await $.fs.list(base)) {
106 if (entry.kind === 'dir') {
107 candidates.push(`${base}/${entry.name}/${id}.jsonl`)
108 }
109 }
110 } catch {
111 // 목록을 못 읽으면 위 두 후보만 본다.
112 }
113 for (const path of [...new Set(candidates)]) {
114 try {
115 const { exitCode, stdout } = await $.process.run(['tail', '-c', '262144', path])
116 if (exitCode === 0) {
117 return { path, text: stdout }
118 }
119 } catch {
120 // 다음 후보를 본다.
121 }
122 }
123 return null
124}
125
126// Claude Code에 로그인한 계정의 이메일. ~/.claude.json에 들어 있고, API 키로 쓰면 없다.
127async function readEmail($: EngineInterface, home: string): Promise<string | null> {
128 try {
129 const config = JSON.parse(await $.fs.read(`${home}/.claude.json`)) as {
130 oauthAccount?: { emailAddress?: string }
131 }
132 return config.oauthAccount?.emailAddress ?? null
133 } catch {
134 return null
135 }
136}
137
138// 지금 폴더의 git 브랜치. git 저장소가 아니거나 브랜치가 없으면(분리된 HEAD) null.
139async function readBranch($: EngineInterface): Promise<string | null> {
140 try {
141 const { exitCode, stdout } = await $.process.run(['git', 'branch', '--show-current'])
142 return exitCode === 0 && stdout.trim() !== '' ? stdout.trim() : null
143 } catch {
144 return null
145 }
146}
147
148// 메시지 전송과 턴 마무리를 막지 않도록, 다시 그리기에서 난 오류는 여기서 삼킨다.
149async function refresh($: EngineInterface): Promise<void> {
150 try {
151 await render($)
152 } catch {
153 // 이전 화면을 그대로 둔다.
154 }
155}
156
157// 세션 값을 모아 두 줄을 다시 짠다.
158async function render($: EngineInterface): Promise<void> {
159 const home = (await $.env.get('HOME')) ?? ''
160 const [usage, model, root, now, branch, email, transcript] = await Promise.all([
161 $.session.usage(),
162 $.session.model(),
163 $.session.root(),
164 $.clock.now(),
165 readBranch($),
166 readEmail($, home),
167 readTranscript($),
168 ])
169 const mode = transcript === null ? null : lastPermissionMode(transcript.text)
170 const polled = await currentPolled($)
171 const polledFable = polled.find(p => p.kind === 'fable')
172 const taken: Snapshot = {
173 model,
174 effort: await read($, effort),
175 context: usage.context,
176 costUsd: usage.cost?.usd ?? null,
177 // 첫 응답 전에는 Claude Code가 한도를 모르므로, /usage에서 읽어 둔 값으로 빈 칸을 채운다.
178 rateLimits: [
179 ...usage.rateLimits,
180 ...polled.filter(p => p.kind !== 'fable' && !usage.rateLimits.some(r => r.kind === p.kind)),
181 ],
182 fableWeek: await currentFable($),
183 ...(polledFable?.resetsAt === undefined ? {} : { fableResetsAt: polledFable.resetsAt }),
184 folder: root.split('/').filter(Boolean).at(-1) ?? root,
185 branch,
186 email,
187 elapsedMs: now - usage.startedAt,
188 nowMs: now,
189 }
190 await update($, snapshot, () => taken)
191 // 새 세션은 기록 파일에 권한 모드가 아직 없다. 그때는 훅 이벤트로 받아 둔 값을 그대로 둔다.
192 if (mode !== null) {
193 await update($, permissionMode, () => mode)
194 }
195}
196
197// Claude Code의 기본 훅 이벤트에는 지금 권한 모드가 실려 온다. 새 세션은 기록 파일에 아직 권한 모드가 없어서,
198// 이 값으로 권한 모드 표시 폭만큼 당긴다.
199async function notePermissionMode($: EngineInterface, e: { permission_mode?: string }): Promise<void> {
200 const mode = e.permission_mode
201 if (typeof mode === 'string') {
202 // 훅 이벤트가 실어 온 값이 가장 새롭다. 안내 줄에서 읽어 둔 값은 버린다.
203 hintedMode = null
204 if (mode !== (await read($, permissionMode))) {
205 await update($, permissionMode, () => mode)
206 }
207 }
208}
209
210// Shift+Tab으로 바꾼 권한 모드. 그리기는 상태를 쓰지 못하므로 모듈 변수에 둔다.
211// 기록 파일은 다음 메시지 전까지 예전 모드를 담고 있으므로, 이 값이 있으면 기록에서 읽은 값보다 먼저 쓴다.
212let hintedMode: string | null = null
213
214// 모드가 띄운 프로세스의 부모를 따라 올라가 Claude Code의 실행 줄을 찾는다. 못 찾으면 빈 문자열.
215const LAUNCH_SCRIPT =
216 'p=$PPID; for i in 1 2 3 4 5 6; do a=$(ps -o args= -p "$p" 2>/dev/null); ' +
217 'case "$a" in claude|claude\\ *|*/claude|*/claude\\ *) echo "$a"; exit 0;; esac; ' +
218 'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); [ -z "$p" ] && break; done'
219
220async function readLaunchMode($: EngineInterface): Promise<string | null> {
221 try {
222 const { exitCode, stdout } = await $.process.run(['sh', '-c', LAUNCH_SCRIPT], { timeoutMs: 5_000 })
223 return exitCode === 0 ? launchPermissionMode(stdout.trim()) : null
224 } catch {
225 return null
226 }
227}
228
229// 세션마다 한 번: 저장된 선택을 읽고, 명령을 등록하고, 타이머를 건다.
230// /reload-plugins는 세션 시작을 다시 알리지 않고 타이머도 지우므로, 다음 메시지나 턴 끝에서 다시 부른다.
231let started = false
232
233async function start($: EngineInterface): Promise<void> {
234 if (started) {
235 return
236 }
237 started = true
238 // 기록에도 훅 이벤트에도 권한 모드가 아직 없으면(새 세션, 다시 불러온 직후) 실행 인자로 처음 값을 정한다.
239 if ((await read($, permissionMode)) === null) {
240 const launched = (await readLaunchMode($)) ?? 'default'
241 if ((await read($, permissionMode)) === null) {
242 await update($, permissionMode, () => launched)
243 }
244 }
245 const [chosen, palette, fable] = [await currentStyle($), await currentTheme($), await currentFable($)]
246 await update($, style, () => chosen)
247 await update($, theme, () => palette)
248 await update($, fableWeek, () => fable)
249 await refresh($)
250 // 명령 등록이 막혀도 대시보드는 그린다.
251 await $.command
252 .register({
253 name: STYLE_COMMAND,
254 description: '입력창 아래 대시보드 모양을 바꿔요. 이름을 빼면 다음 모양으로 바뀌어요',
255 argumentHint: `[${STYLES.join('|')}]`,
256 immediate: true,
257 })
258 .catch(() => {})
259 await $.command
260 .register({
261 name: THEME_COMMAND,
262 description: '입력창 아래 대시보드 색을 바꿔요. 이름을 빼면 다음 색으로 바뀌어요',
263 argumentHint: `[${THEME_NAMES.join('|')}]`,
264 immediate: true,
265 })
266 .catch(() => {})
267 void pollFable($)
268 $.clock.every(USAGE_EVERY_MS, () => {
269 void pollFable($)
270 })
271 $.clock.every(REFRESH_MS, () => {
272 void refresh($)
273 })
274}
275
276export const register: Register = on => {
277 on('session.start', async ($, e, next) => {
278 const result = await next(e)
279 await start($)
280 return result
281 })
282 on('prompt.submit', async ($, e, next) => {
283 const result = await next(e)
284 await start($)
285 await refresh($)
286 return result
287 })
288 // 요청은 그대로 흘려보내고, 메인 대화의 effort만 받아 둔다.
289 on('turn.step', async function* ($, e, next) {
290 if (e.agentId === undefined && typeof e.effort === 'string' && e.effort !== (await read($, effort))) {
291 const level = e.effort
292 await update($, effort, () => level)
293 void refresh($)
294 }
295 return yield* next(e)
296 })
297 on('turn.complete', async ($, e, next) => {
298 const result = await next(e)
299 await start($)
300 await refresh($)
301 return result
302 })
303
304 // 받기만 하고 이벤트는 그대로 흘려보낸다.
305 on('classic.SessionStart', async ($, e, next) => {
306 await notePermissionMode($, e)
307 return next(e)
308 })
309 on('classic.UserPromptSubmit', async ($, e, next) => {
310 await notePermissionMode($, e)
311 return next(e)
312 })
313
314 // /dashboard-style [이름]: 모양을 바꾼다. 이름이 없으면 다음 모양으로 넘어간다.
315 on('command.run', { command: STYLE_COMMAND }, async ($, e) => {
316 const asked = e.args.trim()
317 const now = await currentStyle($)
318 const next = asked === '' ? (STYLES[(STYLES.indexOf(now) + 1) % STYLES.length] ?? 'gauge') : (asked as Style)
319 if (!STYLES.includes(next)) {
320 return { text: `없는 모양이에요. ${STYLES.join(', ')} 중에서 골라 주세요.` }
321 }
322 await update($, style, () => next)
323 await $.store.set(STYLE_KEY, next)
324 await refresh($)
325 return { text: `대시보드 모양을 ${STYLE_NAMES[next]}(${next})로 바꿨어요.` }
326 })
327
328 // /dashboard-theme [이름]: 색을 바꾼다. 이름이 없으면 다음 색으로 넘어간다.
329 on('command.run', { command: THEME_COMMAND }, async ($, e) => {
330 const asked = e.args.trim()
331 const now = await currentTheme($)
332 const next = asked === '' ? (THEME_NAMES[(THEME_NAMES.indexOf(now) + 1) % THEME_NAMES.length] ?? 'everforest') : (asked as Theme)
333 if (!THEME_NAMES.includes(next)) {
334 return { text: `없는 색이에요. ${THEME_NAMES.join(', ')} 중에서 골라 주세요.` }
335 }
336 await update($, theme, () => next)
337 await $.store.set(THEME_KEY, next)
338 await refresh($)
339 return { text: `대시보드 색을 ${next}로 바꿨어요.` }
340 })
341
342 // 사람이 /usage를 실행하면 그 출력에서 이번 주 Fable 사용량을 읽어 둔다. 모드가 /usage를 직접 부르면 대화에 출력이 남아서 부르지 않는다.
343 on('command.run', { command: 'usage' }, async ($, e, next) => {
344 const result = await next(e)
345 const found = parseUsageLimits(result.text ?? '', await $.clock.now())
346 if (found.length > 0) {
347 await saveUsage($, found)
348 void refresh($)
349 }
350 return result
351 })
352
353 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
354 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
355 return next(e)
356 }
357 const below = await next(e)
358 const taken = await read($, snapshot)
359 hintedMode = modeFromHint(e.props.hint) ?? hintedMode
360 const pull = pullFor(hintedMode ?? (await read($, permissionMode)))
361 if (taken === null) {
362 return withSection(below, e.props.hint, 'dashboard', ORDER, null, pull)
363 }
364 // 넓으면 게이지 모양, 좁으면 이름표 위 값 아래 표로 바꾼다. 너비를 모르면 넓다고 본다.
365 const width = e.viewport?.columns
366 const layout = buildLayout(taken, await currentStyle($), await currentTheme($), width === undefined ? undefined : width - 2)
367
368 const { Box, Text } = $.ui.resolve(e)
369 // 색이 지정되지 않은 글자는 원래 상태줄처럼 흐리게(회색) 그린다. 그대로 두면 새하얗게 보인다.
370 const line = (row: Segment[], key: string) => (
371 <Text key={key} wrap="truncate-end">
372 {row.map(seg => (
373 <Text color={seg.color} bold={seg.bold} dimColor={seg.dim === true || seg.color === undefined}>
374 {seg.text}
375 </Text>
376 ))}
377 </Text>
378 )
379 const section =
380 layout.kind === 'rows' ? (
381 <Box flexDirection="row" columnGap={1}>
382 {layout.spine !== undefined && (
383 <Box key="spine" flexDirection="column" flexShrink={0}>
384 {layout.rows.map((_, i) => (
385 <Text key={`spine:${i}`} color={layout.spine}>
386 ▌
387 </Text>
388 ))}
389 </Box>
390 )}
391 <Box key="rows" flexDirection="column">
392 {layout.rows.map((row, i) => line(row, `row:${i}`))}
393 </Box>
394 </Box>
395 ) : (
396 <Box flexDirection="row" justifyContent="space-between" columnGap={GAPS.left}>
397 {/* 맨 앞 세로줄과 왼쪽 구역을 한 묶음으로 둔다. 세로줄 색은 컨텍스트가 차면 경고 색을 따른다. */}
398 <Box key="lead" flexDirection="row" columnGap={1} flexShrink={0}>
399 {layout.spine !== undefined && (
400 <Box key="spine" flexDirection="column">
401 {(layout.left.length > 0 ? layout.left : (layout.columns[0] ?? [])).map((_, i) => (
402 <Text key={`spine:${i}`} color={layout.spine}>
403 ▌
404 </Text>
405 ))}
406 </Box>
407 )}
408 {layout.left.length > 0 && (
409 <Box key="left" flexDirection="column">
410 {layout.left.map((row, i) => line(row, `left:${i}`))}
411 </Box>
412 )}
413 </Box>
414 <Box key="columns" flexDirection="row" columnGap={GAPS.column} flexShrink={0}>
415 {layout.columns.map((col, i) => (
416 <Box key={`col:${i}`} flexDirection="column">
417 {col.map((cell, j) => line(cell, `cell:${i}:${j}`))}
418 </Box>
419 ))}
420 </Box>
421 </Box>
422 )
423
424 return withSection(below, e.props.hint, 'dashboard', ORDER, section, pull)
425 })
426}
427hooks/footer.ts 114 lines1// 입력창 아래 안내 줄(PromptHint)을 여러 모드가 나눠 쓰는 약속.
2// 지금은 dashboard만 쓰지만, 다른 모드도 같은 틀에 구역을 끼워 넣을 수 있게 남겨 둔다.
3//
4// 안내 줄 자리에는 한 트리만 그려지므로, 각 모드는 아래 모양의 "바닥 틀"을 주고받는다.
5// Box(key=oobg-footer, column)
6// Text(안내 문구) ← 권한 모드 표시 바로 뒤에 붙는 첫 줄
7// Box(key=oobg-footer:<순서>:<이름>) ← 각 모드의 구역, 순서대로
8// 모드는 next(e)로 받은 틀에 자기 구역만 끼워 넣어 돌려준다. 틀이 아니면 새로 만든다.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const FOOTER_KEY = 'oobg-footer'
12const SECTION_PREFIX = `${FOOTER_KEY}:`
13
14// 권한 모드마다 안내 줄 맨 앞에 엔진이 그리는 표시("▸▸ … on · ")의 폭.
15// 둘째 줄부터는 이만큼 왼쪽으로 당겨야 화면 왼쪽 끝에 맞는다. 모르는 모드는 당기지 않는다.
16const PILL_WIDTH: Record<string, number> = {
17 default: 19, // ⏸ manual mode on ·
18 acceptEdits: 21, // ▸▸ accept edits on ·
19 plan: 17, // ⏸ plan mode on ·
20 bypassPermissions: 27, // ▸▸ bypass permissions on ·
21 auto: 18, // ▸▸ auto mode on ·
22}
23
24// 지금 권한 모드는 세션 기록 파일의 마지막 "permissionMode"에서 찾는다. 기록 파일 내용을 받아 그 값을 고른다.
25// ($를 쓰는 파일 읽기는 각 모드의 register 안에 있다. $는 다른 파일로 넘길 수 없다.)
26export function lastPermissionMode(transcript: string): string | null {
27 const found = [...transcript.matchAll(/"permissionMode":"([^"]+)"/g)]
28 return found.at(-1)?.[1] ?? null
29}
30
31// Claude Code 실행 줄에서 처음 권한 모드를 읽는다. 플래그가 없으면 null(설정의 기본값을 따른다).
32export function launchPermissionMode(args: string): string | null {
33 if (/(^|\s)--dangerously-skip-permissions(\s|$)/.test(args)) {
34 return 'bypassPermissions'
35 }
36 const found = /(^|\s)--permission-mode[ =](\S+)/.exec(args)?.[2]
37 return found ?? null
38}
39
40// Shift+Tab으로 권한 모드를 바꾸면 엔진은 안내 줄을 새 모드 이름(`plan mode on (shift+tab to cycle)`)으로 한 번 그린 뒤
41// 이름 없이 다시 그린다. 모드가 바뀌었다는 이벤트는 따로 없어서, 이 이름을 보고 지금 모드를 안다. 이름이 없으면 null.
42const HINT_LABELS: Record<string, string> = {
43 'manual mode on': 'default',
44 'accept edits on': 'acceptEdits',
45 'plan mode on': 'plan',
46 'bypass permissions on': 'bypassPermissions',
47 'auto mode on': 'auto',
48}
49
50export function modeFromHint(hint: string): string | null {
51 const label = Object.keys(HINT_LABELS).find(name => hint.startsWith(name))
52 return label === undefined ? null : (HINT_LABELS[label] ?? null)
53}
54
55export function pullFor(permissionMode: string | null): number {
56 return permissionMode === null ? 0 : (PILL_WIDTH[permissionMode] ?? 0)
57}
58
59function isElement(node: RenderNode | undefined | null): node is RenderElement {
60 return typeof node === 'object' && node !== null && 'type' in node
61}
62
63function keyOf(node: RenderNode): string | undefined {
64 if (!isElement(node) || node.type !== 'Box') {
65 return undefined
66 }
67 const key = node.props?.key
68 return typeof key === 'string' ? key : undefined
69}
70
71function orderOf(node: RenderNode): number {
72 return Number(keyOf(node)?.split(':')[1] ?? 0)
73}
74
75// `below`(next(e)가 돌려준 트리)에 이 모드의 구역을 넣은 바닥 틀을 만든다.
76// `section`이 null이면 이 모드의 구역을 뺀다(예: 편집 중이라 버튼을 숨길 때).
77export function withSection(
78 below: RenderNode | null | undefined,
79 hint: string,
80 name: string,
81 order: number,
82 section: RenderNode | null,
83 pull: number,
84): RenderElement {
85 const ownKey = `${SECTION_PREFIX}${order}:${name}`
86 const others =
87 isElement(below) && below.type === 'Box' && below.props?.key === FOOTER_KEY
88 ? (below.children ?? []).filter(child => {
89 const key = keyOf(child)
90 return key !== undefined && key.startsWith(SECTION_PREFIX) && key !== ownKey
91 })
92 : []
93
94 const mine: RenderNode[] =
95 section === null
96 ? []
97 : [{ type: 'Box', props: { key: ownKey, flexDirection: 'column' }, children: [section] }]
98
99 // 구역마다 모드가 다르게 당기지 않도록, 끼워 넣을 때마다 모든 구역의 당김을 지금 값으로 맞춘다.
100 const sections = [...others, ...mine]
101 .sort((a, b) => orderOf(a) - orderOf(b))
102 .map(child =>
103 isElement(child) && child.type === 'Box'
104 ? { ...child, props: { ...child.props, marginLeft: -pull } }
105 : child,
106 )
107
108 return {
109 type: 'Box',
110 props: { key: FOOTER_KEY, flexDirection: 'column' },
111 children: [{ type: 'Text', props: { dimColor: true }, children: [hint] }, ...sections],
112 }
113}
114hooks/lines.ts 451 lines1// 세션 값으로 대시보드 줄을 짜는 부분. $를 쓰지 않는 순수 함수만 둔다.
2// 화면은 이 모드가 정한 모양이다. 다른 상태줄 도구의 코드를 옮기지 않았다.
3import type { Layout, Segment, Snapshot, Style, Theme } from '../types'
4
5export type { Snapshot }
6
7export const STYLES: readonly Style[] = ['gauge', 'calm', 'emoji', 'compact']
8
9const BAR_CELLS = 10
10const SEPARATOR: Segment = { text: ' │ ', dim: true }
11
12// 색은 칸마다 따로 주지 않고 역할로 묶는다. 어두운 배경에서는 색 수를 줄이고 채도를 낮춰야 눈이 편하고,
13// 상태 색도 같은 밝기로 맞춰야 한 칸만 튀지 않는다. 테마마다 널리 쓰이는 터미널 색 구성표의 값을 가져왔다.
14// accent: 모델 이름 primary: 폴더 secondary: 브랜치
15// text: 숫자(토큰, 요금, 시간, 분당 토큰) muted: 이름표(한도 이름, 이메일)
16// ok / warn / orange / high: 컨텍스트와 한도가 찬 정도(40%까지 여유, 55% 노랑, 72% 주황, 90% 빨강으로 섞어 바뀐다)
17export type Palette = {
18 accent: string
19 primary: string
20 secondary: string
21 text: string
22 muted: string
23 ok: string
24 warn: string
25 orange: string
26 high: string
27}
28
29export const THEMES: Record<Theme, Palette> = {
30 // Everforest: 숲 색. 초록과 베이지 위주로 따뜻하다.
31 everforest: {
32 accent: '#a7c080',
33 primary: '#7fbbb3',
34 secondary: '#d699b6',
35 text: '#d3c6aa',
36 muted: '#859289',
37 ok: '#a7c080',
38 warn: '#dbbc7f',
39 orange: '#e69875',
40 high: '#e67e80',
41 },
42 // Nord: 차가운 푸른 회색 계열. 대비가 낮고 색이 적다.
43 nord: {
44 accent: '#88c0d0',
45 primary: '#81a1c1',
46 secondary: '#b48ead',
47 text: '#d8dee9',
48 muted: '#8691a8',
49 ok: '#a3be8c',
50 warn: '#ebcb8b',
51 orange: '#d08770',
52 high: '#bf616a',
53 },
54 // Rosé Pine Moon: 보라와 장밋빛이 섞인 따뜻한 어둠.
55 'rose-pine': {
56 accent: '#c4a7e7',
57 primary: '#9ccfd8',
58 secondary: '#ea9a97',
59 text: '#e0def4',
60 muted: '#908caa',
61 ok: '#3e8fb0',
62 warn: '#f6c177',
63 orange: '#ea9a97',
64 high: '#eb6f92',
65 },
66 // Kanagawa: 일본 판화 색을 딴 차분한 남색과 황토.
67 kanagawa: {
68 accent: '#7e9cd8',
69 primary: '#7fb4ca',
70 secondary: '#957fb8',
71 text: '#dcd7ba',
72 muted: '#727169',
73 ok: '#98bb6c',
74 warn: '#e6c384',
75 orange: '#ffa066',
76 high: '#e46876',
77 },
78}
79
80export const THEME_NAMES = Object.keys(THEMES) as Theme[]
81
82// effort 단계를 한 글자로 줄인다. 모르는 값(숫자 예산 등)은 그대로 보인다.
83const EFFORT_MARK: Record<string, string> = { low: 'L', medium: 'M', high: 'H', xhigh: 'X', max: 'Max' }
84
85// "claude-opus-5-5"에서 모델 계열 이름("Opus")만 꺼낸다.
86export function family(model: string): string {
87 const name = /^claude-([a-z]+)/.exec(model)?.[1]
88 return name === undefined ? model : name.replace(/^./, c => c.toUpperCase())
89}
90
91// 토큰 수를 409K, 1.0M처럼 줄인다.
92export function compactTokens(n: number): string {
93 if (n >= 1_000_000) {
94 return `${(n / 1_000_000).toFixed(1)}M`
95 }
96 if (n >= 1_000) {
97 return n >= 10_000 ? `${Math.round(n / 1_000)}K` : `${(n / 1_000).toFixed(1)}K`
98 }
99 return String(Math.round(n))
100}
101
102export function duration(ms: number): string {
103 const minutes = Math.max(0, Math.floor(ms / 60_000))
104 const hours = Math.floor(minutes / 60)
105 return hours === 0 ? `${minutes}분` : `${hours}시간 ${minutes % 60}분`
106}
107
108// 찬 정도의 색. 여유 색으로 있다가 노랑, 주황, 빨강으로 부드럽게 넘어간다(칸마다 색을 섞어 그라데이션을 만든다).
109const LEVEL_STOPS: readonly [number, keyof Palette][] = [
110 [0, 'ok'],
111 [40, 'ok'],
112 [55, 'warn'],
113 [72, 'orange'],
114 [90, 'high'],
115]
116
117function mix(from: string, to: string, t: number): string {
118 const rgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
119 const [a, b] = [rgb(from), rgb(to)]
120 return `#${a.map((v, i) => Math.round(v + ((b[i] ?? v) - v) * t).toString(16).padStart(2, '0')).join('')}`
121}
122
123export function levelColor(c: Palette, percent: number): string {
124 const p = Math.min(100, Math.max(0, percent))
125 for (let i = 1; i < LEVEL_STOPS.length; i++) {
126 const [hiAt, hiKey] = LEVEL_STOPS[i] as [number, keyof Palette]
127 const [loAt, loKey] = LEVEL_STOPS[i - 1] as [number, keyof Palette]
128 if (p <= hiAt) {
129 return mix(c[loKey], c[hiKey], hiAt === loAt ? 1 : (p - loAt) / (hiAt - loAt))
130 }
131 }
132 return c.high
133}
134
135const filledCells = (percent: number) => Math.min(BAR_CELLS, Math.max(0, Math.round(percent / (100 / BAR_CELLS))))
136// i번째 칸이 나타내는 위치(%). 칸의 오른쪽 끝을 쓴다.
137const cellAt = (i: number) => ((i + 1) / BAR_CELLS) * 100
138
139function joined(parts: Segment[][]): Segment[] {
140 return parts.flatMap((part, i) => (i === 0 ? part : [SEPARATOR, ...part]))
141}
142
143// `/usage`에서 "Current week (Fable): 12% used" 줄의 숫자를 꺼낸다. 없으면 null.
144export function parseFableWeek(usageText: string): number | null {
145 const found = /Current week \(Fable\):\s*([\d.]+)%/.exec(usageText)?.[1]
146 return found === undefined ? null : Number(found)
147}
148
149export type PolledLimit = { kind: 'five_hour' | 'seven_day' | 'fable'; percentUsed: number; resetsAt?: string }
150
151const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
152
153// "Oct 14 at 4:59pm", "Oct 17 at 6pm"을 이 컴퓨터 시간대의 시각으로 읽는다. 지난 날짜면 내년으로 본다.
154export function parseResetTime(text: string, nowMs: number): string | undefined {
155 const m = /([A-Za-z]{3})\w*\s+(\d{1,2})\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i.exec(text)
156 if (m === null) {
157 return undefined
158 }
159 const month = MONTHS.indexOf((m[1] ?? '').toLowerCase())
160 if (month < 0) {
161 return undefined
162 }
163 let hour = Number(m[3]) % 12
164 if ((m[5] ?? '').toLowerCase() === 'pm') {
165 hour += 12
166 }
167 const year = new Date(nowMs).getFullYear()
168 const at = (y: number) => new Date(y, month, Number(m[2]), hour, Number(m[4] ?? 0)).getTime()
169 const time = at(year) < nowMs - 24 * 60 * 60_000 ? at(year + 1) : at(year)
170 return new Date(time).toISOString()
171}
172
173// /usage 출력에서 5시간(Current session), 주간(Current week (all models)), Fable 한도를 읽는다.
174// 첫 응답이 오기 전에는 Claude Code가 한도 값을 갖고 있지 않아서, 그동안 이 값을 쓴다.
175export function parseUsageLimits(usageText: string, nowMs: number): PolledLimit[] {
176 const rows: [PolledLimit['kind'], RegExp][] = [
177 ['five_hour', /Current session:\s*([\d.]+)% used(?:\s*·\s*resets\s+([^\n(]+))?/],
178 ['seven_day', /Current week \(all models\):\s*([\d.]+)% used(?:\s*·\s*resets\s+([^\n(]+))?/],
179 ['fable', /Current week \(Fable\):\s*([\d.]+)% used(?:\s*·\s*resets\s+([^\n(]+))?/],
180 ]
181 const out: PolledLimit[] = []
182 for (const [kind, re] of rows) {
183 const m = re.exec(usageText)
184 if (m !== null) {
185 const resetsAt = m[2] === undefined ? undefined : parseResetTime(m[2], nowMs)
186 out.push({ kind, percentUsed: Number(m[1]), ...(resetsAt === undefined ? {} : { resetsAt }) })
187 }
188 }
189 return out
190}
191
192type Parts = {
193 model: Segment[]
194 bar: Segment[] | null
195 percent: Segment[] | null
196 tokens: Segment[] | null
197 cost: Segment[] | null
198 limits: Segment[][]
199 folder: Segment[]
200 email: Segment[] | null
201 time: Segment[]
202 rate: Segment[] | null
203}
204
205// 칸마다 조각을 만든다. `icons`가 켜지면 칸 앞에 이모지를 붙인다.
206function parts(s: Snapshot, icons: boolean, c: Palette): Parts {
207 const icon = (emoji: string) => (icons ? `${emoji} ` : '')
208 const mark = s.effort === null ? '' : `(${EFFORT_MARK[s.effort] ?? s.effort})`
209 const percent =
210 s.context.percent ?? (s.context.tokens === undefined ? undefined : (s.context.tokens / s.context.window) * 100)
211 const level = (value: number) => ({ color: levelColor(c, value) })
212
213 const limits: Segment[][] = []
214 // 한도 이름은 이름표 색, 비율은 찬 정도에 따른 색.
215 const limit = (label: string, value: number) => [
216 { text: `${label} `, color: c.muted },
217 { text: `${Math.round(value)}%`, ...level(value) },
218 ]
219 const five = s.rateLimits.find(r => r.kind === 'five_hour')
220 const week = s.rateLimits.find(r => r.kind === 'seven_day')
221 if (five !== undefined) {
222 limits.push(limit(`${icon('⏳')}5시간`, five.percentUsed))
223 }
224 if (week !== undefined) {
225 limits.push(limit(`${icon('📅')}7일`, week.percentUsed))
226 }
227 if (s.fableWeek !== null) {
228 limits.push(limit(`${icon('🪶')}Fable`, s.fableWeek))
229 }
230
231 const minutes = s.elapsedMs / 60_000
232 return {
233 model: [{ text: `${icons ? '🧠 ' : '◆ '}${family(s.model)}${mark}`, color: c.accent, bold: true }],
234 bar:
235 percent === undefined
236 ? null
237 : (() => {
238 // 칸마다 그 위치의 색을 칠해, 차오를수록 끝쪽이 노랑, 주황, 빨강으로 물든다.
239 const cells: Segment[] = Array.from({ length: filledCells(percent) }, (_, i) => ({
240 text: '█',
241 ...level(cellAt(i)),
242 }))
243 return [...cells, { text: '░'.repeat(BAR_CELLS - cells.length), dim: true }]
244 })(),
245 // 비율 숫자는 막대 마지막 칸과 같은 색이다.
246 percent:
247 percent === undefined
248 ? null
249 : [{ text: `${Math.round(percent)}%`, ...level(filledCells(percent) === 0 ? 0 : cellAt(filledCells(percent) - 1)) }],
250 tokens:
251 s.context.tokens === undefined
252 ? null
253 : [{ text: `${icon('💬')}${compactTokens(s.context.tokens)}/${compactTokens(s.context.window)}`, color: c.text }],
254 cost: s.costUsd === null ? null : [{ text: `${icon('💰')}$${s.costUsd.toFixed(2)}`, color: c.text }],
255 limits,
256 folder: [
257 { text: `📁 ${s.folder}`, color: c.primary },
258 ...(s.branch === null ? [] : [{ text: icons ? ` 🌿 ${s.branch}` : ` (${s.branch})`, color: c.secondary }]),
259 ],
260 email: s.email === null ? null : [{ text: `🔑 ${s.email}`, color: c.muted }],
261 time: [{ text: `⏱ ${duration(s.elapsedMs)}`, color: c.text }],
262 // 분당 토큰: 지금 컨텍스트에 쌓인 토큰을 세션 시간(분)으로 나눈 값. 첫 1분은 계산하지 않는다.
263 rate:
264 s.context.tokens === undefined || minutes < 1
265 ? null
266 : [{ text: `🔥 ${compactTokens(s.context.tokens / minutes)}/min`, color: c.text }],
267 }
268}
269
270const present = (list: (Segment[] | null)[]): Segment[][] => list.filter((p): p is Segment[] => p !== null)
271
272export function buildLines(s: Snapshot, style: Style = 'calm', theme: Theme = 'everforest'): Segment[][] {
273 const c = THEMES[theme]
274 const p = parts(s, style === 'emoji', c)
275 if (style === 'compact') {
276 // 한 줄: 모델, 비율, 요금, 한도, 브랜치만.
277 const branch = s.branch === null ? null : [{ text: `🌿 ${s.branch}`, color: c.secondary }]
278 return [joined(present([p.model, p.percent, p.cost, ...p.limits, branch]))]
279 }
280 const bar = p.bar === null ? null : [...p.bar, ...(p.percent === null ? [] : [{ text: ' ' }, ...p.percent])]
281 return [
282 joined(present([p.model, bar, p.tokens, p.cost, ...p.limits])),
283 joined(present([p.folder, p.email, p.time, p.rate])),
284 ]
285}
286
287// 한도가 다시 차기까지 남은 시간. 게이지 이름표(5h, 7d)와 단위를 맞추고 단위 사이를 /로 나눠 "2h/13m", "3d/4h"처럼 쓴다.
288export function resetLabel(resetsAt: string | undefined, nowMs: number): string | null {
289 const at = resetsAt === undefined ? NaN : Date.parse(resetsAt)
290 if (Number.isNaN(at)) {
291 return null
292 }
293 const minutes = Math.max(0, Math.ceil((at - nowMs) / 60_000))
294 if (minutes < 60) {
295 return `${minutes}m`
296 }
297 const hours = Math.floor(minutes / 60)
298 if (hours < 24) {
299 return `${hours}h/${minutes % 60}m`
300 }
301 return `${Math.floor(hours / 24)}d/${hours % 24}h`
302}
303
304// 게이지 한 칸: "5h ▰▱▱▱▱ 6%". 막대는 칸마다 색을 섞고, 숫자는 마지막 칸 색을 따른다.
305function gauge(c: Palette, label: string, percent: number, cells: number): Segment[] {
306 const filled = Math.min(cells, Math.max(0, Math.round(percent / (100 / cells))))
307 const at = (i: number) => ((i + 1) / cells) * 100
308 return [
309 { text: `${label} `, color: c.text },
310 ...Array.from({ length: filled }, (_, i) => ({ text: '▰', color: levelColor(c, at(i)) })),
311 { text: '▱'.repeat(cells - filled), dim: true },
312 { text: ` ${String(Math.round(percent)).padStart(2)}%`, color: levelColor(c, filled === 0 ? 0 : at(filled - 1)) },
313 ]
314}
315
316// gauge 모양: 윗줄은 게이지만, 각 게이지 옆에 흐린 글자로 딸린 정보(토큰, 다시 차기까지 남은 시간)를 붙인다.
317// 아랫줄은 무엇으로 어디서 일하는지. 맨 앞 세로줄이 두 줄을 묶는다.
318// ▌ ctx ▰▰▰▰▱▱▱▱▱▱ 41% (409K/1.0M) 5h ▰▱▱▱▱ 6% (2h/13m) 7d ▰▱▱▱▱ 26% (3d/4h) fable ▰▱▱▱▱ 12% (3d/4h)
319// ▌ 🧠 Opus xhigh · 📁 claude-code-mods ⎇ main · 🔑 you@example.com
320function gaugeLayout(s: Snapshot, c: Palette): Layout {
321 const gap: Segment = { text: ' ' }
322 const note = (text: string | null): Segment[] => (text === null ? [] : [{ text: ` ${text}`, dim: true }])
323 // 쓴 토큰과 다시 차기까지 남은 시간은 괄호로 감싸 비율 숫자와 구분한다.
324 const left = (text: string | null) => note(text === null ? null : `(${text})`)
325 const gauges: Segment[][] = []
326 const percent =
327 s.context.percent ?? (s.context.tokens === undefined ? undefined : (s.context.tokens / s.context.window) * 100)
328 if (percent !== undefined) {
329 const tokens =
330 s.context.tokens === undefined ? null : `${compactTokens(s.context.tokens)}/${compactTokens(s.context.window)}`
331 gauges.push([...gauge(c, 'ctx', percent, BAR_CELLS), ...left(tokens)])
332 }
333 const now = s.nowMs ?? Date.now()
334 const five = s.rateLimits.find(r => r.kind === 'five_hour')
335 const week = s.rateLimits.find(r => r.kind === 'seven_day')
336 if (five !== undefined) {
337 gauges.push([...gauge(c, '5h', five.percentUsed, 5), ...left(resetLabel(five.resetsAt, now))])
338 }
339 if (week !== undefined) {
340 gauges.push([...gauge(c, '7d', week.percentUsed, 5), ...left(resetLabel(week.resetsAt, now))])
341 }
342 if (s.fableWeek !== null) {
343 // Fable 주간 한도는 전체 주간 한도와 같은 때 다시 찬다(/usage에서 확인).
344 gauges.push([...gauge(c, 'fable', s.fableWeek, 5), ...left(resetLabel(s.fableResetsAt ?? week?.resetsAt, now))])
345 }
346
347 const dot: Segment = { text: ' · ', dim: true }
348 const place: Segment[][] = [
349 [
350 { text: `🧠 ${family(s.model)}`, color: c.accent, bold: true },
351 ...(s.effort === null ? [] : [{ text: ` ${s.effort}`, dim: true }]),
352 ],
353 [
354 { text: `📁 ${s.folder}`, color: c.primary },
355 ...(s.branch === null ? [] : [{ text: ` ⎇ ${s.branch}`, color: c.secondary }]),
356 ],
357 ...(s.email === null ? [] : [[{ text: `🔑 ${s.email}`, color: c.muted }]]),
358 ]
359
360 // 맨 앞 세로줄: 평소에는 모델 색, 컨텍스트가 50%를 넘으면 게이지와 같은 그라데이션 색.
361 const spine = percent !== undefined && percent >= 50 ? levelColor(c, percent) : c.accent
362 const rows = [
363 gauges.flatMap((g, i) => (i === 0 ? g : [gap, ...g])),
364 place.flatMap((p, i) => (i === 0 ? p : [dot, ...p])),
365 ].filter(row => row.length > 0)
366 return { kind: 'rows', rows, spine }
367}
368
369// 맨 앞 세로줄과 그 뒤 한 칸이 차지하는 폭.
370export const SPINE_WIDTH = 2
371
372// 터미널에서 차지하는 칸 수. 한글과 이모지는 두 칸이다.
373export function cellWidth(text: string): number {
374 let width = 0
375 for (const ch of text) {
376 const cp = ch.codePointAt(0) ?? 0
377 if (cp === 0xfe0f || cp === 0x200d) {
378 continue
379 }
380 const wide =
381 (cp >= 0x1100 && cp <= 0x115f) ||
382 (cp >= 0x2e80 && cp <= 0xa4cf) ||
383 (cp >= 0xac00 && cp <= 0xd7a3) ||
384 (cp >= 0xf900 && cp <= 0xfaff) ||
385 (cp >= 0xff00 && cp <= 0xff60) ||
386 (cp >= 0x23e9 && cp <= 0x23fa) ||
387 (cp >= 0x1f300 && cp <= 0x1faff)
388 width += wide ? 2 : 1
389 }
390 return width
391}
392
393const rowWidth = (row: Segment[]) => row.reduce((sum, seg) => sum + cellWidth(seg.text), 0)
394const LEFT_GAP = 4
395const COLUMN_GAP = 3
396
397// 레이아웃이 차지하는 폭. 그리는 쪽(register)도 같은 간격을 쓴다.
398export function layoutWidth(layout: Layout): number {
399 const spine = layout.spine === undefined ? 0 : SPINE_WIDTH
400 if (layout.kind === 'rows') {
401 return spine + Math.max(0, ...layout.rows.map(rowWidth))
402 }
403 const left = Math.max(0, ...layout.left.map(rowWidth))
404 const columns = layout.columns.map(col => Math.max(0, ...col.map(rowWidth)))
405 const right = columns.reduce((a, b) => a + b, 0) + COLUMN_GAP * Math.max(0, columns.length - 1)
406 return spine + (left > 0 ? left + LEFT_GAP : 0) + right
407}
408
409export const GAPS = { left: LEFT_GAP, column: COLUMN_GAP }
410
411// 좁은 화면용: 이름표 줄 위, 값 줄 아래. 모델부터 브랜치까지 열을 맞춘다.
412// MODEL CONTEXT 5H 7D FABLE BRANCH
413// 🧠 Opus xhigh ▰▰▰▱▱▱▱▱ 41% 6% 26% 12% ⎇ main
414function tilesLayout(s: Snapshot, c: Palette): Layout {
415 const head = (text: string): Segment[] => [{ text, color: c.muted }]
416 const pct = (value: number): Segment[] => [{ text: `${Math.round(value)}%`, color: levelColor(c, value) }]
417 const columns: Segment[][][] = [
418 [head('MODEL'), [{ text: `🧠 ${family(s.model)}`, color: c.accent, bold: true }, ...(s.effort === null ? [] : [{ text: ` ${s.effort}`, dim: true }])]],
419 ]
420 const percent =
421 s.context.percent ?? (s.context.tokens === undefined ? undefined : (s.context.tokens / s.context.window) * 100)
422 if (percent !== undefined) {
423 columns.push([head('CONTEXT'), gauge(c, '', percent, 8).slice(1)])
424 }
425 const five = s.rateLimits.find(r => r.kind === 'five_hour')
426 const week = s.rateLimits.find(r => r.kind === 'seven_day')
427 if (five !== undefined) {
428 columns.push([head('5H'), pct(five.percentUsed)])
429 }
430 if (week !== undefined) {
431 columns.push([head('7D'), pct(week.percentUsed)])
432 }
433 if (s.fableWeek !== null) {
434 columns.push([head('FABLE'), pct(s.fableWeek)])
435 }
436 if (s.branch !== null) {
437 columns.push([head('BRANCH'), [{ text: `⎇ ${s.branch}`, color: c.secondary }]])
438 }
439 const spine = percent !== undefined && percent >= 50 ? levelColor(c, percent) : c.accent
440 return { kind: 'split', left: [], columns, spine }
441}
442
443// `columns`(그릴 수 있는 칸 수)가 주어지면, gauge 모양이 들어가지 않을 때 표 모양으로 바꾼다.
444export function buildLayout(s: Snapshot, style: Style = 'gauge', theme: Theme = 'everforest', columns?: number): Layout {
445 if (style !== 'gauge') {
446 return { kind: 'rows', rows: buildLines(s, style, theme) }
447 }
448 const wide = gaugeLayout(s, THEMES[theme])
449 return columns !== undefined && layoutWidth(wide) > columns ? tilesLayout(s, THEMES[theme]) : wide
450}
451types/index.d.ts 53 lines1/** 대시보드 한 줄을 색이 같은 조각으로 나눈 것. `color`는 테마 이름이고, 없으면 흐리게 그린다. */
2export type Segment = { text: string; color?: string; bold?: boolean; dim?: boolean }
3
4/**
5 * 대시보드 모양: 왼쪽 구역과 게이지 열을 나눈 `gauge`(기본), 차분한 두 줄 `calm`,
6 * 칸마다 이모지를 붙인 `emoji`, 한 줄로 줄인 `compact`.
7 */
8export type Style = 'gauge' | 'calm' | 'emoji' | 'compact'
9
10/**
11 * 그릴 모양. `rows`는 줄 목록, `split`은 왼쪽 줄들과 오른쪽 열들(열마다 위아래 두 칸)이다.
12 * `spine`이 있으면 맨 앞에 그 색의 세로줄을 세워 두 줄을 한 덩어리로 묶는다.
13 */
14export type Layout =
15 | { kind: 'rows'; rows: Segment[][]; spine?: string }
16 | { kind: 'split'; left: Segment[][]; columns: Segment[][][]; spine?: string }
17
18/** 대시보드 색 구성: 널리 쓰이는 터미널 색 구성표에서 가져온 네 가지. */
19export type Theme = 'nord' | 'rose-pine' | 'kanagawa' | 'everforest'
20
21/** 대시보드를 그리는 데 쓰는 세션 값. 모양은 그릴 때 화면 너비를 보고 고른다. */
22export type Snapshot = {
23 model: string
24 effort: string | null
25 context: { tokens?: number; window: number; percent?: number }
26 costUsd: number | null
27 rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
28 /** 이번 주 Fable 한도를 쓴 정도. /usage에서 읽어 둔 값이고, 아직 없으면 null. */
29 fableWeek: number | null
30 /** Fable 주간 한도가 다시 차는 시각. /usage에서 읽었고, 없으면 주간 한도와 같은 때로 본다. */
31 fableResetsAt?: string
32 folder: string
33 branch: string | null
34 email: string | null
35 elapsedMs: number
36 /** 지금 시각(ms). 한도가 다시 차는 시각을 "38분 뒤"처럼 쓰는 데 쓴다. */
37 nowMs?: number
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 dashboard: {
43 snapshot: Snapshot | null
44 permissionMode: string | null
45 effort: string | null
46 layoutStyle: Style | null
47 theme: Theme | null
48 fableWeek: number | null
49 polledLimits: { kind: 'five_hour' | 'seven_day' | 'fable'; percentUsed: number; resetsAt?: string }[] | null
50 }
51 }
52}
53