mod 로 그리는 상태줄 — 외부 도구 없이 프롬프트 아래에 모델·git·컨텍스트·사용량을 띄운다. 줄과 항목 단위로 켜고 끄며 JSON 설정(~/.claude/statusline/settings.json)이나 /sl 명령으로 배치를 바꾼다. ccstatusline 의 위젯 86종과…

mod 로 그리는 상태줄. 외부 도구 없이 프롬프트 아래에 모델·git·컨텍스트·사용량을 띄운다.
statusLine 설정은 다시 그릴 때마다 명령 하나를 통째로 실행한다. 이 플러그인은 그 자리를 mod 로 바꾼다 — 값은 엔진이 이미 갖고 있는 것을 이벤트로 받고, 프로세스는 git 처럼 엔진 밖의 값이 필요할 때만 띄운다.
| 층 | 무엇 | 어디 |
|---|---|---|
| 설정 | 줄 → 항목, 각각 enabled | ~/.claude/statusline/settings.json |
| 판단 | 설정 다듬기·배치·위젯·명령 출력 해석 (순수 함수) | scripts/lib/ |
| 어댑터 | 값 모으기와 이벤트 배선 | hooks/register.mjs |
프롬프트 아래 힌트 줄(PromptHint) 밑이다. 엔진의 힌트 줄은 그대로 두고 그 아래에 줄을 더한다.
$.ui.status 는 쓰지 않는다. 실측해 보면 플러그인당 글자 한 줄이고, ANSI 색과 줄바꿈이 깨진 글자로 찍히며, 앞에 ⚠ <플러그인>: 이 붙는다 — 알림용이다.
파일이 없으면 기본 배치(한 줄)로 그린다. /sl init 이 파일을 만들고, /sl import 가 ccstatusline 의 설정(~/.config/ccstatusline/settings.json)을 옮겨 온다. 손으로 고친 파일은 다음 프롬프트에서 반영된다.
{
"version": 1,
"powerline": { "enabled": true, "separator": "" },
"theme": "nord",
"minimal": true,
"lines": [
{
"enabled": true,
"items": [
{ "type": "model", "merge": true },
{ "type": "thinking-effort" },
{ "type": "flex-separator" },
{ "type": "claude-session-id", "enabled": false }
]
}
]
}
| 키 | 뜻 |
|---|---|
lines[].enabled, items[].enabled | 줄·항목을 끈다. 꺼도 순서와 옵션은 남는다 |
powerline | 구간마다 배경을 칠하고 화살표로 잇는다. 끄면 separator 로 잇는다 |
theme | nord, nord-aurora, null. 구간마다 색을 돌려 쓰고 줄을 넘어 이어진다 |
minimal | 라벨 없이 값만 (Model: Opus 5.5 → Opus 5.5) |
refresh | 소스별 다시 모으는 주기(초): gitSeconds 5, apiSeconds 180, ghSeconds 120, statusSeconds 300, clockSeconds 30, customSeconds 30 |
항목 옵션:
| 옵션 | 뜻 |
|---|---|
color, bg | 전경·배경 (#RRGGBB). 테마를 이긴다 |
bold, dim | 굵게·흐리게 |
merge | 다음 항목과 한 구간으로 묶는다 (화살표·구분자 없이, 같은 테마 색) |
raw, label | 라벨을 떼거나 바꾼다. label 은 minimal 에서도 보인다 |
maxWidth | 넘으면 … 로 자른다 |
showZero | 0 이어서 숨는 값을 보이게 한다 |
text, symbol, url | custom-text, custom-symbol, link 의 내용 |
id, command | custom-command 의 이름과 argv 배열 (셸 없이 실행, 첫 줄만 쓴다) |
width, format | context-bar 의 칸 수(기본 16)와 percent(토큰 수 없이 막대와 퍼센트만) |
ttlMinutes | cache-timer 의 TTL |
format | git-review 의 number(제목 없이 번호만) |
틀린 곳이 있어도 상태줄은 뜬다. 모르는 위젯과 틀린 옵션만 버리고, 이유는 /sl 목록에 보인다.
/sl주소는 1부터 센다. 2 는 2번 줄, 2.3 은 2번 줄의 3번 항목이다.
| 명령 | 하는 일 | |||
|---|---|---|---|---|
/sl | 현재 배치, 설정의 문제, 값을 못 읽은 소스 | |||
| `/sl on\ | off <줄>[.<항목>]` | 켜고 끈다 | ||
/sl add <줄> <위젯> [위치], /sl rm <줄>.<항목> | 넣고 뺀다 | |||
/sl move <줄>.<항목> <줄>[.<위치>] | 옮긴다 | |||
/sl set <줄>.<항목> 키=값 … | 항목 옵션. 키= 는 지운다. label·text 의 _ 는 공백으로 읽는다 | |||
/sl line add, /sl line rm <줄> | 줄을 더하고 뺀다 | |||
| `/sl theme <이름>\ | off, /sl powerline on\ | off, /sl minimal on\ | off` | 전체 모양 |
/sl widgets | 쓸 수 있는 위젯 | |||
/sl init, /sl import [--force], /sl reload | 파일 만들기, ccstatusline 설정 가져오기, 다시 읽기 |
타입 id 는 ccstatusline 의 것을 그대로 쓴다. 전체 목록은 /sl widgets.
| 분류 | 값이 오는 곳 |
|---|---|
| 모델·버전·세션 id·작업 디렉터리 | $.session.* |
| 컨텍스트, 5시간·주간 사용량과 리셋, 비용 | $.session.usage() 와 session.measure 이벤트 — 네트워크 호출 없음 |
| 모델별 주간 사용량, 추가 사용량 | api.anthropic.com/api/oauth/usage. 자격 증명은 호스트가 헤더에 넣고 mod 에는 오지 않는다. 이 위젯이 켜져 있을 때만 부른다 |
| 노력 수준, 세션 이름 | 훅 입력 → 트랜스크립트 → 설정 순 |
| 출력 스타일, 음성, 샌드박스, 원격 제어 | /config 행과 설정 |
| 토큰 합계, 캐시, 속도, 압축 횟수 | 트랜스크립트로 재개 전까지를 되찾고 턴마다 더한다 |
| git | git status --porcelain=v2 --branch 하나에서 대부분. 변경 줄 수·원격·worktree 는 그 위젯이 켜졌을 때만 더 부른다 |
| PR·CI | gh pr view |
| 계정 이메일 | ~/.claude.json |
| 위젯 | 차이 |
|---|---|
vim-mode | 실시간 INSERT/NORMAL 을 mod 가 읽을 곳이 없다. 편집기 모드 설정(vim 인지)만 보인다 |
worktree-mode, worktree-name, worktree-branch | 세션이 --worktree 로 열렸는지가 아니라 현재 디렉터리가 linked worktree 인지로 판단한다 |
worktree-original-branch, jj-workspace | 없다. 앞은 값을 얻을 곳이 없고, 뒤는 만든 기기에 jj 가 없어 검증하지 못했다 |
input-speed, output-speed, total-speed | 턴 길이로 나눈다. 도구 실행 시간이 섞인 턴 평균이다 |
cache-timer | 캐시 TTL 이 노출되지 않아 ttlMinutes(기본 5)로 정한다 |
session-clock | 세션이 꺼져 있던 시간도 포함한다 |
link | 글자만 그린다. 눌리지 않는다 |
| 조건부 숨김 | 값이 없으면 자리 표시 없이 숨는다. 0 인 개수는 showZero 로만 보인다 |
| 테마 | nord, nord-aurora 둘. 그라디언트와 줄 간 자동 정렬은 없다 |
jj 위젯은 파서만 테스트했고 실제 jj 로 돌려 보지 못했다.
--safe-mode)에서는 아무것도 그리지 않는다PromptHint 가 있는 곳)statusLine 설정은 직접 지운다. 이 플러그인은 settings.json 을 건드리지 않는다node --test "scripts/test/*.test.mjs"
claude plugin validate .
순수 로직만 테스트한다 — 형식, 설정 다듬기와 가져오기, 배치, 위젯, 명령 출력 해석, /sl. 값을 모으는 쪽은 실측 대상이라 테스트하지 않는다. hooks/register.mjs 는 claude plugin validate 가 읽어 내는 hooks·calls 목록으로 확인한다.
hooks/
hooks.json mod 모듈 등록
register.mjs 어댑터: 값 모으기, 이벤트 배선, PromptHint 그리기, /sl
scripts/lib/
config.mjs 설정의 꼴·기본값·다듬기, ccstatusline 설정 옮기기
layout.mjs 설정 + 스냅숏 → 그릴 칸의 줄 (merge·구분자·테마·화살표)
widgets/ 위젯 레지스트리 (session · git · usage · env)
command.mjs /sl 인자 해석과 설정 편집
parse-git.mjs git · gh · jj · vm_stat 출력 해석
parse-transcript.mjs 트랜스크립트와 usage API 응답 해석
format.mjs 토큰 수·기간·막대·경로·모델 이름
themes.mjs 테마 색hooks/register.mjs 441 lines1/**
2 * statusline mod — 프롬프트 아래 힌트 줄 밑에 상태줄을 그린다.
3 *
4 * 여기는 어댑터다. 값을 모으는 일(`$` 를 부르는 모든 것)과 이벤트 배선만 있고, 무엇을 어떻게 그릴지는
5 * `scripts/lib/` 의 순수 함수가 정한다: 설정은 `config.mjs`, 배치는 `layout.mjs`, 위젯은 `widgets/`,
6 * 명령 출력의 해석은 `parse-*.mjs`, `/sl` 은 `command.mjs`.
7 *
8 * `$` 를 받는 함수는 모두 이 파일의 최상위에 둔다 — 엔진이 hooks module 을 읽을 때 `$` 가 어디로
9 * 가는지 이 파일 안에서 따라가며, 다른 파일로 넘기면 로드를 거부한다.
10 *
11 * 값을 모으다 실패한 소스는 상태줄을 막지 않는다. 그 칸만 비고, 이유는 `/sl` 목록에 보인다.
12 *
13 * @param {(event: string, ...rest: unknown[]) => unknown} on
14 */
15
16import { COMMAND_NAME, USAGE, applyEdit, describeConfig, describeWidgets, parseCommand } from '../scripts/lib/command.mjs';
17import { CCSTATUSLINE_RELATIVE_PATH, CONFIG_RELATIVE_PATH, DEFAULT_CONFIG, fromCcstatusline, normalizeConfig } from '../scripts/lib/config.mjs';
18import { activeItems, buildRows, neededSources } from '../scripts/lib/layout.mjs';
19import { parseGitStatus, parseJjLog, parsePullRequest, parseRemotes, parseShortstat, parseVmStat, parseWorktree } from '../scripts/lib/parse-git.mjs';
20import { addUsage, parseUsageApi, projectSlug, summarizeTranscript, usageOf } from '../scripts/lib/parse-transcript.mjs';
21
22const USAGE_API_URL = 'https://api.anthropic.com/api/oauth/usage';
23const STATUS_URL = 'https://status.claude.com/api/v2/status.json';
24const CUSTOM_COMMAND_TIMEOUT_MS = 5000;
25const JJ_TEMPLATE = 'change_id.shortest(8) ++ "\\n" ++ bookmarks.join(",") ++ "\\n" ++ description.first_line()';
26
27// --- State ---
28
29/** 모은 값. 꼴은 `scripts/lib/widgets/index.mjs` 가 적는다. */
30const snap = {};
31/** 소스 이름 → 마지막으로 값을 못 읽은 이유. */
32const errors = {};
33/** 소스 이름 → 마지막으로 모은 시각(ms). */
34const collectedAt = {};
35
36let home;
37let configState = { config: DEFAULT_CONFIG, exists: false, problems: [], mtimeMs: null };
38let needs = neededSources(DEFAULT_CONFIG);
39let ticker = null;
40
41// --- Config ---
42
43async function homeDir($) {
44 home ??= await $.env.get('HOME');
45 snap.home = home;
46 return home;
47}
48
49async function configPath($) {
50 return `${await homeDir($)}/${CONFIG_RELATIVE_PATH}`;
51}
52
53async function configMtime($, path) {
54 return (await $.fs.exists(path)) ? (await $.fs.stat(path)).mtimeMs : null;
55}
56
57async function loadConfig($) {
58 const path = await configPath($);
59 const mtimeMs = await configMtime($, path);
60 if (mtimeMs === null) {
61 configState = { config: DEFAULT_CONFIG, exists: false, problems: [], mtimeMs };
62 } else {
63 let raw;
64 let broken = null;
65 try {
66 raw = JSON.parse(await $.fs.read(path));
67 } catch (error) {
68 broken = `설정 파일을 JSON 으로 읽지 못했다 (${error.message}) — 기본 설정으로 그린다`;
69 }
70 const { config, problems } = broken ? { config: DEFAULT_CONFIG, problems: [broken] } : normalizeConfig(raw);
71 configState = { config, exists: true, problems, mtimeMs };
72 }
73 needs = neededSources(configState.config);
74 restartTicker($);
75}
76
77async function reloadIfChanged($) {
78 if ((await configMtime($, await configPath($))) === configState.mtimeMs) return false;
79 await loadConfig($);
80 return true;
81}
82
83async function saveConfig($, config) {
84 const path = await configPath($);
85 const made = await $.process.run(['mkdir', '-p', path.slice(0, path.lastIndexOf('/'))]);
86 if (made.exitCode !== 0) throw new Error(`설정 폴더를 만들지 못했다: ${made.stderr.trim()}`);
87 await $.fs.write(path, `${JSON.stringify(config, null, 2)}\n`);
88 await loadConfig($);
89}
90
91function restartTicker($) {
92 ticker?.cancel();
93 ticker = needs.has('clock') ? $.clock.every(configState.config.refresh.clockSeconds * 1000, () => $.ui.invalidate('ui.render')) : null;
94}
95
96// --- Collecting ---
97
98/** 소스 하나를 모은다. 실패는 그 소스의 칸만 비우고 이유를 남긴다. */
99async function collect(source, body) {
100 try {
101 await body();
102 delete errors[source];
103 } catch (error) {
104 errors[source] = String(error?.message ?? error);
105 }
106}
107
108function isDue(source, seconds, now, force) {
109 if (!force && collectedAt[source] !== undefined && now - collectedAt[source] < seconds * 1000) return false;
110 collectedAt[source] = now;
111 return true;
112}
113
114async function collectSession($) {
115 const [model, version, id, cwd, repo] = await Promise.all([
116 $.session.model(),
117 $.session.version(),
118 $.session.id(),
119 $.session.cwd(),
120 $.session.repo(),
121 ]);
122 snap.session = { ...snap.session, model, version: version.version, id, cwd };
123 snap.repo = repo;
124}
125
126async function collectSettings($) {
127 const settings = await $.settings.read();
128 snap.settings = { effortLevel: settings.effortLevel, voice: settings.voice, sandbox: settings.sandbox };
129}
130
131async function collectConfigRows($) {
132 snap.configRows = Object.fromEntries((await $.config.list()).map((row) => [row.key, row.value]));
133}
134
135async function collectContextUsable($) {
136 const usage = await $.session.usage({ breakdown: 'summary' });
137 snap.contextUsable = { maxTokens: usage.context.breakdown?.rawMaxTokens ?? null };
138}
139
140async function collectAccount($) {
141 const account = JSON.parse(await $.fs.read(`${await homeDir($)}/.claude.json`)).oauthAccount;
142 snap.account = { email: account?.emailAddress ?? null };
143}
144
145/** 트랜스크립트에서 세션 이름을 읽고, 처음 한 번은 재개 전까지의 토큰 합계를 되찾는다. */
146async function collectTranscript($) {
147 const [root, id] = await Promise.all([$.session.root(), $.session.id()]);
148 const path = `${await homeDir($)}/.claude/projects/${projectSlug(root)}/${id}.jsonl`;
149 // 첫 프롬프트 전에는 파일이 없다.
150 const summary = summarizeTranscript((await $.fs.exists(path)) ? await $.fs.read(path) : '');
151 snap.transcript = { customTitle: summary.customTitle, aiTitle: summary.aiTitle, effort: summary.effort };
152 if (!snap.tokens) {
153 snap.tokens = {
154 ...summary.total,
155 last: summary.last,
156 lastResponseAt: summary.lastResponseAt,
157 compactions: summary.compactions,
158 turnInput: 0,
159 turnOutput: 0,
160 turnMs: 0,
161 };
162 }
163}
164
165async function runGit($, args) {
166 const ran = await $.process.run(['git', ...args]);
167 return ran.exitCode === 0 ? ran.stdout : null;
168}
169
170async function collectGit($) {
171 const status = await runGit($, ['status', '--porcelain=v2', '--branch']);
172 if (status === null) {
173 // 저장소 밖이다.
174 snap.git = null;
175 snap.gitDiff = null;
176 snap.gitRemotes = null;
177 snap.gitWorktree = null;
178 return;
179 }
180 snap.git = parseGitStatus(status);
181 const [diff, remotes, worktree] = await Promise.all([
182 needs.has('gitDiff') ? runGit($, ['diff', 'HEAD', '--shortstat']) : null,
183 needs.has('gitRemotes') ? runGit($, ['remote', '-v']) : null,
184 needs.has('gitWorktree') ? runGit($, ['rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir', '--show-toplevel']) : null,
185 ]);
186 // 첫 커밋 전에는 HEAD 가 없어 diff 가 실패한다. 그때는 변화 0 으로 둔다.
187 snap.gitDiff = needs.has('gitDiff') ? parseShortstat(diff ?? '') : undefined;
188 snap.gitRemotes = remotes === null ? undefined : parseRemotes(remotes);
189 snap.gitWorktree = worktree === null ? undefined : parseWorktree(worktree);
190}
191
192async function collectGh($) {
193 const ran = await $.process.run(['gh', 'pr', 'view', '--json', 'number,title,url,state,statusCheckRollup']);
194 snap.gh = ran.exitCode === 0 ? parsePullRequest(ran.stdout) : null;
195}
196
197async function collectJj($) {
198 const log = await $.process.run(['jj', 'log', '-r', '@', '--no-graph', '-T', JJ_TEMPLATE]);
199 const parsed = log.exitCode === 0 ? parseJjLog(log.stdout) : null;
200 if (!parsed) {
201 snap.jj = null;
202 return;
203 }
204 const [stat, root] = await Promise.all([$.process.run(['jj', 'diff', '--stat']), $.process.run(['jj', 'root'])]);
205 snap.jj = { ...parsed, ...parseShortstat(stat.exitCode === 0 ? stat.stdout : ''), root: root.exitCode === 0 ? root.stdout.trim() : null };
206}
207
208async function collectApi($) {
209 const authorization = await $.session.authorize();
210 if (authorization === null) {
211 // 자사 로그인이 아니다 (API 키, 게이트웨이, 3P). 이 값은 없는 것이다.
212 snap.api = null;
213 return;
214 }
215 const response = await $.http.fetch(USAGE_API_URL, { headers: { 'anthropic-beta': 'oauth-2025-04-20' }, auth: authorization.handle });
216 if (!response.ok) throw new Error(`usage API ${response.status}`);
217 snap.api = parseUsageApi(JSON.parse(response.text));
218}
219
220async function collectStatus($) {
221 const response = await $.http.fetch(STATUS_URL);
222 if (!response.ok) throw new Error(`status ${response.status}`);
223 snap.status = { indicator: JSON.parse(response.text).status?.indicator ?? null };
224}
225
226async function collectMemory($) {
227 const [vmStat, total] = await Promise.all([$.process.run(['vm_stat']), $.process.run(['sysctl', '-n', 'hw.memsize'])]);
228 snap.memory = vmStat.exitCode === 0 && total.exitCode === 0 ? parseVmStat(vmStat.stdout, Number(total.stdout.trim())) : null;
229}
230
231async function collectCustom($) {
232 const outputs = {};
233 for (const item of activeItems(configState.config)) {
234 if (item.type !== 'custom-command' || !item.command) continue;
235 const ran = await $.process.run(item.command, { timeoutMs: CUSTOM_COMMAND_TIMEOUT_MS });
236 outputs[item.id] = ran.exitCode === 0 ? ran.stdout.split('\n')[0].trim() : '';
237 }
238 snap.custom = outputs;
239}
240
241/** 켜진 위젯이 요구하는 소스를, 주기가 된 것만 모으고 다시 그리게 한다. */
242async function refresh($, force) {
243 const now = await $.clock.now();
244 const every = configState.config.refresh;
245 const jobs = [];
246 const add = (source, seconds, body) => {
247 if (needs.has(source) && isDue(source, seconds, now, force)) jobs.push(collect(source, body));
248 };
249 add('session', 0, () => collectSession($));
250 add('usage', 0, async () => {
251 snap.usage = await $.session.usage();
252 });
253 add('settings', 0, () => collectSettings($));
254 add('configRows', 0, () => collectConfigRows($));
255 add('contextUsable', every.apiSeconds, () => collectContextUsable($));
256 add('account', every.statusSeconds, () => collectAccount($));
257 add('transcript', every.clockSeconds, () => collectTranscript($));
258 add('tokens', every.statusSeconds, () => collectTranscript($));
259 add('git', every.gitSeconds, () => collectGit($));
260 add('gh', every.ghSeconds, () => collectGh($));
261 add('jj', every.gitSeconds, () => collectJj($));
262 add('api', every.apiSeconds, () => collectApi($));
263 add('status', every.statusSeconds, () => collectStatus($));
264 add('memory', every.clockSeconds, () => collectMemory($));
265 add('custom', every.customSeconds, () => collectCustom($));
266 await Promise.all(jobs);
267 $.ui.invalidate('ui.render');
268}
269
270/** 도구 호출 뒤에 부른다 — 작업 트리만 주기 안에서 다시 본다. */
271async function refreshWorkingTree($) {
272 const now = await $.clock.now();
273 const seconds = configState.config.refresh.gitSeconds;
274 const jobs = [];
275 if (needs.has('git') && isDue('git', seconds, now, false)) jobs.push(collect('git', () => collectGit($)));
276 if (needs.has('jj') && isDue('jj', seconds, now, false)) jobs.push(collect('jj', () => collectJj($)));
277 if (jobs.length === 0) return;
278 await Promise.all(jobs);
279 $.ui.invalidate('ui.render');
280}
281
282/** 턴 하나의 토큰과 길이를 합계에 더한다. */
283function addTurn(usage, durationMs, now) {
284 const turn = usageOf(usage);
285 const tokens = snap.tokens ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, compactions: 0, turnInput: 0, turnOutput: 0, turnMs: 0 };
286 snap.tokens = {
287 ...tokens,
288 ...addUsage(tokens, turn),
289 last: turn,
290 lastResponseAt: now,
291 turnInput: tokens.turnInput + turn.input + turn.cacheRead + turn.cacheWrite,
292 turnOutput: tokens.turnOutput + turn.output,
293 turnMs: tokens.turnMs + durationMs,
294 };
295}
296
297/** 훅 입력에 실려 오는 노력 수준과 세션 이름. 없으면 그대로 둔다. */
298function noteHookInput(e) {
299 const effort = e.effort?.level;
300 const name = e.session_title;
301 if (!effort && !name) return;
302 snap.session = { ...snap.session, ...(effort ? { effort } : {}), ...(name ? { name } : {}) };
303}
304
305// --- /sl ---
306
307async function runCommand($, args) {
308 const command = parseCommand(args);
309 const path = await configPath($);
310 const listing = () => describeConfig(configState.config, { path, exists: configState.exists, problems: configState.problems, errors });
311
312 switch (command.action) {
313 case 'error':
314 return command.message;
315 case 'help':
316 return USAGE;
317 case 'widgets':
318 return describeWidgets();
319 case 'list':
320 return `${listing()}\n\n${USAGE}`;
321 case 'reload':
322 await loadConfig($);
323 await refresh($, true);
324 return `다시 읽었다.\n\n${listing()}`;
325 case 'init':
326 if (configState.exists) return `이미 있다: ${path}`;
327 await saveConfig($, configState.config);
328 await refresh($, true);
329 return `만들었다: ${path}`;
330 case 'import': {
331 const source = `${await homeDir($)}/${CCSTATUSLINE_RELATIVE_PATH}`;
332 if (!(await $.fs.exists(source))) return `ccstatusline 설정이 없다: ${source}`;
333 if (configState.exists && !command.force) return `설정이 이미 있다: ${path}\n덮어쓰려면 \`/sl import --force\``;
334 const { config, problems } = fromCcstatusline(JSON.parse(await $.fs.read(source)));
335 await saveConfig($, config);
336 await refresh($, true);
337 return [`가져왔다: ${source} → ${path}`, ...problems.map((problem) => ` - ${problem}`), '', listing()].join('\n');
338 }
339 default: {
340 const edited = applyEdit(configState.config, command);
341 if (edited.error) return edited.error;
342 await saveConfig($, edited.config);
343 await refresh($, true);
344 return `${edited.message}\n\n${listing()}`;
345 }
346 }
347}
348
349// --- Wiring ---
350
351export function register(on) {
352 on('session.start', async ($, e, next) => {
353 await collect('config', () => loadConfig($));
354 await collect('command', () => $.command.register({ name: COMMAND_NAME, description: '상태줄 배치를 보고 바꾼다', argumentHint: '[on|off|add|rm|move|set|…]' }));
355 await refresh($, true);
356 return next(e);
357 });
358
359 // 손으로 고친 설정은 다음 프롬프트에서 반영된다.
360 on('turn.start', async ($, e, next) => {
361 const changed = await reloadIfChanged($);
362 await refresh($, changed);
363 return next(e);
364 });
365
366 on('turn.complete', async ($, e, next) => {
367 // 서브에이전트의 턴도 지나간다. 메인 대화만 센다.
368 if (!e.agentId) {
369 if (e.usage) addTurn(e.usage, e.durationMs, await $.clock.now());
370 await refresh($, false);
371 }
372 return next(e);
373 });
374
375 on('session.measure', async ($, e, next) => {
376 snap.usage = { ...snap.usage, context: e.context, rateLimits: e.rateLimits, cost: e.cost };
377 await refresh($, false);
378 return next(e);
379 });
380
381 on('session.compact', async ($, e, next) => {
382 const result = await next(e);
383 // precompute 는 아무것도 바꾸지 않는다.
384 if (e.trigger !== 'precompute' && !e.agentId && !result?.skip && snap.tokens) {
385 snap.tokens = { ...snap.tokens, compactions: snap.tokens.compactions + 1 };
386 $.ui.invalidate('ui.render');
387 }
388 return result;
389 });
390
391 on('skill.prompt', ($, e, next) => {
392 snap.skills = { last: e.skill, count: (snap.skills?.count ?? 0) + 1 };
393 $.ui.invalidate('ui.render');
394 return next(e);
395 });
396
397 on('classic.UserPromptSubmit', ($, e, next) => {
398 noteHookInput(e);
399 return next(e);
400 });
401
402 // 턴 도중에 바뀌는 git 상태를 따라간다. 주기 안에서는 git 을 다시 부르지 않는다.
403 on('classic.PostToolUse', async ($, e, next) => {
404 noteHookInput(e);
405 await refreshWorkingTree($);
406 return next(e);
407 });
408
409 on('command.run', { command: 'sl' }, async ($, e) => {
410 try {
411 return { text: await runCommand($, e.args) };
412 } catch (error) {
413 return { text: `/sl 실패: ${error.message}` };
414 }
415 });
416
417 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
418 const hint = await next(e);
419 const rows = buildRows(configState.config, { ...snap, now: await $.clock.now(), columns: e.viewport?.columns });
420 if (rows.length === 0) return hint;
421
422 const { Box, Text } = $.ui.resolve(e);
423 return h(
424 Box,
425 { flexDirection: 'column' },
426 hint,
427 ...rows.map((cells) =>
428 h(
429 Box,
430 null,
431 ...cells.map((cell) =>
432 cell.flex
433 ? h(Box, { flexGrow: 1 })
434 : h(Text, { color: cell.color, backgroundColor: cell.bg, bold: cell.bold, dimColor: cell.dim }, cell.text),
435 ),
436 ),
437 ),
438 );
439 });
440}
441scripts/lib/command.mjs 269 lines1/**
2 * `/sl` 명령의 순수 로직 — 인자를 읽고, 설정을 고친 새 설정을 만들고, 현재 배치를 글로 보여 준다.
3 *
4 * 파일을 읽고 쓰는 일(init·import·reload·저장)은 hooks module 이 한다. 여기는 «무엇을 할지» 만 정한다.
5 *
6 * 주소는 1부터 센다: `2` 는 2번 줄, `2.3` 은 2번 줄의 3번 항목.
7 */
8
9import { THEMES } from './themes.mjs';
10import { LAYOUT_TYPES, UNSUPPORTED, WIDGETS, isKnownType } from './widgets/index.mjs';
11
12/** `/sl`. hooks module 의 `command.run` 매처에도 같은 글자가 리터럴로 적혀 있다 — 엔진이 매처를 소스에서 읽는다. */
13export const COMMAND_NAME = 'sl';
14
15export const USAGE = [
16 '/sl 현재 배치와 문제 보기',
17 '/sl on|off <줄>[.<항목>] 줄이나 항목을 켜고 끈다',
18 '/sl add <줄> <위젯> [위치] 줄에 위젯을 넣는다 (위치 생략 = 맨 끝)',
19 '/sl rm <줄>.<항목> 항목을 뺀다',
20 '/sl move <줄>.<항목> <줄>[.<위치>] 항목을 옮긴다',
21 '/sl set <줄>.<항목> 키=값 … 항목 옵션 (color bg label text symbol url format bold dim raw merge showZero maxWidth width ttlMinutes), 값 없이 `키=` 는 지움',
22 '/sl line add | line rm <줄> 줄을 더하고 뺀다',
23 '/sl theme <이름>|off powerline 테마',
24 '/sl powerline on|off powerline 화살표',
25 '/sl minimal on|off 라벨 없이 값만',
26 '/sl widgets 쓸 수 있는 위젯',
27 '/sl init | import [--force] | reload 설정 파일 만들기 · ccstatusline 설정 가져오기 · 다시 읽기',
28].join('\n');
29
30const BOOLEAN_OPTIONS = ['bold', 'dim', 'raw', 'merge', 'showZero'];
31const NUMBER_OPTIONS = ['maxWidth', 'width', 'ttlMinutes'];
32const STRING_OPTIONS = ['id', 'color', 'bg', 'label', 'text', 'symbol', 'url', 'format'];
33
34/**
35 * `/sl` 뒤의 인자를 동작으로 읽는다. 읽지 못하면 `{ action: 'error', message }`.
36 *
37 * @param {string} args
38 * @returns {{action: string, [key: string]: unknown}}
39 */
40export function parseCommand(args) {
41 const words = args.trim().split(/\s+/).filter(Boolean);
42 const [verb, ...rest] = words;
43 switch (verb) {
44 case undefined:
45 case 'list':
46 return { action: 'list' };
47 case 'help':
48 return { action: 'help' };
49 case 'widgets':
50 case 'init':
51 case 'reload':
52 return { action: verb };
53 case 'import':
54 return { action: 'import', force: rest.includes('--force') };
55 case 'on':
56 case 'off': {
57 const address = parseAddress(rest[0]);
58 return address ? { action: 'toggle', enabled: verb === 'on', ...address } : fail(`\`/sl ${verb} <줄>[.<항목>]\``);
59 }
60 case 'add': {
61 const line = positive(rest[0]);
62 const position = rest[2] === undefined ? null : positive(rest[2]);
63 if (!line || !rest[1] || position === undefined) return fail('`/sl add <줄> <위젯> [위치]`');
64 return { action: 'add', line, type: rest[1], position };
65 }
66 case 'rm': {
67 const address = parseAddress(rest[0]);
68 return address?.item ? { action: 'remove', ...address } : fail('`/sl rm <줄>.<항목>`');
69 }
70 case 'move': {
71 const from = parseAddress(rest[0]);
72 const to = parseAddress(rest[1]);
73 return from?.item && to ? { action: 'move', from, to } : fail('`/sl move <줄>.<항목> <줄>[.<위치>]`');
74 }
75 case 'set': {
76 const address = parseAddress(rest[0]);
77 const pairs = rest.slice(1).map((word) => {
78 const index = word.indexOf('=');
79 return index > 0 ? [word.slice(0, index), word.slice(index + 1)] : null;
80 });
81 if (!address?.item || pairs.length === 0 || pairs.includes(null)) return fail('`/sl set <줄>.<항목> 키=값 …`');
82 return { action: 'set', ...address, pairs };
83 }
84 case 'line': {
85 if (rest[0] === 'add') return { action: 'line-add' };
86 const line = positive(rest[1]);
87 return rest[0] === 'rm' && line ? { action: 'line-remove', line } : fail('`/sl line add` 또는 `/sl line rm <줄>`');
88 }
89 case 'theme':
90 return rest[0] ? { action: 'theme', theme: rest[0] === 'off' ? null : rest[0] } : fail('`/sl theme <이름>|off`');
91 case 'powerline':
92 case 'minimal':
93 return rest[0] === 'on' || rest[0] === 'off' ? { action: verb, enabled: rest[0] === 'on' } : fail(`\`/sl ${verb} on|off\``);
94 default:
95 return { action: 'error', message: `\`${verb}\` 는 모르는 동작이다.\n${USAGE}` };
96 }
97}
98
99/**
100 * 설정을 고치는 동작을 적용한 새 설정. 적용하지 못하면 `error` 에 이유가 실리고 설정은 그대로다.
101 *
102 * @param {object} config `normalizeConfig` 를 거친 설정
103 * @param {ReturnType<typeof parseCommand>} command
104 * @returns {{config: object, message?: string, error?: string}}
105 */
106export function applyEdit(config, command) {
107 const next = structuredCloneJson(config);
108 const lineAt = (number) => next.lines[number - 1];
109 const itemAt = (address) => lineAt(address.line)?.items[address.item - 1];
110 const missing = (address) => ({ config, error: `${formatAddress(address)} 은(는) 없다. \`/sl\` 로 번호를 확인한다.` });
111
112 switch (command.action) {
113 case 'toggle': {
114 const target = command.item ? itemAt(command) : lineAt(command.line);
115 if (!target) return missing(command);
116 if (command.item && command.enabled) delete target.enabled;
117 else target.enabled = command.enabled;
118 return { config: next, message: `${formatAddress(command)} ${command.enabled ? '켬' : '끔'}` };
119 }
120 case 'add': {
121 const line = lineAt(command.line);
122 if (!line) return missing({ line: command.line });
123 if (Object.hasOwn(UNSUPPORTED, command.type)) return { config, error: `\`${command.type}\` 는 지원하지 않는다 — ${UNSUPPORTED[command.type]}` };
124 if (!isKnownType(command.type)) return { config, error: `위젯 \`${command.type}\` 를 모른다. \`/sl widgets\` 로 목록을 본다.` };
125 const item = { type: command.type };
126 if (command.type === 'custom-command') item.id = `cmd-${line.items.length + 1}`;
127 const index = command.position === null ? line.items.length : Math.min(command.position - 1, line.items.length);
128 line.items.splice(index, 0, item);
129 return { config: next, message: `${command.line}.${index + 1} 에 \`${command.type}\` 추가` };
130 }
131 case 'remove': {
132 if (!itemAt(command)) return missing(command);
133 const [removed] = lineAt(command.line).items.splice(command.item - 1, 1);
134 return { config: next, message: `${formatAddress(command)} \`${removed.type}\` 뺌` };
135 }
136 case 'move': {
137 if (!itemAt(command.from)) return missing(command.from);
138 if (!lineAt(command.to.line)) return missing({ line: command.to.line });
139 const [moved] = lineAt(command.from.line).items.splice(command.from.item - 1, 1);
140 const target = lineAt(command.to.line).items;
141 const index = command.to.item ? Math.min(command.to.item - 1, target.length) : target.length;
142 target.splice(index, 0, moved);
143 return { config: next, message: `\`${moved.type}\` 을(를) ${command.to.line}.${index + 1} 로 옮김` };
144 }
145 case 'set': {
146 const item = itemAt(command);
147 if (!item) return missing(command);
148 for (const [key, value] of command.pairs) {
149 const problem = setOption(item, key, value);
150 if (problem) return { config, error: problem };
151 }
152 return { config: next, message: `${formatAddress(command)} \`${item.type}\` 옵션 바꿈` };
153 }
154 case 'line-add':
155 next.lines.push({ enabled: true, items: [] });
156 return { config: next, message: `${next.lines.length}번 줄 추가` };
157 case 'line-remove':
158 if (!lineAt(command.line)) return missing({ line: command.line });
159 next.lines.splice(command.line - 1, 1);
160 return { config: next, message: `${command.line}번 줄 뺌` };
161 case 'theme':
162 if (command.theme !== null && !Object.hasOwn(THEMES, command.theme)) {
163 return { config, error: `테마 \`${command.theme}\` 를 모른다 (${Object.keys(THEMES).join(', ')}, off)` };
164 }
165 next.theme = command.theme;
166 return { config: next, message: `테마 ${command.theme ?? '없음'}` };
167 case 'powerline':
168 next.powerline.enabled = command.enabled;
169 return { config: next, message: `powerline ${command.enabled ? '켬' : '끔'}` };
170 case 'minimal':
171 next.minimal = command.enabled;
172 return { config: next, message: `minimal ${command.enabled ? '켬' : '끔'}` };
173 default:
174 return { config, error: `\`${command.action}\` 는 설정을 고치는 동작이 아니다.` };
175 }
176}
177
178function setOption(item, key, value) {
179 if (value === '') {
180 delete item[key];
181 return null;
182 }
183 if (BOOLEAN_OPTIONS.includes(key)) {
184 if (value !== 'true' && value !== 'false') return `\`${key}\` 는 true 나 false 다.`;
185 item[key] = value === 'true';
186 } else if (NUMBER_OPTIONS.includes(key)) {
187 const number = Number(value);
188 if (!Number.isFinite(number) || number < 1) return `\`${key}\` 는 1 이상의 숫자다.`;
189 item[key] = number;
190 } else if (STRING_OPTIONS.includes(key)) {
191 // 밑줄을 공백으로 읽는다 — 명령 인자는 공백으로 갈라지므로 공백 든 값을 적을 길이 없다.
192 item[key] = key === 'label' || key === 'text' ? value.replaceAll('_', ' ') : value;
193 } else {
194 return `\`${key}\` 는 모르는 옵션이다.`;
195 }
196 return null;
197}
198
199/**
200 * 현재 배치를 번호와 함께 보여 준다. 설정의 문제와 소스 오류가 있으면 뒤에 붙인다.
201 *
202 * @param {object} config
203 * @param {{path: string, exists: boolean, problems: string[], errors: Record<string, string>}} state
204 * @returns {string}
205 */
206export function describeConfig(config, state) {
207 const out = [
208 `설정: ${state.path}${state.exists ? '' : ' (없음 — 기본 설정으로 그리는 중, `/sl init` 으로 만든다)'}`,
209 `powerline ${config.powerline.enabled ? 'on' : 'off'} · 테마 ${config.theme ?? '없음'} · minimal ${config.minimal ? 'on' : 'off'}`,
210 '',
211 ];
212 config.lines.forEach((line, lineIndex) => {
213 out.push(`${lineIndex + 1}번 줄 ${line.enabled ? '' : '(꺼짐)'}`.trimEnd());
214 line.items.forEach((item, itemIndex) => {
215 const options = Object.entries(item)
216 .filter(([key]) => key !== 'type' && key !== 'enabled')
217 .map(([key, value]) => `${key}=${Array.isArray(value) ? JSON.stringify(value) : value}`)
218 .join(' ');
219 out.push(` ${lineIndex + 1}.${itemIndex + 1} ${item.enabled === false ? '○' : '●'} ${item.type}${options ? ` ${options}` : ''}`);
220 });
221 if (line.items.length === 0) out.push(' (빈 줄)');
222 });
223 if (state.problems.length > 0) out.push('', '설정의 문제:', ...state.problems.map((problem) => ` - ${problem}`));
224 const errors = Object.entries(state.errors);
225 if (errors.length > 0) out.push('', '값을 못 읽은 소스:', ...errors.map(([source, message]) => ` - ${source}: ${message}`));
226 return out.join('\n');
227}
228
229/**
230 * 쓸 수 있는 위젯을 분류별로.
231 *
232 * @returns {string}
233 */
234export function describeWidgets() {
235 const byCategory = new Map();
236 for (const [type, widget] of Object.entries(WIDGETS)) {
237 if (!byCategory.has(widget.category)) byCategory.set(widget.category, []);
238 byCategory.get(widget.category).push(type);
239 }
240 const out = [...byCategory].map(([category, types]) => `${category}: ${types.join(', ')}`);
241 out.push(`layout: ${LAYOUT_TYPES.join(', ')}`);
242 out.push(`지원 안 함: ${Object.keys(UNSUPPORTED).join(', ')}`);
243 return out.join('\n');
244}
245
246function parseAddress(word) {
247 const match = /^(\d+)(?:\.(\d+))?$/.exec(word ?? '');
248 if (!match || Number(match[1]) < 1) return null;
249 if (match[2] === undefined) return { line: Number(match[1]) };
250 return Number(match[2]) < 1 ? null : { line: Number(match[1]), item: Number(match[2]) };
251}
252
253function formatAddress(address) {
254 return address.item ? `${address.line}.${address.item}` : `${address.line}번 줄`;
255}
256
257/** 양의 정수면 그 수, 아니면 undefined. */
258function positive(word) {
259 return /^\d+$/.test(word ?? '') && Number(word) >= 1 ? Number(word) : undefined;
260}
261
262function fail(usage) {
263 return { action: 'error', message: `쓰는 법: ${usage}` };
264}
265
266function structuredCloneJson(value) {
267 return JSON.parse(JSON.stringify(value));
268}
269scripts/lib/config.mjs 241 lines1/**
2 * 설정의 꼴과 기본값 — 줄(`lines`) 안에 항목(`items`)이 놓이고, 줄과 항목 각각 `enabled` 로 끄고 켠다.
3 *
4 * 설정 파일(`~/.claude/statusline/settings.json`)은 사람이 손으로도 고치므로 여기가 입력 경계다.
5 * 틀린 곳은 던지지 않고 `problems` 에 모아 돌려준다 — 한 항목이 틀렸다고 상태줄 전체가 사라지면 안 된다.
6 *
7 * `node:` import 를 두지 않는다. hooks module 이 import 한다.
8 */
9
10import { ANSI_HEX, THEMES } from './themes.mjs';
11import { UNSUPPORTED, isKnownType } from './widgets/index.mjs';
12
13export const CONFIG_VERSION = 1;
14
15/** 홈 기준 설정 파일 경로. */
16export const CONFIG_RELATIVE_PATH = '.claude/statusline/settings.json';
17
18/** 홈 기준 ccstatusline 설정 파일 경로 (`/sl import` 가 읽는다). */
19export const CCSTATUSLINE_RELATIVE_PATH = '.config/ccstatusline/settings.json';
20
21const DEFAULT_REFRESH = { gitSeconds: 5, apiSeconds: 180, ghSeconds: 120, statusSeconds: 300, clockSeconds: 30, customSeconds: 30 };
22
23const ITEM_STRING_KEYS = ['id', 'color', 'bg', 'label', 'text', 'symbol', 'url', 'format'];
24const ITEM_BOOLEAN_KEYS = ['bold', 'dim', 'raw', 'merge', 'showZero'];
25const ITEM_NUMBER_KEYS = ['maxWidth', 'width', 'ttlMinutes'];
26
27/** 설정 파일이 없을 때의 상태줄. */
28export const DEFAULT_CONFIG = Object.freeze({
29 version: CONFIG_VERSION,
30 powerline: { enabled: true, separator: '' },
31 theme: 'nord',
32 separator: ' | ',
33 padding: ' ',
34 bold: false,
35 minimal: true,
36 refresh: DEFAULT_REFRESH,
37 lines: [
38 {
39 enabled: true,
40 items: [
41 { type: 'model', merge: true },
42 { type: 'thinking-effort' },
43 { type: 'git-root-dir' },
44 { type: 'git-branch', merge: true },
45 { type: 'git-changes' },
46 { type: 'flex-separator' },
47 { type: 'context-bar' },
48 { type: 'session-usage', merge: true },
49 { type: 'reset-timer' },
50 { type: 'weekly-usage' },
51 ],
52 },
53 ],
54});
55
56/**
57 * 읽은 JSON 을 쓸 수 있는 설정으로 다듬는다. 모르는 타입·틀린 값은 버리고 그 이유를 `problems` 에 적는다.
58 *
59 * @param {unknown} raw
60 * @returns {{config: typeof DEFAULT_CONFIG, problems: string[]}}
61 */
62export function normalizeConfig(raw) {
63 const problems = [];
64 if (!isRecord(raw)) return { config: DEFAULT_CONFIG, problems: ['설정이 객체가 아니다 — 기본 설정으로 그린다'] };
65
66 const lines = Array.isArray(raw.lines) ? raw.lines : null;
67 if (!lines) problems.push('`lines` 가 배열이 아니다 — 기본 줄로 그린다');
68
69 const theme = raw.theme === undefined ? DEFAULT_CONFIG.theme : raw.theme;
70 if (theme !== null && !Object.hasOwn(THEMES, theme)) {
71 problems.push(`테마 \`${String(theme)}\` 를 모른다 (${Object.keys(THEMES).join(', ')}) — 테마 없이 그린다`);
72 }
73
74 return {
75 config: {
76 version: CONFIG_VERSION,
77 powerline: {
78 enabled: isRecord(raw.powerline) ? raw.powerline.enabled !== false : DEFAULT_CONFIG.powerline.enabled,
79 separator:
80 isRecord(raw.powerline) && typeof raw.powerline.separator === 'string'
81 ? raw.powerline.separator
82 : DEFAULT_CONFIG.powerline.separator,
83 },
84 theme: theme !== null && Object.hasOwn(THEMES, theme) ? theme : null,
85 separator: typeof raw.separator === 'string' ? raw.separator : DEFAULT_CONFIG.separator,
86 padding: typeof raw.padding === 'string' ? raw.padding : DEFAULT_CONFIG.padding,
87 bold: raw.bold === true,
88 minimal: raw.minimal === undefined ? DEFAULT_CONFIG.minimal : raw.minimal === true,
89 refresh: normalizeRefresh(raw.refresh, problems),
90 lines: lines ? lines.map((line, index) => normalizeLine(line, index, problems)) : DEFAULT_CONFIG.lines,
91 },
92 problems,
93 };
94}
95
96function normalizeRefresh(raw, problems) {
97 if (raw === undefined) return DEFAULT_REFRESH;
98 if (!isRecord(raw)) {
99 problems.push('`refresh` 가 객체가 아니다 — 기본 주기를 쓴다');
100 return DEFAULT_REFRESH;
101 }
102 const refresh = { ...DEFAULT_REFRESH };
103 for (const key of Object.keys(DEFAULT_REFRESH)) {
104 if (raw[key] === undefined) continue;
105 if (typeof raw[key] === 'number' && raw[key] >= 1) refresh[key] = raw[key];
106 else problems.push(`\`refresh.${key}\` 는 1 이상의 숫자여야 한다 — 기본값 ${DEFAULT_REFRESH[key]} 을 쓴다`);
107 }
108 return refresh;
109}
110
111function normalizeLine(raw, index, problems) {
112 const where = `${index + 1}번 줄`;
113 // 줄을 항목 배열로만 적은 꼴도 받는다 (ccstatusline 과 같은 꼴).
114 const line = Array.isArray(raw) ? { items: raw } : raw;
115 if (!isRecord(line) || !Array.isArray(line.items)) {
116 problems.push(`${where}: \`items\` 배열이 없다 — 빈 줄로 둔다`);
117 return { enabled: false, items: [] };
118 }
119 const items = [];
120 line.items.forEach((item, itemIndex) => {
121 const normalized = normalizeItem(item, `${where} ${itemIndex + 1}번 항목`, problems);
122 if (normalized) items.push(normalized);
123 });
124 return { enabled: line.enabled !== false, items };
125}
126
127function normalizeItem(raw, where, problems) {
128 if (!isRecord(raw) || typeof raw.type !== 'string') {
129 problems.push(`${where}: \`type\` 이 없다 — 버린다`);
130 return null;
131 }
132 if (Object.hasOwn(UNSUPPORTED, raw.type)) {
133 problems.push(`${where}: \`${raw.type}\` 는 지원하지 않는다 — ${UNSUPPORTED[raw.type]}`);
134 return null;
135 }
136 if (!isKnownType(raw.type)) {
137 problems.push(`${where}: 위젯 \`${raw.type}\` 를 모른다 — 버린다`);
138 return null;
139 }
140 const item = { type: raw.type };
141 if (raw.enabled === false) item.enabled = false;
142 copyTyped(raw, item, ITEM_STRING_KEYS, 'string', where, problems);
143 copyTyped(raw, item, ITEM_BOOLEAN_KEYS, 'boolean', where, problems);
144 copyTyped(raw, item, ITEM_NUMBER_KEYS, 'number', where, problems);
145 if (raw.command !== undefined) {
146 if (Array.isArray(raw.command) && raw.command.length > 0 && raw.command.every((part) => typeof part === 'string')) {
147 item.command = raw.command;
148 } else {
149 problems.push(`${where}: \`command\` 는 문자열 배열(argv)이어야 한다 — 무시한다`);
150 }
151 }
152 if (item.type === 'custom-command' && !item.id) {
153 problems.push(`${where}: custom-command 에는 \`id\` 가 있어야 출력을 찾는다 — 버린다`);
154 return null;
155 }
156 return item;
157}
158
159function copyTyped(from, to, keys, type, where, problems) {
160 for (const key of keys) {
161 if (from[key] === undefined) continue;
162 if (typeof from[key] === type) to[key] = from[key];
163 else problems.push(`${where}: \`${key}\` 는 ${type} 이어야 한다 — 무시한다`);
164 }
165}
166
167/**
168 * ccstatusline 의 설정(v3·v4 꼴)을 이 플러그인의 설정으로 옮긴다. 옮기지 못한 것은 `problems` 에 적는다.
169 *
170 * @param {unknown} raw ccstatusline `settings.json` 의 내용
171 * @returns {{config: typeof DEFAULT_CONFIG, problems: string[]}}
172 */
173export function fromCcstatusline(raw) {
174 if (!isRecord(raw) || !Array.isArray(raw.lines)) {
175 return { config: DEFAULT_CONFIG, problems: ['ccstatusline 설정에 `lines` 가 없다 — 기본 설정을 쓴다'] };
176 }
177 const powerline = isRecord(raw.powerline) ? raw.powerline : {};
178 const themed = typeof powerline.theme === 'string' && Object.hasOwn(THEMES, powerline.theme);
179 const notes = [];
180 if (typeof powerline.theme === 'string' && !themed) {
181 notes.push(`ccstatusline 테마 \`${powerline.theme}\` 는 없다 — 항목의 색만 옮긴다`);
182 }
183
184 const lines = raw.lines
185 .filter((line) => Array.isArray(line) && line.length > 0)
186 .map((line) => ({ enabled: true, items: line.map((item) => convertItem(item, themed)) }));
187
188 const converted = {
189 version: CONFIG_VERSION,
190 powerline: {
191 enabled: powerline.enabled === true,
192 separator: Array.isArray(powerline.separators) && typeof powerline.separators[0] === 'string' ? powerline.separators[0] : '',
193 },
194 theme: themed ? powerline.theme : null,
195 separator: typeof raw.defaultSeparator === 'string' ? raw.defaultSeparator : DEFAULT_CONFIG.separator,
196 padding: typeof raw.defaultPadding === 'string' ? raw.defaultPadding : DEFAULT_CONFIG.padding,
197 bold: raw.globalBold === true,
198 minimal: raw.minimalistMode === true,
199 lines,
200 };
201 const { config, problems } = normalizeConfig(converted);
202 return { config, problems: [...notes, ...problems] };
203}
204
205function convertItem(raw, themed) {
206 if (!isRecord(raw)) return raw;
207 const item = { type: raw.type };
208 // 테마가 색을 정하면 항목에 남은 색은 테마 이전의 흔적이다. 옮기면 테마를 덮어쓴다.
209 if (!themed) {
210 const color = convertColor(raw.color);
211 const bg = convertColor(raw.backgroundColor);
212 if (color) item.color = color;
213 if (bg) item.bg = bg;
214 }
215 if (raw.bold === true) item.bold = true;
216 if (raw.dim === true) item.dim = true;
217 if (raw.rawValue === true) item.raw = true;
218 if (raw.merge === true || raw.merge === 'no-padding') item.merge = true;
219 if (typeof raw.maxWidth === 'number') item.maxWidth = raw.maxWidth;
220 if (typeof raw.customText === 'string') item.text = raw.customText;
221 if (typeof raw.customSymbol === 'string') item.symbol = raw.customSymbol;
222 if (isRecord(raw.metadata) && typeof raw.metadata.label === 'string') item.label = raw.metadata.label;
223 if (typeof raw.commandPath === 'string') {
224 item.command = ['sh', '-c', raw.commandPath];
225 item.id = typeof raw.id === 'string' ? raw.id : raw.commandPath;
226 }
227 return item;
228}
229
230/** ccstatusline 의 색 표기(`bgBrightGreen`, `hex:88C0D0`, `cyan`)를 hex 로. 못 옮기면 undefined. */
231function convertColor(color) {
232 if (typeof color !== 'string') return undefined;
233 if (color.startsWith('hex:')) return `#${color.slice(4)}`;
234 const name = color.startsWith('bg') ? color[2].toLowerCase() + color.slice(3) : color;
235 return ANSI_HEX[name];
236}
237
238function isRecord(value) {
239 return value !== null && typeof value === 'object' && !Array.isArray(value);
240}
241scripts/lib/layout.mjs 153 lines1/**
2 * 설정과 스냅숏을 «그릴 칸의 줄» 로 바꾼다 — 끄기·숨김·merge·구분자·테마 색·powerline 화살표.
3 *
4 * 결과는 화면 요소가 아니라 평평한 칸 목록이다. hooks module 은 칸 하나를 요소 하나로 옮기기만 한다.
5 *
6 * @typedef {{text: string, color?: string, bg?: string, bold?: boolean, dim?: boolean, url?: string}} TextCell
7 * @typedef {TextCell | {flex: true}} Cell
8 */
9
10import { truncate } from './format.mjs';
11import { FALLBACK_COLORS, THEMES } from './themes.mjs';
12import { WIDGETS } from './widgets/index.mjs';
13
14/**
15 * 켜진 항목들이 요구하는 소스의 이름. hooks module 은 여기 없는 소스를 모으지 않는다.
16 *
17 * @param {{lines: {enabled: boolean, items: {type: string, enabled?: boolean}[]}[]}} config
18 * @returns {Set<string>}
19 */
20export function neededSources(config) {
21 const needs = new Set();
22 for (const item of activeItems(config)) {
23 for (const need of WIDGETS[item.type]?.needs ?? []) needs.add(need);
24 }
25 return needs;
26}
27
28/**
29 * 켜진 줄의 켜진 항목.
30 *
31 * @param {{lines: {enabled: boolean, items: object[]}[]}} config
32 * @returns {object[]}
33 */
34export function activeItems(config) {
35 return config.lines.filter((line) => line.enabled).flatMap((line) => line.items.filter((item) => item.enabled !== false));
36}
37
38/**
39 * 그릴 줄들. 보일 칸이 하나도 없는 줄은 빠진다.
40 *
41 * @param {object} config `normalizeConfig` 를 거친 설정
42 * @param {object} snap 스냅숏 (`widgets/index.mjs` 참고)
43 * @returns {Cell[][]}
44 */
45export function buildRows(config, snap) {
46 const theme = config.theme ? THEMES[config.theme] : null;
47 const rows = [];
48 // 테마 색은 줄이 바뀌어도 이어서 돈다.
49 let groupIndex = -1;
50
51 for (const line of config.lines) {
52 if (!line.enabled) continue;
53 const tokens = [];
54 let previousMerged = false;
55 for (const item of line.items) {
56 if (item.enabled === false) continue;
57 if (item.type === 'flex-separator') {
58 tokens.push({ kind: 'flex' });
59 previousMerged = false;
60 continue;
61 }
62 if (item.type === 'separator') {
63 tokens.push({ kind: 'separator', text: item.text });
64 previousMerged = false;
65 continue;
66 }
67 const rendered = WIDGETS[item.type]?.render(snap, item);
68 if (!rendered) continue;
69 if (!previousMerged) groupIndex += 1;
70 tokens.push({ kind: 'segment', ...styleSegment(config, theme, groupIndex, item, rendered), joined: previousMerged });
71 previousMerged = item.merge === true;
72 }
73 if (!tokens.some((token) => token.kind === 'segment')) continue;
74 rows.push(config.powerline.enabled ? powerlineCells(config, tokens) : plainCells(config, tokens));
75 }
76 return rows;
77}
78
79function styleSegment(config, theme, groupIndex, item, rendered) {
80 // 항목에 적은 label 은 minimal·raw 에서도 보인다 — 일부러 적은 것이다.
81 const label = item.label ?? (item.raw || config.minimal ? '' : rendered.label);
82 const text = truncate(`${label}${rendered.value}`, item.maxWidth);
83 const themed = theme ? { color: theme.fg[groupIndex % theme.fg.length], bg: theme.bg[groupIndex % theme.bg.length] } : {};
84 const segment = {
85 text,
86 color: item.color ?? themed.color,
87 bg: item.bg ?? themed.bg,
88 bold: item.bold ?? config.bold,
89 dim: item.dim === true,
90 };
91 if (rendered.url) segment.url = rendered.url;
92 return segment;
93}
94
95/** powerline: 구간마다 배경을 칠하고, 구간 사이·끝에 앞 구간의 배경색으로 화살표를 그린다. */
96function powerlineCells(config, tokens) {
97 const cells = [];
98 let chainBg = null;
99 const closeChain = () => {
100 if (chainBg !== null) cells.push({ text: config.powerline.separator, color: chainBg });
101 chainBg = null;
102 };
103 for (const token of tokens) {
104 if (token.kind === 'flex') {
105 closeChain();
106 cells.push({ flex: true });
107 continue;
108 }
109 // 수동 구분자는 powerline 에서 뜻이 없다.
110 if (token.kind === 'separator') continue;
111 const bg = token.bg ?? FALLBACK_COLORS.bg;
112 const color = token.color ?? FALLBACK_COLORS.fg;
113 if (chainBg !== null && !token.joined) cells.push({ text: config.powerline.separator, color: chainBg, bg });
114 cells.push(textCell({ ...token, text: `${config.padding}${token.text}${config.padding}`, color, bg }));
115 chainBg = bg;
116 }
117 closeChain();
118 return cells;
119}
120
121/** 일반: 구간 사이에 구분자를 넣는다. 줄에 수동 구분자가 있으면 그것만 쓴다. */
122function plainCells(config, tokens) {
123 const manual = tokens.some((token) => token.kind === 'separator');
124 const cells = [];
125 let previousWasSegment = false;
126 for (const token of tokens) {
127 if (token.kind === 'flex') {
128 cells.push({ flex: true });
129 previousWasSegment = false;
130 continue;
131 }
132 if (token.kind === 'separator') {
133 cells.push({ text: token.text ?? config.separator, dim: true });
134 previousWasSegment = false;
135 continue;
136 }
137 if (previousWasSegment) cells.push(token.joined || manual ? { text: token.joined ? ' ' : '' } : { text: config.separator, dim: true });
138 cells.push(textCell(token));
139 previousWasSegment = true;
140 }
141 return cells.filter((cell) => cell.flex || cell.text !== '');
142}
143
144function textCell({ text, color, bg, bold, dim, url }) {
145 const cell = { text };
146 if (color) cell.color = color;
147 if (bg) cell.bg = bg;
148 if (bold) cell.bold = true;
149 if (dim) cell.dim = true;
150 if (url) cell.url = url;
151 return cell;
152}
153scripts/lib/parse-git.mjs 156 lines1/**
2 * git·gh·jj 명령의 출력을 값으로 읽는 순수 함수들. 명령을 부르는 쪽은 hooks module 이다.
3 */
4
5/**
6 * `git status --porcelain=v2 --branch` 의 출력.
7 *
8 * @param {string} output
9 * @returns {{branch: string | null, sha: string | null, upstream: string | null, ahead: number, behind: number,
10 * staged: number, unstaged: number, untracked: number, conflicts: number}}
11 */
12export function parseGitStatus(output) {
13 const status = { branch: null, sha: null, upstream: null, ahead: 0, behind: 0, staged: 0, unstaged: 0, untracked: 0, conflicts: 0 };
14 for (const line of output.split('\n')) {
15 if (line.startsWith('# branch.oid ')) {
16 const oid = line.slice('# branch.oid '.length);
17 status.sha = oid === '(initial)' ? null : oid;
18 } else if (line.startsWith('# branch.head ')) {
19 const head = line.slice('# branch.head '.length);
20 status.branch = head === '(detached)' ? null : head;
21 } else if (line.startsWith('# branch.upstream ')) {
22 status.upstream = line.slice('# branch.upstream '.length);
23 } else if (line.startsWith('# branch.ab ')) {
24 const match = /\+(\d+) -(\d+)/.exec(line);
25 if (match) {
26 status.ahead = Number(match[1]);
27 status.behind = Number(match[2]);
28 }
29 } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
30 // XY: X 는 인덱스, Y 는 작업 트리. `.` 은 변화 없음.
31 if (line[2] !== '.') status.staged += 1;
32 if (line[3] !== '.') status.unstaged += 1;
33 } else if (line.startsWith('u ')) {
34 status.conflicts += 1;
35 } else if (line.startsWith('? ')) {
36 status.untracked += 1;
37 }
38 }
39 return status;
40}
41
42/**
43 * `git diff --shortstat` (또는 `jj diff --stat` 의 마지막 줄)의 삽입·삭제 줄 수. 변화가 없으면 둘 다 0.
44 *
45 * @param {string} output
46 * @returns {{insertions: number, deletions: number}}
47 */
48export function parseShortstat(output) {
49 const insertions = /(\d+) insertions?\(\+\)/.exec(output);
50 const deletions = /(\d+) deletions?\(-\)/.exec(output);
51 return { insertions: insertions ? Number(insertions[1]) : 0, deletions: deletions ? Number(deletions[1]) : 0 };
52}
53
54/**
55 * 원격 URL 에서 호스트·소유자·저장소. ssh(`git@host:owner/repo.git`)와 URL 꼴을 읽는다. 못 읽으면 null.
56 *
57 * @param {string} url
58 * @returns {{host: string, owner: string, repo: string} | null}
59 */
60export function parseRemoteUrl(url) {
61 const match = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^:/]+)[:/](.+?)\/([^/]+?)(?:\.git)?\/?$/.exec(url.trim());
62 return match ? { host: match[1], owner: match[2], repo: match[3] } : null;
63}
64
65/**
66 * `git remote -v` 의 출력에서 origin 과 upstream.
67 *
68 * @param {string} output
69 * @returns {{origin: ReturnType<typeof parseRemoteUrl>, upstream: ReturnType<typeof parseRemoteUrl>}}
70 */
71export function parseRemotes(output) {
72 const remotes = { origin: null, upstream: null };
73 for (const line of output.split('\n')) {
74 const [name, url] = line.split(/\s+/);
75 if ((name === 'origin' || name === 'upstream') && url && !remotes[name]) remotes[name] = parseRemoteUrl(url);
76 }
77 return remotes;
78}
79
80/**
81 * `git rev-parse --path-format=absolute --git-dir --git-common-dir --show-toplevel` 의 세 줄.
82 * git-dir 이 common-dir 과 다르면 linked worktree 다.
83 *
84 * @param {string} output
85 * @returns {{isWorktree: boolean, name: string} | null}
86 */
87export function parseWorktree(output) {
88 const [gitDir, commonDir, toplevel] = output.split('\n').map((line) => line.trim());
89 if (!gitDir || !commonDir || !toplevel) return null;
90 const parts = toplevel.split('/').filter(Boolean);
91 return { isWorktree: gitDir !== commonDir, name: parts[parts.length - 1] ?? toplevel };
92}
93
94/**
95 * `gh pr view --json number,title,url,state,statusCheckRollup` 의 출력. PR 이 없거나 JSON 이 아니면 null.
96 *
97 * @param {string} output
98 * @returns {{pr: {number: number, title: string, url: string, state: string},
99 * checks: {failing: number, pending: number, passing: number}} | null}
100 */
101export function parsePullRequest(output) {
102 let json;
103 try {
104 json = JSON.parse(output);
105 } catch {
106 return null;
107 }
108 if (!json || typeof json.number !== 'number') return null;
109 const checks = { failing: 0, pending: 0, passing: 0 };
110 for (const check of Array.isArray(json.statusCheckRollup) ? json.statusCheckRollup : []) {
111 // CheckRun 은 status/conclusion, StatusContext 는 state 로 온다.
112 const outcome = String(check.conclusion || check.state || '').toUpperCase();
113 const running = check.status && String(check.status).toUpperCase() !== 'COMPLETED';
114 if (running || outcome === 'PENDING' || outcome === 'EXPECTED' || outcome === '') checks.pending += 1;
115 else if (['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(outcome)) checks.passing += 1;
116 else checks.failing += 1;
117 }
118 return { pr: { number: json.number, title: json.title ?? '', url: json.url ?? '', state: json.state ?? '' }, checks };
119}
120
121/**
122 * `jj log -r @ --no-graph -T 'change_id.shortest(8) ++ "\n" ++ bookmarks.join(",") ++ "\n" ++ description.first_line()'`
123 * 의 세 줄.
124 *
125 * @param {string} output
126 * @returns {{revision: string, bookmarks: string[], description: string} | null}
127 */
128export function parseJjLog(output) {
129 const [revision = '', bookmarks = '', description = ''] = output.split('\n');
130 if (!revision.trim()) return null;
131 return {
132 revision: revision.trim(),
133 bookmarks: bookmarks.split(',').map((name) => name.trim()).filter(Boolean),
134 description: description.trim(),
135 };
136}
137
138/**
139 * macOS `vm_stat` 의 출력과 전체 메모리(바이트)에서 쓰는 메모리. 페이지 크기를 못 읽으면 null.
140 *
141 * @param {string} vmStat
142 * @param {number} totalBytes `sysctl -n hw.memsize`
143 * @returns {{usedBytes: number, totalBytes: number} | null}
144 */
145export function parseVmStat(vmStat, totalBytes) {
146 const pageSize = /page size of (\d+) bytes/.exec(vmStat);
147 const pages = (name) => {
148 const match = new RegExp(`${name}:\\s+(\\d+)`).exec(vmStat);
149 return match ? Number(match[1]) : 0;
150 };
151 if (!pageSize || !(totalBytes > 0)) return null;
152 // 비어 있거나 바로 내줄 수 있는 페이지를 뺀 나머지를 «쓰는 중» 으로 본다.
153 const available = (pages('Pages free') + pages('Pages inactive') + pages('Pages speculative')) * Number(pageSize[1]);
154 return { usedBytes: Math.max(0, totalBytes - available), totalBytes };
155}
156scripts/lib/parse-transcript.mjs 131 lines1/**
2 * 세션 트랜스크립트(JSONL)와 usage API 응답을 읽는 순수 함수들.
3 *
4 * 트랜스크립트는 세션을 재개했을 때 «이전까지의 합계» 를 되찾는 곳이다. 이후의 턴은 hooks module 이
5 * `turn.complete` 에서 더한다.
6 */
7
8const EMPTY_USAGE = Object.freeze({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
9
10/**
11 * API 가 준 usage 를 네 값으로.
12 *
13 * @param {object | undefined} usage `input_tokens` 등 API 철자의 usage
14 * @returns {{input: number, output: number, cacheRead: number, cacheWrite: number}}
15 */
16export function usageOf(usage) {
17 return {
18 input: usage?.input_tokens ?? 0,
19 output: usage?.output_tokens ?? 0,
20 cacheRead: usage?.cache_read_input_tokens ?? 0,
21 cacheWrite: usage?.cache_creation_input_tokens ?? 0,
22 };
23}
24
25/**
26 * 두 usage 의 합.
27 *
28 * @param {ReturnType<typeof usageOf>} a
29 * @param {ReturnType<typeof usageOf>} b
30 * @returns {ReturnType<typeof usageOf>}
31 */
32export function addUsage(a, b) {
33 return {
34 input: a.input + b.input,
35 output: a.output + b.output,
36 cacheRead: a.cacheRead + b.cacheRead,
37 cacheWrite: a.cacheWrite + b.cacheWrite,
38 };
39}
40
41/**
42 * 트랜스크립트 전체에서 세션 이름·토큰 합계·압축 횟수·마지막 노력 수준을 읽는다.
43 *
44 * 한 응답은 내용 블록마다 줄이 따로 실리고 줄마다 같은 usage 가 붙는다. 요청 id 로 한 번만 센다.
45 * 서브에이전트(sidechain) 줄은 메인 대화의 합계에 넣지 않는다. 깨진 줄은 건너뛴다 — 쓰는 도중의
46 * 마지막 줄이 그럴 수 있다.
47 *
48 * @param {string} text
49 * @returns {{customTitle: string | null, aiTitle: string | null, effort: string | null, compactions: number,
50 * lastResponseAt: number | null, total: ReturnType<typeof usageOf>, last: ReturnType<typeof usageOf> | null}}
51 */
52export function summarizeTranscript(text) {
53 const summary = { customTitle: null, aiTitle: null, effort: null, compactions: 0, lastResponseAt: null, total: EMPTY_USAGE, last: null };
54 const byRequest = new Map();
55 for (const line of text.split('\n')) {
56 if (!line) continue;
57 let entry;
58 try {
59 entry = JSON.parse(line);
60 } catch {
61 continue;
62 }
63 if (entry.type === 'custom-title' && typeof entry.customTitle === 'string' && entry.customTitle) {
64 summary.customTitle = entry.customTitle;
65 } else if (entry.type === 'ai-title' && typeof entry.aiTitle === 'string' && entry.aiTitle) {
66 summary.aiTitle = entry.aiTitle;
67 } else if (entry.type === 'system' && entry.subtype === 'compact_boundary' && entry.isSidechain !== true) {
68 summary.compactions += 1;
69 } else if (entry.type === 'assistant' && entry.isSidechain !== true && entry.message?.usage) {
70 const usage = usageOf(entry.message.usage);
71 byRequest.set(entry.requestId ?? entry.message.id ?? entry.uuid, usage);
72 summary.last = usage;
73 if (typeof entry.effort === 'string') summary.effort = entry.effort;
74 const at = Date.parse(entry.timestamp ?? '');
75 if (!Number.isNaN(at)) summary.lastResponseAt = at;
76 }
77 }
78 for (const usage of byRequest.values()) summary.total = addUsage(summary.total, usage);
79 return summary;
80}
81
82/**
83 * 세션 루트에서 트랜스크립트 폴더 이름. Claude Code 는 경로의 영숫자가 아닌 글자를 `-` 로 바꾼다.
84 *
85 * @param {string} root
86 * @returns {string}
87 */
88export function projectSlug(root) {
89 return root.replace(/[^a-zA-Z0-9]/g, '-');
90}
91
92/**
93 * `/api/oauth/usage` 응답에서 모델별 주간 사용량과 추가 사용량. 금액은 응답의 `decimal_places`
94 * (없으면 2) 만큼 내려 통화 단위로 돌려준다.
95 *
96 * @param {unknown} json
97 * @returns {{perModel: Record<string, {percent: number, resetsAt: string | null}>,
98 * extra: {enabled: boolean, limit: number | null, used: number | null, utilization: number | null, currency: string} | null}}
99 */
100export function parseUsageApi(json) {
101 const result = { perModel: {}, extra: null };
102 if (json === null || typeof json !== 'object') return result;
103
104 for (const limit of Array.isArray(json.limits) ? json.limits : []) {
105 const name = limit?.scope?.model?.display_name;
106 if (limit?.kind !== 'weekly_scoped' || typeof name !== 'string' || typeof limit.percent !== 'number') continue;
107 const key = ['sonnet', 'opus', 'fable'].find((model) => name.toLowerCase().includes(model));
108 if (key) result.perModel[key] = { percent: limit.percent, resetsAt: limit.resets_at ?? null };
109 }
110 // limits[] 로 옮겨 가기 전의 평평한 키. 새 꼴이 있으면 그쪽이 맞다.
111 for (const [key, bucket] of [['sonnet', json.seven_day_sonnet], ['opus', json.seven_day_opus]]) {
112 if (!result.perModel[key] && bucket && typeof bucket.utilization === 'number') {
113 result.perModel[key] = { percent: bucket.utilization, resetsAt: bucket.resets_at ?? null };
114 }
115 }
116
117 const extra = json.extra_usage;
118 if (extra && typeof extra === 'object') {
119 const scale = 10 ** (typeof extra.decimal_places === 'number' ? extra.decimal_places : 2);
120 const amount = (value) => (typeof value === 'number' ? value / scale : null);
121 result.extra = {
122 enabled: extra.is_enabled === true,
123 limit: amount(extra.monthly_limit),
124 used: amount(extra.used_credits),
125 utilization: typeof extra.utilization === 'number' ? extra.utilization : null,
126 currency: typeof extra.currency === 'string' ? extra.currency : 'USD',
127 };
128 }
129 return result;
130}
131scripts/lib/themes.mjs 42 lines1/**
2 * powerline 테마 — 구간마다 돌려 쓰는 전경·배경 색 목록. ccstatusline 의 같은 이름 테마에서 truecolor 값을 옮겼다.
3 *
4 * 색은 `#RRGGBB` 로만 둔다. mod 의 `Text` 가 이름 색과 hex 를 받는 것은 실측했지만 `brightGreen` 같은
5 * 이름을 터미널마다 같게 그린다는 보장이 없어, 가져온 설정의 이름 색도 `ANSI_HEX` 로 hex 로 바꾼다.
6 */
7
8/** @type {Record<string, {fg: string[], bg: string[]}>} */
9export const THEMES = {
10 nord: {
11 fg: ['#2E3440', '#D8DEE9', '#FDF6E3', '#2E3440', '#2E3440'],
12 bg: ['#88C0D0', '#4C566A', '#5E81AC', '#B48EAD', '#A3BE8C'],
13 },
14 'nord-aurora': {
15 fg: ['#ECEFF4', '#2E3440', '#FDF6E3', '#2E3440', '#2E3440'],
16 bg: ['#BF616A', '#EBCB8B', '#5E81AC', '#A3BE8C', '#B48EAD'],
17 },
18};
19
20/** 테마도 항목 색도 없는 powerline 구간의 색. */
21export const FALLBACK_COLORS = { fg: '#D8DEE9', bg: '#4C566A' };
22
23/** ANSI 16색 이름의 hex (xterm 기본 팔레트). ccstatusline 설정의 이름 색을 옮길 때 쓴다. */
24export const ANSI_HEX = {
25 black: '#000000',
26 red: '#CD0000',
27 green: '#00CD00',
28 yellow: '#CDCD00',
29 blue: '#0000EE',
30 magenta: '#CD00CD',
31 cyan: '#00CDCD',
32 white: '#E5E5E5',
33 brightBlack: '#7F7F7F',
34 brightRed: '#FF0000',
35 brightGreen: '#00FF00',
36 brightYellow: '#FFFF00',
37 brightBlue: '#5C5CFF',
38 brightMagenta: '#FF00FF',
39 brightCyan: '#00FFFF',
40 brightWhite: '#FFFFFF',
41};
42scripts/lib/widgets/index.mjs 58 lines1/**
2 * 위젯 레지스트리 — 위젯 타입 id 에서 «무엇이 필요하고 어떻게 그리는가» 로 가는 단일 표.
3 *
4 * 타입 id 는 ccstatusline 의 것을 그대로 쓴다. 그 설정을 가져올 때 이름을 옮길 필요가 없다.
5 *
6 * 스냅숏(`snap`)은 hooks module 이 모아 넘기는 값의 묶음이다. 안 모은 소스의 키는 없고(undefined),
7 * 모았는데 해당 없음은 null 이다 (저장소 밖의 `git`).
8 *
9 * - `now`, `columns`, `home` — 그리는 시점의 시각(ms)·터미널 폭·홈 경로
10 * - `session` — model, version, id, cwd, name, effort
11 * - `repo` — root, remote (`$.session.repo()`)
12 * - `usage` — startedAt, context{tokens,window,percent}, rateLimits[{kind,percentUsed,resetsAt}], cost{usd}
13 * - `contextUsable` — maxTokens (압축 기준 창)
14 * - `settings` — effortLevel, voice, sandbox / `configRows` — /config 행의 key → value
15 * - `transcript` — customTitle, aiTitle, effort / `account` — email
16 * - `tokens` — input, output, cacheRead, cacheWrite, last{...}, lastResponseAt, compactions, turnInput, turnOutput, turnMs
17 * - `skills` — last, count
18 * - `git` — branch, sha, upstream, ahead, behind, staged, unstaged, untracked, conflicts
19 * - `gitDiff` — insertions, deletions / `gitRemotes` — origin, upstream / `gitWorktree` — isWorktree, name
20 * - `gh` — pr{number,title,url,state}, checks{failing,pending,passing}
21 * - `jj` — revision, bookmarks, description, root, insertions, deletions
22 * - `api` — perModel{sonnet,opus,fable}, extra / `status` — indicator / `memory` — usedBytes, totalBytes
23 * - `custom` — 항목 id → custom-command 출력
24 *
25 * @typedef {{label: string, value: string, url?: string}} Rendered
26 * @typedef {{category: string, needs: string[], render: (snap: object, item: object) => Rendered | null}} Widget
27 */
28
29import { ENV_WIDGETS } from './env.mjs';
30import { GIT_WIDGETS } from './git.mjs';
31import { SESSION_WIDGETS } from './session.mjs';
32import { USAGE_WIDGETS } from './usage.mjs';
33
34/** @type {Record<string, Widget>} */
35export const WIDGETS = { ...SESSION_WIDGETS, ...GIT_WIDGETS, ...USAGE_WIDGETS, ...ENV_WIDGETS };
36
37/** 위젯이 아니라 배치 규칙인 항목 타입. */
38export const LAYOUT_TYPES = ['separator', 'flex-separator'];
39
40/**
41 * ccstatusline 에는 있지만 mod 가 값을 얻을 곳이 없어 만들지 않은 타입과 그 이유.
42 * 가져온 설정에 있으면 이 이유를 그대로 알린다.
43 */
44export const UNSUPPORTED = {
45 'worktree-original-branch': 'worktree 를 어느 브랜치에서 만들었는지 mod 가 알 수 없다',
46 'jj-workspace': '만든 기기에 jj 가 없어 명령을 검증하지 못해 넣지 않았다',
47};
48
49/**
50 * 항목 타입이 설정에 쓸 수 있는 것인지.
51 *
52 * @param {string} type
53 * @returns {boolean}
54 */
55export function isKnownType(type) {
56 return Object.hasOwn(WIDGETS, type) || LAYOUT_TYPES.includes(type);
57}
58scripts/lib/format.mjs 134 lines1/**
2 * 위젯이 값을 글자로 바꿀 때 쓰는 순수 함수들 — 토큰 수, 기간, 막대, 경로, 모델 이름.
3 *
4 * `node:` import 를 두지 않는다. hooks module 이 import 하므로 mod 의 실행 환경에서 돌아야 한다.
5 */
6
7/**
8 * 토큰 수를 짧게 쓴다 (`950`, `12.3k`, `1.2M`).
9 *
10 * @param {number} count
11 * @returns {string}
12 */
13export function formatTokens(count) {
14 if (count < 1000) return String(Math.round(count));
15 if (count < 1_000_000) return `${trimZero((count / 1000).toFixed(1))}k`;
16 return `${trimZero((count / 1_000_000).toFixed(1))}M`;
17}
18
19/**
20 * 기간을 큰 단위 둘로 쓴다 (`2d 3hr`, `1hr 5m`, `12m`, `40s`). 음수는 0 으로 본다.
21 *
22 * @param {number} ms
23 * @returns {string}
24 */
25export function formatDuration(ms) {
26 const totalSeconds = Math.max(0, Math.floor(ms / 1000));
27 const days = Math.floor(totalSeconds / 86_400);
28 const hours = Math.floor((totalSeconds % 86_400) / 3600);
29 const minutes = Math.floor((totalSeconds % 3600) / 60);
30 if (days > 0) return `${days}d ${hours}hr`;
31 if (hours > 0) return `${hours}hr ${minutes}m`;
32 if (minutes > 0) return `${minutes}m`;
33 return `${totalSeconds}s`;
34}
35
36/**
37 * 퍼센트를 소수 한 자리까지 쓴다 (`18%`, `23.5%`).
38 *
39 * @param {number} percent
40 * @returns {string}
41 */
42export function formatPercent(percent) {
43 return `${trimZero(percent.toFixed(1))}%`;
44}
45
46/**
47 * 채움 막대 (`███░░░░░░░`). 0~100 밖의 값은 끝에 붙인다.
48 *
49 * @param {number} percent
50 * @param {number} [width]
51 * @returns {string}
52 */
53export function bar(percent, width = 10) {
54 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width);
55 return '█'.repeat(filled) + '░'.repeat(width - filled);
56}
57
58/**
59 * 금액 (`$2.61`). 통화 코드가 USD 가 아니면 코드를 뒤에 붙인다.
60 *
61 * @param {number} amount
62 * @param {string} [currency]
63 * @returns {string}
64 */
65export function formatMoney(amount, currency = 'USD') {
66 return currency === 'USD' ? `$${amount.toFixed(2)}` : `${amount.toFixed(2)} ${currency}`;
67}
68
69/**
70 * 바이트를 GiB 로 (`12.3G`).
71 *
72 * @param {number} bytes
73 * @returns {string}
74 */
75export function formatGigabytes(bytes) {
76 return `${trimZero((bytes / 1024 ** 3).toFixed(1))}G`;
77}
78
79/**
80 * 경로의 마지막 조각.
81 *
82 * @param {string} path
83 * @returns {string}
84 */
85export function basename(path) {
86 const parts = path.split('/').filter(Boolean);
87 return parts.length > 0 ? parts[parts.length - 1] : path;
88}
89
90/**
91 * 홈 아래 경로를 `~` 로 줄인다.
92 *
93 * @param {string} path
94 * @param {string | undefined} home
95 * @returns {string}
96 */
97export function tildePath(path, home) {
98 if (!home) return path;
99 if (path === home) return '~';
100 return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
101}
102
103/**
104 * 모델 id 를 읽기 좋게 (`claude-opus-5-5` → `Opus 5.5`). 모르는 꼴은 그대로 둔다.
105 *
106 * @param {string} model
107 * @returns {string}
108 */
109export function prettyModel(model) {
110 const bare = model.replace(/\[[^\]]*\]$/, '').replace(/\s*\([^)]*\)$/, '');
111 const match = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/.exec(bare);
112 if (!match) return bare;
113 const [, family, major, minor] = match;
114 const name = family[0].toUpperCase() + family.slice(1);
115 return minor === undefined ? `${name} ${major}` : `${name} ${major}.${minor}`;
116}
117
118/**
119 * `max` 칸을 넘으면 끝을 `…` 로 자른다. `max` 가 없으면 그대로.
120 *
121 * @param {string} text
122 * @param {number | undefined} max
123 * @returns {string}
124 */
125export function truncate(text, max) {
126 if (!max || max < 1) return text;
127 const chars = Array.from(text);
128 return chars.length <= max ? text : `${chars.slice(0, max - 1).join('')}…`;
129}
130
131function trimZero(text) {
132 return text.endsWith('.0') ? text.slice(0, -2) : text;
133}
134scripts/lib/widgets/env.mjs 54 lines1/**
2 * 환경·커스텀 위젯 — 작업 디렉터리, 터미널 폭, 메모리, 사용자가 적은 글자·명령·링크.
3 *
4 * `separator` 와 `flex-separator` 는 위젯이 아니라 배치 규칙이라 `layout.mjs` 가 직접 다룬다.
5 */
6
7import { formatGigabytes, tildePath } from '../format.mjs';
8
9/** @type {Record<string, import('./index.mjs').Widget>} */
10export const ENV_WIDGETS = {
11 'current-working-dir': {
12 category: 'env',
13 needs: ['session'],
14 render: (snap) => (snap.session?.cwd ? { label: 'cwd: ', value: tildePath(snap.session.cwd, snap.home) } : null),
15 },
16 'terminal-width': {
17 category: 'env',
18 needs: [],
19 render: (snap) => (snap.columns ? { label: 'Width: ', value: String(snap.columns) } : null),
20 },
21 'free-memory': {
22 category: 'env',
23 needs: ['memory'],
24 render: (snap) => {
25 const memory = snap.memory;
26 if (!memory) return null;
27 return { label: 'Mem: ', value: `${formatGigabytes(memory.usedBytes)}/${formatGigabytes(memory.totalBytes)}` };
28 },
29 },
30 'custom-text': {
31 category: 'custom',
32 needs: [],
33 render: (snap, item) => (item.text ? { label: '', value: item.text } : null),
34 },
35 'custom-symbol': {
36 category: 'custom',
37 needs: [],
38 render: (snap, item) => (item.symbol ? { label: '', value: item.symbol } : null),
39 },
40 'custom-command': {
41 category: 'custom',
42 needs: ['custom'],
43 render: (snap, item) => {
44 const output = item.id ? snap.custom?.[item.id] : undefined;
45 return output ? { label: '', value: output } : null;
46 },
47 },
48 link: {
49 category: 'custom',
50 needs: [],
51 render: (snap, item) => (item.url ? { label: '', value: item.text ?? item.url, url: item.url } : null),
52 },
53};
54scripts/lib/widgets/git.mjs 176 lines1/**
2 * Git·JJ 위젯. 값은 `parse-git.mjs` 가 읽은 것을 그대로 쓴다 — 여기서는 git 을 부르지 않는다.
3 *
4 * 저장소 밖이면 `snap.git` 이 null 이고 모든 git 위젯은 숨는다. 개수 위젯은 0 이면 숨고, 항목의
5 * `showZero` 가 켜져 있으면 0 도 보인다.
6 */
7
8import { basename } from '../format.mjs';
9
10const count = (label, pick) => (snap, item) => {
11 const value = snap.git ? pick(snap.git) : undefined;
12 if (typeof value !== 'number') return null;
13 if (value === 0 && !item.showZero) return null;
14 return { label, value: String(value) };
15};
16
17const flag = (glyph, pick) => (snap) => (snap.git && pick(snap.git) > 0 ? { label: '', value: glyph } : null);
18
19const remotePart = (name, pick) => (snap) => {
20 const remote = snap.gitRemotes?.[name];
21 return remote ? { label: '', value: pick(remote) } : null;
22};
23
24const diffPart = (source, format) => (snap, item) => {
25 const diff = snap[source];
26 if (!diff) return null;
27 if (diff.insertions === 0 && diff.deletions === 0 && !item.showZero) return null;
28 return { label: '', value: format(diff) };
29};
30
31/** @type {Record<string, import('./index.mjs').Widget>} */
32export const GIT_WIDGETS = {
33 'git-branch': {
34 category: 'git',
35 needs: ['git'],
36 render: (snap) => (snap.git?.branch ? { label: '⎇ ', value: snap.git.branch } : null),
37 },
38 'git-root-dir': {
39 category: 'git',
40 needs: ['session'],
41 render: (snap) => (snap.repo?.root ? { label: '', value: basename(snap.repo.root) } : null),
42 },
43 'git-sha': {
44 category: 'git',
45 needs: ['git'],
46 render: (snap) => (snap.git?.sha ? { label: '', value: snap.git.sha.slice(0, 7) } : null),
47 },
48 'git-changes': { category: 'git', needs: ['gitDiff'], render: diffPart('gitDiff', (d) => `(+${d.insertions},-${d.deletions})`) },
49 'git-insertions': { category: 'git', needs: ['gitDiff'], render: diffPart('gitDiff', (d) => `+${d.insertions}`) },
50 'git-deletions': { category: 'git', needs: ['gitDiff'], render: diffPart('gitDiff', (d) => `-${d.deletions}`) },
51 'git-staged-files': { category: 'git', needs: ['git'], render: count('S:', (g) => g.staged) },
52 'git-unstaged-files': { category: 'git', needs: ['git'], render: count('M:', (g) => g.unstaged) },
53 'git-untracked-files': { category: 'git', needs: ['git'], render: count('?:', (g) => g.untracked) },
54 'git-conflicts': { category: 'git', needs: ['git'], render: count('⚠ ', (g) => g.conflicts) },
55 'git-staged': { category: 'git', needs: ['git'], render: flag('+', (g) => g.staged) },
56 'git-unstaged': { category: 'git', needs: ['git'], render: flag('!', (g) => g.unstaged) },
57 'git-untracked': { category: 'git', needs: ['git'], render: flag('?', (g) => g.untracked) },
58 'git-status': {
59 category: 'git',
60 needs: ['git'],
61 render: (snap) => {
62 if (!snap.git) return null;
63 const marks = [
64 snap.git.conflicts > 0 ? '⚠' : '',
65 snap.git.staged > 0 ? '+' : '',
66 snap.git.unstaged > 0 ? '!' : '',
67 snap.git.untracked > 0 ? '?' : '',
68 ].join('');
69 return marks ? { label: '', value: marks } : null;
70 },
71 },
72 'git-clean-status': {
73 category: 'git',
74 needs: ['git'],
75 render: (snap) => {
76 if (!snap.git) return null;
77 const dirty = snap.git.staged + snap.git.unstaged + snap.git.untracked + snap.git.conflicts > 0;
78 return { label: '', value: dirty ? '✗' : '✓' };
79 },
80 },
81 'git-ahead-behind': {
82 category: 'git',
83 needs: ['git'],
84 render: (snap, item) => {
85 if (!snap.git?.upstream) return null;
86 const { ahead, behind } = snap.git;
87 if (ahead === 0 && behind === 0 && !item.showZero) return null;
88 return { label: '', value: `↑${ahead}↓${behind}` };
89 },
90 },
91 'git-origin-owner': { category: 'git', needs: ['gitRemotes'], render: remotePart('origin', (r) => r.owner) },
92 'git-origin-repo': { category: 'git', needs: ['gitRemotes'], render: remotePart('origin', (r) => r.repo) },
93 'git-origin-owner-repo': { category: 'git', needs: ['gitRemotes'], render: remotePart('origin', (r) => `${r.owner}/${r.repo}`) },
94 'git-upstream-owner': { category: 'git', needs: ['gitRemotes'], render: remotePart('upstream', (r) => r.owner) },
95 'git-upstream-repo': { category: 'git', needs: ['gitRemotes'], render: remotePart('upstream', (r) => r.repo) },
96 'git-upstream-owner-repo': {
97 category: 'git',
98 needs: ['gitRemotes'],
99 render: remotePart('upstream', (r) => `${r.owner}/${r.repo}`),
100 },
101 'git-is-fork': {
102 category: 'git',
103 needs: ['gitRemotes'],
104 render: (snap) => (snap.gitRemotes?.upstream ? { label: '', value: 'fork' } : null),
105 },
106 'git-worktree': {
107 category: 'git',
108 needs: ['gitWorktree'],
109 render: (snap) => (snap.gitWorktree?.isWorktree ? { label: 'wt: ', value: snap.gitWorktree.name } : null),
110 },
111 // 세션이 `--worktree` 로 열렸는지는 mod 가 알 수 없다. 현재 디렉터리가 linked worktree 인지로 대신한다.
112 'worktree-mode': {
113 category: 'git',
114 needs: ['gitWorktree'],
115 render: (snap) => (snap.gitWorktree?.isWorktree ? { label: '', value: 'worktree' } : null),
116 },
117 'worktree-name': {
118 category: 'git',
119 needs: ['gitWorktree'],
120 render: (snap) => (snap.gitWorktree?.isWorktree ? { label: '', value: snap.gitWorktree.name } : null),
121 },
122 'worktree-branch': {
123 category: 'git',
124 needs: ['gitWorktree', 'git'],
125 render: (snap) => (snap.gitWorktree?.isWorktree && snap.git?.branch ? { label: '', value: snap.git.branch } : null),
126 },
127 'git-review': {
128 category: 'git',
129 needs: ['gh'],
130 render: (snap, item) => {
131 const pr = snap.gh?.pr;
132 if (!pr) return null;
133 const parts = [`#${pr.number}`];
134 if (item.format !== 'number' && pr.state && pr.state !== 'OPEN') parts.push(pr.state.toLowerCase());
135 if (item.format !== 'number' && pr.title) parts.push(pr.title);
136 return { label: 'PR ', value: parts.join(' '), url: pr.url };
137 },
138 },
139 'git-ci-status': {
140 category: 'git',
141 needs: ['gh'],
142 render: (snap) => {
143 const checks = snap.gh?.checks;
144 if (!checks || checks.failing + checks.pending + checks.passing === 0) return null;
145 const parts = [];
146 if (checks.failing > 0) parts.push(`✗${checks.failing}`);
147 if (checks.pending > 0) parts.push(`●${checks.pending}`);
148 if (checks.passing > 0) parts.push(`✓${checks.passing}`);
149 return { label: '', value: parts.join(' ') };
150 },
151 },
152 'jj-revision': {
153 category: 'jj',
154 needs: ['jj'],
155 render: (snap) => (snap.jj?.revision ? { label: '', value: snap.jj.revision } : null),
156 },
157 'jj-bookmarks': {
158 category: 'jj',
159 needs: ['jj'],
160 render: (snap) => (snap.jj?.bookmarks?.length ? { label: '', value: snap.jj.bookmarks.join(',') } : null),
161 },
162 'jj-description': {
163 category: 'jj',
164 needs: ['jj'],
165 render: (snap) => (snap.jj?.description ? { label: '', value: snap.jj.description } : null),
166 },
167 'jj-root-dir': {
168 category: 'jj',
169 needs: ['jj'],
170 render: (snap) => (snap.jj?.root ? { label: '', value: basename(snap.jj.root) } : null),
171 },
172 'jj-changes': { category: 'jj', needs: ['jj'], render: diffPart('jj', (d) => `(+${d.insertions},-${d.deletions})`) },
173 'jj-insertions': { category: 'jj', needs: ['jj'], render: diffPart('jj', (d) => `+${d.insertions}`) },
174 'jj-deletions': { category: 'jj', needs: ['jj'], render: diffPart('jj', (d) => `-${d.deletions}`) },
175};
176scripts/lib/widgets/session.mjs 127 lines1/**
2 * Claude·세션 위젯 — 모델, 버전, 세션 id·이름, 노력 수준, 출력 스타일, 비용, 경과 시간 등.
3 *
4 * 위젯은 `(snap, item) => { label, value } | null` 이다. null 은 «보일 값이 없다» 이고 그 칸은 그리지 않는다.
5 */
6
7import { formatDuration, formatMoney, prettyModel } from '../format.mjs';
8
9const onOff = (flag) => (flag ? 'on' : 'off');
10
11/** @type {Record<string, import('./index.mjs').Widget>} */
12export const SESSION_WIDGETS = {
13 model: {
14 category: 'session',
15 needs: ['session'],
16 render: (snap) => (snap.session?.model ? { label: 'Model: ', value: prettyModel(snap.session.model) } : null),
17 },
18 version: {
19 category: 'session',
20 needs: ['session'],
21 render: (snap) => (snap.session?.version ? { label: 'Version: ', value: snap.session.version } : null),
22 },
23 'claude-session-id': {
24 category: 'session',
25 needs: ['session'],
26 render: (snap) => (snap.session?.id ? { label: 'Session ID: ', value: snap.session.id } : null),
27 },
28 'session-name': {
29 category: 'session',
30 needs: ['transcript'],
31 render: (snap) => {
32 const name = snap.session?.name ?? snap.transcript?.customTitle ?? snap.transcript?.aiTitle;
33 return name ? { label: 'Session: ', value: name } : null;
34 },
35 },
36 'claude-account-email': {
37 category: 'session',
38 needs: ['account'],
39 render: (snap) => (snap.account?.email ? { label: 'Account: ', value: snap.account.email } : null),
40 },
41 'claude-status': {
42 category: 'session',
43 needs: ['status'],
44 render: (snap) => {
45 const indicator = snap.status?.indicator;
46 if (!indicator) return null;
47 return { label: 'Claude: ', value: indicator === 'none' ? 'ok' : indicator };
48 },
49 },
50 'thinking-effort': {
51 category: 'session',
52 needs: ['settings'],
53 render: (snap) => {
54 const effort = snap.session?.effort ?? snap.transcript?.effort ?? snap.settings?.effortLevel;
55 return effort ? { label: 'Effort: ', value: String(effort) } : null;
56 },
57 },
58 'output-style': {
59 category: 'session',
60 needs: ['configRows'],
61 render: (snap, item) => {
62 const style = snap.configRows?.outputStyle;
63 if (style === undefined || style === null) return null;
64 if (style === 'default' && !item.showZero) return null;
65 return { label: 'Style: ', value: String(style) };
66 },
67 },
68 // 실시간 INSERT/NORMAL 은 mod 가 읽을 곳이 없다. 편집기 모드 설정(normal/vim)만 보인다.
69 'vim-mode': {
70 category: 'session',
71 needs: ['configRows'],
72 render: (snap) => {
73 const editor = snap.configRows?.editor;
74 return editor && editor !== 'normal' ? { label: 'Editor: ', value: String(editor) } : null;
75 },
76 },
77 'voice-status': {
78 category: 'session',
79 needs: ['settings'],
80 render: (snap) => (snap.settings ? { label: 'voice ', value: onOff(snap.settings.voice?.enabled === true) } : null),
81 },
82 'sandbox-status': {
83 category: 'session',
84 needs: ['settings'],
85 render: (snap) => (snap.settings ? { label: 'SB: ', value: snap.settings.sandbox?.enabled === true ? 'ON' : 'OFF' } : null),
86 },
87 'remote-control-status': {
88 category: 'session',
89 needs: ['configRows'],
90 render: (snap) => {
91 const remote = snap.configRows?.remoteControl;
92 if (remote === undefined || remote === null) return null;
93 return { label: 'RC: ', value: typeof remote === 'boolean' ? onOff(remote) : String(remote) };
94 },
95 },
96 skills: {
97 category: 'session',
98 needs: [],
99 render: (snap) => {
100 const skills = snap.skills;
101 if (!skills || skills.count === 0) return null;
102 return { label: 'Skill: ', value: skills.count > 1 ? `${skills.last} ×${skills.count}` : skills.last };
103 },
104 },
105 'session-clock': {
106 category: 'session',
107 needs: ['usage', 'clock'],
108 render: (snap, item) => {
109 const startedAt = snap.usage?.startedAt;
110 if (!startedAt || !snap.now) return null;
111 const elapsed = snap.now - startedAt;
112 if (elapsed < 60_000 && !item.showZero) return null;
113 return { label: 'Session: ', value: formatDuration(elapsed) };
114 },
115 },
116 'session-cost': {
117 category: 'session',
118 needs: ['usage'],
119 render: (snap, item) => {
120 const usd = snap.usage?.cost?.usd;
121 if (typeof usd !== 'number') return null;
122 if (usd === 0 && !item.showZero) return null;
123 return { label: 'Cost: ', value: formatMoney(usd) };
124 },
125 },
126};
127