SLOPSHOPPER

statusline

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

newspinnercommandpromptprocessnetwork
v0.1.0no licenseupdated 2026-10-09aydenden/cc-plugins/plugins/statusline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusline
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /sl ⎿ statusline: 설정: /Users/dev/.claude/statusline/settings.json (없음 — 기본 설정으로 그리는 중, `/sl init` 으로 만든다) ⎿ statusline: powerline on · 테마 nord · minimal on ⎿ statusline: ⎿ statusline: 1번 줄 ⎿ statusline: 1.1 ● model merge=true ⎿ statusline: 1.2 ● thinking-effort ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ Opus 5.5 app (+14,-3) [████████░░░░░░░░] 97k/200k (49%) 31%

Draws

Prompt hint
⟨Claude Code's own drawing⟩ Opus 5.5 app (+14,-3) [████████░░░░░░░░] 97k/200k (49%) 31%
README

statusline

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 로 잇는다
themenord, 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넘으면 … 로 자른다
showZero0 이어서 숨는 값을 보이게 한다
text, symbol, urlcustom-text, custom-symbol, link 의 내용
id, commandcustom-command 의 이름과 argv 배열 (셸 없이 실행, 첫 줄만 쓴다)
width, formatcontext-bar 의 칸 수(기본 16)와 percent(토큰 수 없이 막대와 퍼센트만)
ttlMinutescache-timer 의 TTL
formatgit-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 행과 설정
토큰 합계, 캐시, 속도, 압축 횟수트랜스크립트로 재개 전까지를 되찾고 턴마다 더한다
gitgit status --porcelain=v2 --branch 하나에서 대부분. 변경 줄 수·원격·worktree 는 그 위젯이 켜졌을 때만 더 부른다
PR·CIgh pr view
계정 이메일~/.claude.json

ccstatusline 과 다른 곳

위젯차이
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 로 돌려 보지 못했다.

전제

  • Claude Code v2.1.287 이상 (mod). mod 가 안 뜨는 세션(구버전, 조직 정책, --safe-mode)에서는 아무것도 그리지 않는다
  • 터미널과 데스크톱에서만 그려진다 (PromptHint 가 있는 곳)
  • powerline 화살표는 Nerd Font 나 powerline 글리프가 있는 글꼴이 필요하다
  • 쓰던 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          테마 색
Source 13 files
hooks/register.mjs 441 lines
1/**
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}
441
scripts/lib/command.mjs 269 lines
1/**
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}
269
scripts/lib/config.mjs 241 lines
1/**
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}
241
scripts/lib/layout.mjs 153 lines
1/**
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}
153
scripts/lib/parse-git.mjs 156 lines
1/**
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}
156
scripts/lib/parse-transcript.mjs 131 lines
1/**
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}
131
scripts/lib/themes.mjs 42 lines
1/**
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};
42
scripts/lib/widgets/index.mjs 58 lines
1/**
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}
58
scripts/lib/format.mjs 134 lines
1/**
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}
134
scripts/lib/widgets/env.mjs 54 lines
1/**
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};
54
scripts/lib/widgets/git.mjs 176 lines
1/**
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};
176
scripts/lib/widgets/session.mjs 127 lines
1/**
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