SLOPSHOPPER

ccstatus

A configurable status bar for Claude Code — band, status line, pane, and toasts driven by one config; configured with npx @sushant-kum/ccstatus or /ccstatus.

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-09sushant-kum/ccstatus/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ccstatus
› 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 › /ccstatus ⎿ ccstatus: ccstatus band hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ccstatus

A configurable status bar for Claude Code — the mod-system analog of ccstatusline. One JSON config drives four surfaces — the above-prompt band, the native status line, an openable pane, and threshold toasts — edited with an interactive TUI and rendered live by a Claude Code mod. The TUI preview and the mod paint from the same engine, so what you preview is what you get.

Status: v1, pre-release (0.1.0). Not yet published to npm or a hosted marketplace — the published commands below work once it ships; until then use the from-source setup.

Install

ccstatus has two halves that share one config file. _(Published install — these work once ccstatus is on npm and the marketplace; to try it before then, see Development.)_

1. The mod (renders the bar)

claude plugin marketplace add sushant-kum/ccstatus
claude plugin install ccstatus@ccstatus

Then /ccstatus toggles the band; /ccstatus pane | reload | theme <name> | edit cover the rest.

2. The configurator (edits the config)

npx @sushant-kum/ccstatus

An interactive TUI with a live band preview on every screen. Changes are saved to the shared config file and the running mod hot-reloads them.

On launch, npx @sushant-kum/ccstatus checks whether the mod is installed; if it isn't, it shows a banner and offers to install it for you (press i). A successful install takes effect after you restart Claude Code or run /reload-plugins.

The four surfaces

  • Band — a status row above the prompt.
  • Status line — the native Claude Code status line.
  • Pane — an openable panel with the full bar.
  • Toasts — threshold alerts (e.g. context % over a limit).

All four are driven by the same config and the same 21 widget types (model, context length/%, tokens, git branch/changes/worktree, session clock, cost, rate limits, block timer, cwd, custom text/command, flex separator, …), with 9 built-in themes and a configurable Powerline separator.

Configuration

One JSON file at $XDG_CONFIG_HOME/ccstatus/config.json (else ~/.config/ccstatus/config.json), shared by the mod and the TUI. It is hand-editable: invalid input never crashes either half — it degrades with a warning. Colors are the named terminal palette (v1; no hex yet).

Demo

A demo gif will be added here (docs/demo.gif).

Development

npm-workspaces monorepo: @ccstatus/core (pure engine), @sushant-kum/ccstatus (the TUI bin), and plugin/ (the mod). See CLAUDE.md and the decision/flow records under docs/.

npm install
npm test -w @ccstatus/core
npm test -w @sushant-kum/ccstatus
npm run build:plugin-core && claude plugin test plugin

License

MIT © Sushant Kumar

Source 8 files
hooks/register.tsx 462 lines
1import { atom, read, update } from 'claude-code';
2import type { EngineInterface, Register } from 'claude-code';
3
4import { configPath, parseConfig, shouldReload, snapshotPath } from './config-io.js';
5import { render } from './core.js';
6import type { Config, Snapshot } from './core.js';
7import { parseGit } from './git-parse.js';
8import { paintModel } from './paint.js';
9import { buildSnapshot, type RawUsage } from './snapshot-build.js';
10import { dueToasts, evalWhen } from './toasts.js';
11
12const visible = atom({ plugin: 'ccstatus', key: 'visible' } as const, true);
13const snapshot = atom({ plugin: 'ccstatus', key: 'snapshot' } as const, null);
14const config = atom({ plugin: 'ccstatus', key: 'config' } as const, null);
15const paneOpen = atom({ plugin: 'ccstatus', key: 'paneOpen' } as const, false);
16const toastFired = atom(
17  { plugin: 'ccstatus', key: 'toastFired' } as const,
18  {} as Record<string, boolean>
19);
20const configMtime = atom(
21  { plugin: 'ccstatus', key: 'configMtime' } as const,
22  null as number | null
23);
24const effort = atom({ plugin: 'ccstatus', key: 'effort' } as const, null);
25
26const GIT =
27  'r=$(git rev-parse --show-toplevel 2>/dev/null)||{ printf NO;exit 0;};' +
28  'b=$(git rev-parse --abbrev-ref HEAD 2>/dev/null);' +
29  'gd=$(git rev-parse --git-dir 2>/dev/null); case "$gd" in */worktrees/*) w=$(basename "$gd");; *) w="";; esac;' +
30  's=$(git status --porcelain 2>/dev/null);' +
31  'a=$(printf "%s\\n" "$s"|grep -cE "^(A.|.A|\\?\\?)");' +
32  'm=$(printf "%s\\n" "$s"|grep -cE "^(M.|.M)");' +
33  'd=$(printf "%s\\n" "$s"|grep -cE "^(D.|.D)");' +
34  'printf "YES\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s" "$(basename "$r")" "$b" "$w" "$a" "$m" "$d"';
35
36/**
37 * Resolve the config file path using the engine's environment.
38 * @param $ - The engine interface.
39 * @returns The absolute path to the config file.
40 */
41async function resolveConfigPath($: EngineInterface): Promise<string> {
42  const xdg = await $.env.get('XDG_CONFIG_HOME').catch(() => undefined);
43  const home = await $.env.get('HOME').catch(() => undefined);
44  return configPath({ XDG_CONFIG_HOME: xdg, HOME: home });
45}
46
47/**
48 * Read and parse the config from disk, falling back to defaults on any error.
49 * @param $ - The engine interface.
50 * @returns The loaded config and any warnings raised while validating it.
51 */
52async function loadConfigFromDisk(
53  $: EngineInterface
54): Promise<{ config: Config; warnings: string[] }> {
55  try {
56    const text = await $.fs.read(await resolveConfigPath($)).catch(() => null);
57    return parseConfig(typeof text === 'string' ? text : null);
58  } catch {
59    return parseConfig(null);
60  }
61}
62
63/**
64 * Re-read the config when its mtime changed, or always when forced.
65 * @param $     - The engine interface.
66 * @param force - When `true`, reload regardless of the file's mtime.
67 * @returns     `true` when the config was reloaded, `false` otherwise.
68 */
69async function reloadConfig($: EngineInterface, force: boolean): Promise<boolean> {
70  try {
71    const path = await resolveConfigPath($);
72    const stat = await $.fs.stat(path).catch(() => null);
73    const prev = await read($, configMtime);
74    if (!force && (!stat || !shouldReload(prev, stat))) {
75      return false;
76    }
77    const { config: cfg, warnings } = await loadConfigFromDisk($);
78    await update($, config, () => cfg);
79    await update($, configMtime, () => (stat ? stat.mtimeMs : null));
80    // Make a rejected/degraded config observable instead of silently showing defaults.
81    if (warnings.length) {
82      try {
83        await $.ui.toast(
84          `ccstatus: config had ${warnings.length} problem(s); using defaults where needed — run \`npx @sushant-kum/ccstatus\` to fix`
85        );
86      } catch {
87        /* guarded: never throw from a reload */
88      }
89    }
90    return true;
91  } catch {
92    return false;
93  }
94}
95
96/**
97 * Switch the active theme and persist it to the config file atomically.
98 * @param $    - The engine interface.
99 * @param name - The theme name to switch to.
100 * @returns    A status message describing the outcome.
101 */
102async function setTheme($: EngineInterface, name: string): Promise<string> {
103  const cfg = (await read($, config)) as Config | null;
104  if (!cfg) {
105    return 'ccstatus: config not loaded yet.';
106  }
107  if (!name || !cfg.themes[name]) {
108    return `ccstatus: unknown theme "${name}". Available: ${Object.keys(cfg.themes).join(', ')}`;
109  }
110  const next: Config = { ...cfg, theme: name };
111  await update($, config, () => next);
112  const path = await resolveConfigPath($);
113  const tmp = path + '.tmp';
114  try {
115    // Atomic write, matching the TUI's saveConfigFile discipline. The sandbox fs
116    // has no rename, but $.process.run does (the same shell-out the git snapshot
117    // uses), and rename within one directory is atomic — so a reader never sees a
118    // half-written config.json, and a .bak keeps the prior file recoverable.
119    const existing = await $.fs.read(path).catch(() => null);
120    if (typeof existing === 'string') {
121      await $.fs.write(path + '.bak', existing).catch(() => {
122        /* no-op: a missing backup must not abort the write */
123      });
124    }
125    await $.fs.write(tmp, JSON.stringify(next, null, 2) + '\n');
126    const { exitCode } = await $.process.run(['mv', '-f', tmp, path], { timeoutMs: 5000 });
127    if (exitCode !== 0) {
128      return `ccstatus: theme set to ${name} (could not save the config file).`;
129    }
130  } catch {
131    return `ccstatus: theme set to ${name} (could not save the config file).`;
132  }
133  return `ccstatus: theme set to ${name}.`;
134}
135
136type UsageRaw = Awaited<ReturnType<EngineInterface['session']['usage']>>;
137
138/**
139 * Run the git probe in a directory, returning no-repo fields on any failure.
140 * @param $   - The engine interface.
141 * @param cwd - The directory to probe, or an empty string to skip the probe.
142 * @returns   The parsed git fields for the directory.
143 */
144async function probeGit($: EngineInterface, cwd: string): Promise<ReturnType<typeof parseGit>> {
145  if (!cwd) {
146    return parseGit('NO');
147  }
148  try {
149    const { stdout } = await $.process.run(['sh', '-c', GIT], { cwd, timeoutMs: 5000 });
150    return parseGit(stdout || '');
151  } catch {
152    return parseGit('NO');
153  }
154}
155
156type UsageContext = NonNullable<UsageRaw>['context'];
157type UsageRateLimits = NonNullable<UsageRaw>['rateLimits'];
158
159/**
160 * Reduce the context portion of the raw usage report into snapshot fields.
161 * @param context - The raw context report, or `null`/`undefined` when absent.
162 * @returns       The normalized context usage figures.
163 */
164function extractContextUsage(
165  context: UsageContext | null
166): Pick<RawUsage & object, 'ctxTokens' | 'ctxPct' | 'cached' | 'input' | 'output'> {
167  const b = context?.breakdown ?? null;
168  const u = b?.apiUsage ?? null;
169  return {
170    ctxTokens: b?.totalTokens ?? context?.tokens ?? 0,
171    ctxPct: b?.percentage ?? context?.percent ?? 0,
172    cached: u ? u.cache_read_input_tokens + u.cache_creation_input_tokens : 0,
173    input: u?.input_tokens ?? 0,
174    output: u?.output_tokens ?? 0,
175  };
176}
177
178/**
179 * Reduce the rate-limit portion of the raw usage report into snapshot fields.
180 * @param rl - The raw rate-limit entries.
181 * @returns  The normalized five-hour and weekly usage figures.
182 */
183function extractRateLimitUsage(
184  rl: UsageRateLimits
185): Pick<RawUsage & object, 'fivePct' | 'fiveReset' | 'weekPct' | 'weekReset'> {
186  const five = rl.find((r) => r.kind === 'five_hour') ?? null;
187  const week = rl.find((r) => r.kind === 'seven_day') ?? null;
188  return {
189    fivePct: five?.percentUsed ?? null,
190    fiveReset: five?.resetsAt ?? null,
191    weekPct: week?.percentUsed ?? null,
192    weekReset: week?.resetsAt ?? null,
193  };
194}
195
196/**
197 * Reduce the engine's raw usage report into the fields the snapshot needs.
198 * @param usageRaw - The raw usage report, or `null` when it could not be read.
199 * @returns        The normalized usage figures.
200 */
201function extractUsage(usageRaw: UsageRaw | null): RawUsage {
202  const context = usageRaw?.context ?? null;
203  const rl = usageRaw?.rateLimits ?? [];
204  return {
205    ...extractContextUsage(context),
206    ...extractRateLimitUsage(rl),
207    startedAt: usageRaw?.startedAt ?? 0,
208  };
209}
210
211/**
212 * Gather a live snapshot from the session, git, and usage sources.
213 * @param $   - The engine interface.
214 * @param eff - The active reasoning effort, or `null` when unknown.
215 * @returns   The assembled snapshot.
216 */
217async function measure($: EngineInterface, eff: string | null): Promise<Snapshot> {
218  const [version, model, cwd, usageRaw] = await Promise.all([
219    $.session
220      .version()
221      .then((v) => v.version)
222      .catch(() => ''),
223    $.session.model().catch(() => ''),
224    $.session.cwd().catch(() => ''),
225    $.session.usage({ breakdown: 'summary' }).catch(() => null),
226  ]);
227  const git = await probeGit($, cwd);
228  const usage = extractUsage(usageRaw);
229  return buildSnapshot({ version, model, effort: eff, cwd, now: await $.clock.now(), git, usage });
230}
231
232/**
233 * Fire any due toasts and update the per-rule fired state, never throwing.
234 * @param $    - The engine interface.
235 * @param cfg  - The active config.
236 * @param snap - The current snapshot the toast rules are evaluated against.
237 */
238async function fireToasts($: EngineInterface, cfg: Config, snap: Snapshot): Promise<void> {
239  try {
240    if (!cfg.surfaces.toasts.enabled) {
241      return;
242    }
243    const rules = cfg.surfaces.toasts.rules;
244    const fired = (await read($, toastFired)) ?? {};
245    const next: Record<string, boolean> = { ...fired };
246    for (const t of dueToasts(rules, snap, fired)) {
247      try {
248        await $.ui.toast(t.text);
249      } catch {
250        /* guarded */
251      }
252      next[t.key] = true;
253    }
254    for (const r of rules) {
255      if (!evalWhen(r.when, snap)) {
256        delete next[r.when];
257      }
258    }
259    if (JSON.stringify(next) !== JSON.stringify(fired)) {
260      await update($, toastFired, () => next);
261    }
262  } catch {
263    /* never throw from a refresh */
264  }
265}
266
267/**
268 * Fire any due toasts for a snapshot, never throwing. The native status line is
269 * rendered by the `ccstatus statusline` settings command, not the mod.
270 * @param $    - The engine interface.
271 * @param snap - The current snapshot to evaluate toasts against.
272 */
273async function applySurfaces($: EngineInterface, snap: Snapshot): Promise<void> {
274  try {
275    const cfg = (await read($, config)) as Config | null;
276    if (!cfg) {
277      return;
278    }
279    await fireToasts($, cfg, snap);
280  } catch {
281    /* never throw from a refresh */
282  }
283}
284
285let refreshing = false;
286/**
287 * Measure a fresh snapshot, persist it, and repaint all surfaces.
288 * @param $ - The engine interface.
289 */
290async function refresh($: EngineInterface): Promise<void> {
291  if (refreshing) {
292    return;
293  }
294  refreshing = true;
295  try {
296    const eff = await read($, effort).catch(() => null);
297    const s = await measure($, eff); // compute first, then write (updater is sync)
298    await update($, snapshot, () => s);
299    try {
300      const env = {
301        HOME: await $.env.get('HOME').catch(() => undefined),
302        XDG_CONFIG_HOME: await $.env.get('XDG_CONFIG_HOME').catch(() => undefined),
303      };
304      await $.fs.write(snapshotPath(env), JSON.stringify(s)).catch(() => {
305        /* no-op: snapshot persistence is best-effort */
306      });
307    } catch {
308      /* ignore snapshot persistence errors */
309    }
310    await applySurfaces($, s);
311  } catch {
312    /* keep last snapshot */
313  } finally {
314    refreshing = false;
315  }
316}
317
318/**
319 * Toggle the ccstatus pane open or closed.
320 * @param $ - The engine interface.
321 * @returns A status message describing the new pane state.
322 */
323async function togglePane($: EngineInterface): Promise<string> {
324  if (await read($, paneOpen)) {
325    await $.ui.close({ id: 'ccstatus' });
326    await update($, paneOpen, () => false);
327    return 'ccstatus pane closed.';
328  }
329  await $.ui.open({ id: 'ccstatus', title: 'ccstatus' });
330  await update($, paneOpen, () => true);
331  return 'ccstatus pane opened.';
332}
333
334/**
335 * Capture the active reasoning effort from an engine event into plugin state.
336 * @param $ - The engine interface.
337 * @param e - The engine event, inspected for an `effort.active` field.
338 * @returns A promise that resolves once the effort has been recorded.
339 */
340function captureEffort($: EngineInterface, e: unknown): Promise<void> {
341  const active = (e as { effort?: { active?: unknown } })?.effort?.active;
342  if (typeof active === 'string' && active) {
343    return update($, effort, () => active).then(
344      () => undefined,
345      () => undefined
346    );
347  }
348  return Promise.resolve();
349}
350
351export const register: Register = (on) => {
352  on('session.start', async ($, e, next) => {
353    try {
354      await $.command.register({
355        name: 'ccstatus',
356        description: 'Toggle the ccstatus band above the prompt',
357      });
358      await reloadConfig($, true);
359      await refresh($);
360      $.clock.every(2000, () => void reloadConfig($, false).then(() => refresh($)));
361    } catch {
362      /* never block session start */
363    }
364    return next(e);
365  });
366
367  on('turn.complete', async ($, e, next) => {
368    await captureEffort($, e);
369    await refresh($);
370    return next(e);
371  });
372
373  on('tool.call', async ($, e, next) => {
374    await captureEffort($, e);
375    return next(e);
376  });
377
378  on('command.run', { command: 'ccstatus' }, async ($, e) => {
379    try {
380      const args = String((e as { args?: unknown })?.args ?? '').trim();
381      const [sub = '', ...rest] = args.split(/\s+/);
382      if (sub === 'pane') {
383        return { text: await togglePane($) };
384      }
385      if (sub === 'reload') {
386        await reloadConfig($, true);
387        await refresh($);
388        return { text: 'ccstatus config reloaded.' };
389      }
390      if (sub === 'theme') {
391        return { text: await setTheme($, rest[0] ?? '') };
392      }
393      if (sub === 'edit') {
394        return {
395          text:
396            'Edit your layout with the ccstatus editor: run `npx @sushant-kum/ccstatus` in a terminal. ' +
397            'Changes are picked up automatically.',
398        };
399      }
400      const now = !(await read($, visible));
401      await update($, visible, () => now);
402      return { text: now ? 'ccstatus band shown.' : 'ccstatus band hidden.' };
403    } catch {
404      return { text: 'ccstatus: command failed.' };
405    }
406  });
407
408  on('ui.close', async ($, e, next) => {
409    try {
410      if ((e as { id?: string }).id === 'ccstatus') {
411        await update($, paneOpen, () => false);
412      }
413    } catch {
414      /* ignore */
415    }
416    return next(e);
417  });
418
419  on('ui.render', { component: 'Pane', requestId: 'ccstatus' }, async ($, e, next) => {
420    try {
421      const cfg = await read($, config);
422      const snap = await read($, snapshot);
423      if (!cfg || !snap) {
424        return next(e);
425      }
426      const model = render(cfg, snap, { surface: 'pane', width: e.props.bodyColumns || 80 });
427      if (model.lines.length === 0) {
428        return next(e);
429      }
430      // eslint-disable-next-line @typescript-eslint/no-explicit-any -- engine boundary: $.ui.resolve + handler element types
431      return paintModel(model, $.ui.resolve(e) as any) as any;
432    } catch {
433      return next(e);
434    }
435  });
436
437  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
438    try {
439      if (e.props.hasSurvey || !(await read($, visible))) {
440        return next(e);
441      }
442      const cfg = await read($, config);
443      const snap = await read($, snapshot);
444      if (!cfg || !snap) {
445        return next(e);
446      }
447      const width = e.props.bodyColumns;
448      if (!width) {
449        return next(e);
450      } // not measured yet: let the engine draw
451      const model = render(cfg, snap, { surface: 'band', width });
452      if (model.lines.length === 0) {
453        return next(e);
454      }
455      // eslint-disable-next-line @typescript-eslint/no-explicit-any -- engine boundary: $.ui.resolve + handler element types
456      return paintModel(model, $.ui.resolve(e) as any) as any;
457    } catch {
458      return next(e);
459    }
460  });
461};
462
hooks/config-io.ts 55 lines
1import { loadConfig } from './core.js';
2import type { Config } from './core.js';
3
4/**
5 * Resolve the absolute path to the shared config file from the environment.
6 * @param env - The process environment, read for `XDG_CONFIG_HOME` and `HOME`.
7 * @returns   The absolute path to `config.json`.
8 */
9export function configPath(env: Record<string, string | undefined>): string {
10  const xdg = env['XDG_CONFIG_HOME'];
11  const base = xdg && xdg.startsWith('/') ? xdg : `${env['HOME'] ?? ''}/.config`;
12  return `${base}/ccstatus/config.json`;
13}
14
15/**
16 * Resolve the absolute path to the shared snapshot file from the environment.
17 * @param env - The process environment, read for `XDG_CONFIG_HOME` and `HOME`.
18 * @returns   The absolute path to `snapshot.json`.
19 */
20export function snapshotPath(env: Record<string, string | undefined>): string {
21  const xdg = env['XDG_CONFIG_HOME'];
22  const base = xdg && xdg.startsWith('/') ? xdg : `${env['HOME'] ?? ''}/.config`;
23  return `${base}/ccstatus/snapshot.json`;
24}
25
26/**
27 * Parse config file text into a validated config, never throwing.
28 * @param text - The raw config file contents, or `null` when the file is absent.
29 * @returns    The loaded config and any warnings raised while validating it.
30 */
31export function parseConfig(text: string | null): { config: Config; warnings: string[] } {
32  if (text === null) {
33    return loadConfig(undefined);
34  }
35  let parsed: unknown;
36  try {
37    parsed = JSON.parse(text);
38  } catch {
39    const fallback = loadConfig(undefined);
40    return { config: fallback.config, warnings: ['config file is not valid JSON; using defaults'] };
41  }
42  return loadConfig(parsed);
43}
44
45/**
46 * Decide whether the config should be reloaded given its last-seen mtime.
47 * @param prevMtime    - The previously observed modification time, or `null` when never read.
48 * @param stat         - The current file stat.
49 * @param stat.mtimeMs - The current modification time in milliseconds.
50 * @returns            `true` when the config has not been read before or has changed since.
51 */
52export function shouldReload(prevMtime: number | null, stat: { mtimeMs: number }): boolean {
53  return prevMtime === null || stat.mtimeMs !== prevMtime;
54}
55
hooks/core.js 4928 lines
1var __defProp = Object.defineProperty;
2var __export = (target, all) => {
3  for (var name in all)
4    __defProp(target, name, { get: all[name], enumerable: true });
5};
6
7// packages/core/src/theme/themes.ts
8function resolveColors(item, config) {
9  var _a;
10  const themeEntry = (_a = config.themes[config.theme]) == null ? void 0 : _a[item.type];
11  const fg2 = item.type === "flex-separator" ? void 0 : item.fg;
12  const bg2 = item.type === "flex-separator" ? void 0 : item.bg;
13  return {
14    fg: fg2 ?? (themeEntry == null ? void 0 : themeEntry.fg),
15    bg: bg2 ?? (themeEntry == null ? void 0 : themeEntry.bg)
16  };
17}
18var WIDGETS = [
19  "model",
20  "version",
21  "context-percentage",
22  "git-branch",
23  "git-changes",
24  "tokens-input",
25  "tokens-output",
26  "tokens-total"
27];
28var mk = (rows) => {
29  const t = {};
30  WIDGETS.forEach((w, i) => {
31    const row = rows[i];
32    if (!row) {
33      throw new Error(`theme definition is missing a color row for widget "${w}"`);
34    }
35    const [fg2, bg2] = row;
36    t[w] = { fg: fg2, bg: bg2 };
37  });
38  return t;
39};
40var builtinThemes = {
41  default: {
42    model: { fg: "black", bg: "bgCyan" },
43    version: { fg: "white", bg: "bgBlue" },
44    "context-percentage": { fg: "black", bg: "bgBrightYellow" },
45    "git-branch": { fg: "black", bg: "bgCyan" },
46    "git-changes": { fg: "white", bg: "bgBrightBlack" }
47  },
48  classic: mk([
49    ["black", "bgYellow"],
50    ["black", "bgWhite"],
51    ["black", "bgGreen"],
52    ["white", "bgBlue"],
53    ["black", "bgMagenta"],
54    ["black", "bgCyan"],
55    ["black", "bgBrightYellow"],
56    ["white", "bgRed"]
57  ]),
58  nord: mk([
59    ["black", "bgBrightCyan"],
60    ["black", "bgCyan"],
61    ["black", "bgBlue"],
62    ["white", "bgBrightBlack"],
63    ["black", "bgBrightBlue"],
64    ["black", "bgGreen"],
65    ["black", "bgYellow"],
66    ["white", "bgBlack"]
67  ]),
68  dracula: mk([
69    ["black", "bgMagenta"],
70    ["black", "bgBrightMagenta"],
71    ["black", "bgBrightGreen"],
72    ["black", "bgBrightCyan"],
73    ["black", "bgBrightYellow"],
74    ["black", "bgBrightRed"],
75    ["white", "bgBrightBlack"],
76    ["white", "bgMagenta"]
77  ]),
78  gruvbox: mk([
79    ["black", "bgYellow"],
80    ["black", "bgBrightYellow"],
81    ["black", "bgGreen"],
82    ["black", "bgCyan"],
83    ["black", "bgRed"],
84    ["black", "bgMagenta"],
85    ["white", "bgBrightBlack"],
86    ["white", "bgBlack"]
87  ]),
88  monokai: mk([
89    ["black", "bgBrightGreen"],
90    ["black", "bgBrightYellow"],
91    ["black", "bgBrightMagenta"],
92    ["black", "bgBrightCyan"],
93    ["black", "bgBrightRed"],
94    ["white", "bgBrightBlack"],
95    ["black", "bgYellow"],
96    ["white", "bgBlack"]
97  ]),
98  "one-dark": mk([
99    ["black", "bgBlue"],
100    ["black", "bgBrightBlue"],
101    ["black", "bgGreen"],
102    ["black", "bgMagenta"],
103    ["black", "bgYellow"],
104    ["black", "bgCyan"],
105    ["white", "bgBrightBlack"],
106    ["white", "bgBlack"]
107  ]),
108  solarized: mk([
109    ["white", "bgBlue"],
110    ["black", "bgCyan"],
111    ["black", "bgYellow"],
112    ["white", "bgGreen"],
113    ["white", "bgMagenta"],
114    ["white", "bgRed"],
115    ["white", "bgBrightBlack"],
116    ["white", "bgBlack"]
117  ]),
118  "tokyo-night": mk([
119    ["black", "bgBrightBlue"],
120    ["black", "bgBlue"],
121    ["black", "bgBrightGreen"],
122    ["black", "bgBrightMagenta"],
123    ["black", "bgBrightYellow"],
124    ["black", "bgBrightCyan"],
125    ["white", "bgBrightBlack"],
126    ["white", "bgBlack"]
127  ])
128};
129
130// packages/core/src/widgets/registry.ts
131var registry = {};
132function register(def) {
133  registry[def.type] = def;
134}
135
136// packages/core/src/layout/cells.ts
137function itemToCell(item, config, snapshot, commandOutputs) {
138  const def = registry[item.type];
139  if (!def) {
140    return null;
141  }
142  const value = def.format({ snapshot, item, commandOutputs });
143  if (value === null) {
144    return null;
145  }
146  const flex = item.type === "flex-separator";
147  const pad = flex ? "" : " ".repeat(Math.max(0, Math.floor(config.defaults.padding)));
148  const { fg: fg2, bg: bg2 } = resolveColors(item, config);
149  const merge = item.type === "flex-separator" ? false : item.merge ?? false;
150  return { text: flex ? "" : `${pad}${value}${pad}`, fg: fg2, bg: bg2, merge, flex };
151}
152function lineToCells(items, config, snapshot, commandOutputs) {
153  return items.map((it) => itemToCell(it, config, snapshot, commandOutputs)).filter((c) => c !== null);
154}
155
156// packages/core/src/layout/compose.ts
157function bgToFg(bg2) {
158  if (!bg2) {
159    return void 0;
160  }
161  if (!bg2.startsWith("bg")) {
162    return bg2;
163  }
164  const rest = bg2.slice(2);
165  return rest.charAt(0).toLowerCase() + rest.slice(1);
166}
167function mergeCells(cells) {
168  const out = [];
169  for (const c of cells) {
170    const prev = out[out.length - 1];
171    if (c.merge && prev && !prev.flex && !c.flex) {
172      prev.text = `${prev.text}${c.text}`;
173    } else {
174      out.push({ ...c });
175    }
176  }
177  return out;
178}
179function truncate(segs, width) {
180  const total = segs.reduce((n, s) => n + s.text.length, 0);
181  if (total <= width) {
182    return segs;
183  }
184  let budget = width;
185  const out = [];
186  for (const s of segs) {
187    if (s.kind === "cap") {
188      continue;
189    }
190    if (budget <= 0) {
191      break;
192    }
193    if (s.text.length <= budget) {
194      out.push(s);
195      budget -= s.text.length;
196    } else {
197      out.push({ ...s, text: s.text.slice(0, budget) });
198      budget = 0;
199    }
200  }
201  return out;
202}
203function composeLine(cells, mode, width, glyph = "\u2592") {
204  var _a, _b;
205  const merged = mergeCells(cells);
206  const content = merged.filter((c) => !c.flex);
207  const flexCount = merged.filter((c) => c.flex).length;
208  const contentWidth = content.reduce((n, c) => n + c.text.length, 0);
209  let sepWidth = 0;
210  if (mode === "powerline") {
211    sepWidth = Math.max(0, content.length - 1) + (content.length ? 2 : 0);
212  } else if (mode === "space") {
213    sepWidth = Math.max(0, content.length - 1);
214  }
215  const leftover = Math.max(0, width - contentWidth - sepWidth);
216  const per = flexCount ? Math.floor(leftover / flexCount) : 0;
217  let extra = flexCount ? leftover - per * flexCount : 0;
218  const flexWidth = () => {
219    const w = per + (extra > 0 ? 1 : 0);
220    if (extra > 0) {
221      extra -= 1;
222    }
223    return w;
224  };
225  const segs = [];
226  const emitStartCap = mode === "powerline" && content.length > 0;
227  if (emitStartCap) {
228    segs.push({ kind: "cap", text: glyph, fg: bgToFg((_a = content[0]) == null ? void 0 : _a.bg) });
229  }
230  let ci = 0;
231  for (const c of merged) {
232    if (c.flex) {
233      segs.push({ kind: "flex", text: " ".repeat(flexWidth()) });
234      continue;
235    }
236    segs.push({ kind: "cell", text: c.text, fg: c.fg, bg: c.bg });
237    const isLast = ci === content.length - 1;
238    const next = content[ci + 1];
239    if (!isLast && next) {
240      if (mode === "powerline") {
241        segs.push({ kind: "separator", text: glyph, fg: bgToFg(c.bg), bg: next.bg });
242      } else if (mode === "space") {
243        segs.push({ kind: "separator", text: " " });
244      }
245    }
246    ci += 1;
247  }
248  if (emitStartCap) {
249    segs.push({ kind: "cap", text: glyph, fg: bgToFg((_b = content[content.length - 1]) == null ? void 0 : _b.bg) });
250  }
251  return truncate(segs, width);
252}
253
254// packages/core/src/format.ts
255function fmtNum(n) {
256  const trim = (v, suffix) => `${v.toFixed(1).replace(/\.0$/, "")}${suffix}`;
257  if (Math.abs(n) >= 1e6) {
258    return trim(n / 1e6, "M");
259  }
260  if (Math.abs(n) >= 1e3) {
261    return trim(n / 1e3, "k");
262  }
263  return String(Math.round(n));
264}
265function fmtDur(ms) {
266  let s = Math.max(0, Math.floor(ms / 1e3));
267  const h = Math.floor(s / 3600);
268  s -= h * 3600;
269  const m = Math.floor(s / 60);
270  s -= m * 60;
271  if (h > 0) {
272    return `${h}h ${m}m`;
273  }
274  if (m > 0) {
275    return `${m}m ${s}s`;
276  }
277  return `${s}s`;
278}
279function pct(x) {
280  return `${Math.round(x)}%`;
281}
282function bar(x, width = 5) {
283  const filled = Math.floor(Math.min(100, Math.max(0, x)) / 100 * width);
284  return "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
285}
286
287// packages/core/src/widgets/data-widgets.ts
288var labeled = (raw, label, value) => raw ? value : `${label}: ${value}`;
289var rawOf = (item) => "rawValue" in item ? item.rawValue : void 0;
290register({ type: "model", label: "Model", format: ({ snapshot }) => snapshot.model || null });
291register({
292  type: "version",
293  label: "Version",
294  format: ({ snapshot, item }) => snapshot.version ? rawOf(item) ? snapshot.version : `v${snapshot.version}` : null
295});
296register({
297  type: "context-length",
298  label: "Ctx",
299  format: ({ snapshot, item }) => labeled(rawOf(item), "Ctx", fmtNum(snapshot.ctxTokens))
300});
301register({
302  type: "context-percentage",
303  label: "Ctx Used",
304  format: ({ snapshot, item }) => labeled(rawOf(item), "Ctx Used", pct(snapshot.ctxPct))
305});
306register({
307  type: "tokens-input",
308  label: "In",
309  format: ({ snapshot, item }) => labeled(rawOf(item), "In", fmtNum(snapshot.input))
310});
311register({
312  type: "tokens-output",
313  label: "Out",
314  format: ({ snapshot, item }) => labeled(rawOf(item), "Out", fmtNum(snapshot.output))
315});
316register({
317  type: "tokens-cached",
318  label: "Cached",
319  format: ({ snapshot, item }) => labeled(rawOf(item), "Cached", fmtNum(snapshot.cached))
320});
321register({
322  type: "tokens-total",
323  label: "Total",
324  format: ({ snapshot, item }) => labeled(rawOf(item), "Total", fmtNum(snapshot.total))
325});
326register({
327  type: "session-clock",
328  label: "Session",
329  format: ({ snapshot, item }) => labeled(rawOf(item), "Session", fmtDur(snapshot.now - snapshot.startedAt))
330});
331register({
332  type: "cwd",
333  label: "cwd",
334  format: ({ snapshot }) => {
335    if (!snapshot.cwd) {
336      return null;
337    }
338    const parts = snapshot.cwd.split("/").filter(Boolean);
339    return parts[parts.length - 1] ?? snapshot.cwd;
340  }
341});
342
343// packages/core/src/widgets/git-widgets.ts
344register({
345  type: "git-branch",
346  label: "Branch",
347  format: ({ snapshot }) => snapshot.repo ? `\u270E ${snapshot.gitBranch || "?"}` : null
348});
349register({
350  type: "git-root-dir",
351  label: "Git root",
352  format: ({ snapshot }) => snapshot.repo ? `\u2302 ${snapshot.gitRoot || "?"}` : null
353});
354register({
355  type: "git-changes",
356  label: "Changes",
357  format: ({ snapshot }) => snapshot.repo ? `(+${snapshot.added} ~${snapshot.modified} -${snapshot.deleted})` : null
358});
359register({
360  type: "git-worktree",
361  label: "Worktree",
362  format: ({ snapshot }) => snapshot.repo && snapshot.gitWorktree ? `\u2387 ${snapshot.gitWorktree}` : null
363});
364
365// packages/core/src/widgets/usage-widgets.ts
366function until(iso, now) {
367  if (!iso) {
368    return null;
369  }
370  const t = Date.parse(iso);
371  if (Number.isNaN(t)) {
372    return null;
373  }
374  const d = t - now;
375  return d > 0 ? d : null;
376}
377register({
378  type: "cost",
379  label: "Cost",
380  format: ({ snapshot }) => snapshot.cost === null ? null : `$${snapshot.cost.toFixed(2)}`
381});
382register({
383  type: "rate-limit-5h",
384  label: "Session limit",
385  format: ({ snapshot }) => {
386    if (snapshot.fivePct === null) {
387      return "Session: \u2014";
388    }
389    const reset = until(snapshot.fiveReset, snapshot.now);
390    return `Session: ${bar(snapshot.fivePct)} ${pct(snapshot.fivePct)}${reset !== null ? `  ${fmtDur(reset)}` : ""}`;
391  }
392});
393register({
394  type: "rate-limit-week",
395  label: "Weekly limit",
396  format: ({ snapshot }) => {
397    if (snapshot.weekPct === null) {
398      return "Weekly: \u2014";
399    }
400    const reset = until(snapshot.weekReset, snapshot.now);
401    return `Weekly: ${pct(snapshot.weekPct)}${reset !== null ? `  ${fmtDur(reset)}` : ""}`;
402  }
403});
404register({
405  type: "block-timer",
406  label: "Block",
407  format: ({ snapshot }) => {
408    const reset = until(snapshot.blockReset, snapshot.now);
409    return reset === null ? null : `Block: ${fmtDur(reset)}`;
410  }
411});
412
413// packages/core/src/widgets/custom-widgets.ts
414register({
415  type: "custom-text",
416  label: "Custom text",
417  format: ({ item }) => {
418    const text = item.type === "custom-text" ? item.text : "";
419    return text.length ? text : null;
420  }
421});
422register({
423  type: "custom-command",
424  label: "Custom command",
425  format: ({ item, commandOutputs }) => {
426    const out = (commandOutputs[item.id] ?? "").trim();
427    return out.length ? out : null;
428  }
429});
430register({ type: "flex-separator", label: "Flex separator", format: () => "" });
431
432// packages/core/src/render.ts
433function render(config, snapshot, opts) {
434  const surface = config.surfaces[opts.surface];
435  if (!surface.enabled) {
436    return { lines: [] };
437  }
438  const outputs = opts.commandOutputs ?? {};
439  const lines = surface.lines.map((items) => lineToCells(items, config, snapshot, outputs)).filter((cells) => cells.length > 0).map((cells) => ({
440    segments: composeLine(cells, config.defaults.separator, opts.width, config.defaults.glyph)
441  }));
442  return { lines };
443}
444
445// packages/core/src/ansi.ts
446var FG = {
447  black: 30,
448  red: 31,
449  green: 32,
450  yellow: 33,
451  blue: 34,
452  magenta: 35,
453  cyan: 36,
454  white: 37,
455  gray: 90,
456  brightBlack: 90,
457  brightRed: 91,
458  brightGreen: 92,
459  brightYellow: 93,
460  brightBlue: 94,
461  brightMagenta: 95,
462  brightCyan: 96,
463  brightWhite: 97,
464  brightGray: 97
465};
466var BG = {
467  bgBlack: 40,
468  bgRed: 41,
469  bgGreen: 42,
470  bgYellow: 43,
471  bgBlue: 44,
472  bgMagenta: 45,
473  bgCyan: 46,
474  bgWhite: 47,
475  bgGray: 100,
476  bgBrightBlack: 100,
477  bgBrightRed: 101,
478  bgBrightGreen: 102,
479  bgBrightYellow: 103,
480  bgBrightBlue: 104,
481  bgBrightMagenta: 105,
482  bgBrightCyan: 106,
483  bgBrightWhite: 107,
484  bgBrightGray: 107
485};
486function sgr(seg) {
487  const codes = [];
488  if (seg.fg && FG[seg.fg] !== void 0) {
489    codes.push(`\x1B[${FG[seg.fg]}m`);
490  }
491  if (seg.bg && BG[seg.bg] !== void 0) {
492    codes.push(`\x1B[${BG[seg.bg]}m`);
493  }
494  if (!codes.length) {
495    return seg.text;
496  }
497  return `${codes.join("")}${seg.text}\x1B[0m`;
498}
499function toAnsi(model) {
500  return model.lines.map((line) => line.segments.map(sgr).join("")).join("\n");
501}
502
503// packages/core/src/colors.ts
504var BASE = [
505  "black",
506  "white",
507  "red",
508  "green",
509  "yellow",
510  "blue",
511  "magenta",
512  "cyan",
513  "gray"
514];
515var cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
516var fg = BASE.flatMap((c) => [c, `bright${cap(c)}`]);
517var bg = BASE.flatMap((c) => [`bg${cap(c)}`, `bgBright${cap(c)}`]);
518var COLORS = /* @__PURE__ */ new Set([...fg, ...bg]);
519function isColor(x) {
520  return typeof x === "string" && COLORS.has(x);
521}
522
523// packages/core/src/toast.ts
524var TOAST_FIELDS = {
525  ctxPct: (s) => s.ctxPct,
526  fivePct: (s) => s.fivePct,
527  weekPct: (s) => s.weekPct,
528  ctxTokens: (s) => s.ctxTokens,
529  total: (s) => s.total,
530  cost: (s) => s.cost
531};
532var WHEN_RE = /^\s*([a-zA-Z]+)\s*(>=|<=|==|>|<)\s*(-?\d+(?:\.\d+)?)\s*$/;
533function isValidWhen(when) {
534  const m = WHEN_RE.exec(when);
535  if (m === null) {
536    return false;
537  }
538  const field = m[1];
539  return field !== void 0 && field in TOAST_FIELDS;
540}
541function evalWhen(when, snapshot) {
542  const m = WHEN_RE.exec(when);
543  if (!m) {
544    return false;
545  }
546  const field = m[1];
547  const getter = field === void 0 ? void 0 : TOAST_FIELDS[field];
548  if (!getter) {
549    return false;
550  }
551  const value = getter(snapshot);
552  if (value === null || value === void 0 || Number.isNaN(value)) {
553    return false;
554  }
555  const n = Number(m[3]);
556  switch (m[2]) {
557    case ">":
558      return value > n;
559    case ">=":
560      return value >= n;
561    case "<":
562      return value < n;
563    case "<=":
564      return value <= n;
565    case "==":
566      return value === n;
567    default:
568      return false;
569  }
570}
571
572// packages/core/src/config/defaults.ts
573var defaultConfig = {
574  version: 1,
575  theme: "default",
576  themes: structuredClone(builtinThemes),
577  defaults: { separator: "powerline", padding: 1, align: "left", glyph: "\u2592", invert: false },
578  surfaces: {
579    band: {
580      enabled: true,
581      lines: [
582        [
583          { id: "d-version", type: "version" },
584          { id: "d-model", type: "model" },
585          { id: "d-ctx", type: "context-length" },
586          { id: "d-ctxp", type: "context-percentage" }
587        ]
588      ]
589    },
590    statusline: { enabled: false, lines: [[]] },
591    pane: { enabled: false, lines: [[]] },
592    toasts: {
593      enabled: true,
594      rules: [{ when: "ctxPct>80", text: "Context over 80%", once: true }]
595    }
596  }
597};
598
599// node_modules/zod/v3/external.js
600var external_exports = {};
601__export(external_exports, {
602  BRAND: () => BRAND,
603  DIRTY: () => DIRTY,
604  EMPTY_PATH: () => EMPTY_PATH,
605  INVALID: () => INVALID,
606  NEVER: () => NEVER,
607  OK: () => OK,
608  ParseStatus: () => ParseStatus,
609  Schema: () => ZodType,
610  ZodAny: () => ZodAny,
611  ZodArray: () => ZodArray,
612  ZodBigInt: () => ZodBigInt,
613  ZodBoolean: () => ZodBoolean,
614  ZodBranded: () => ZodBranded,
615  ZodCatch: () => ZodCatch,
616  ZodDate: () => ZodDate,
617  ZodDefault: () => ZodDefault,
618  ZodDiscriminatedUnion: () => ZodDiscriminatedUnion,
619  ZodEffects: () => ZodEffects,
620  ZodEnum: () => ZodEnum,
621  ZodError: () => ZodError,
622  ZodFirstPartyTypeKind: () => ZodFirstPartyTypeKind,
623  ZodFunction: () => ZodFunction,
624  ZodIntersection: () => ZodIntersection,
625  ZodIssueCode: () => ZodIssueCode,
626  ZodLazy: () => ZodLazy,
627  ZodLiteral: () => ZodLiteral,
628  ZodMap: () => ZodMap,
629  ZodNaN: () => ZodNaN,
630  ZodNativeEnum: () => ZodNativeEnum,
631  ZodNever: () => ZodNever,
632  ZodNull: () => ZodNull,
633  ZodNullable: () => ZodNullable,
634  ZodNumber: () => ZodNumber,
635  ZodObject: () => ZodObject,
636  ZodOptional: () => ZodOptional,
637  ZodParsedType: () => ZodParsedType,
638  ZodPipeline: () => ZodPipeline,
639  ZodPromise: () => ZodPromise,
640  ZodReadonly: () => ZodReadonly,
641  ZodRecord: () => ZodRecord,
642  ZodSchema: () => ZodType,
643  ZodSet: () => ZodSet,
644  ZodString: () => ZodString,
645  ZodSymbol: () => ZodSymbol,
646  ZodTransformer: () => ZodEffects,
647  ZodTuple: () => ZodTuple,
648  ZodType: () => ZodType,
649  ZodUndefined: () => ZodUndefined,
650  ZodUnion: () => ZodUnion,
651  ZodUnknown: () => ZodUnknown,
652  ZodVoid: () => ZodVoid,
653  addIssueToContext: () => addIssueToContext,
654  any: () => anyType,
655  array: () => arrayType,
656  bigint: () => bigIntType,
657  boolean: () => booleanType,
658  coerce: () => coerce,
659  custom: () => custom,
660  date: () => dateType,
661  datetimeRegex: () => datetimeRegex,
662  defaultErrorMap: () => en_default,
663  discriminatedUnion: () => discriminatedUnionType,
664  effect: () => effectsType,
665  enum: () => enumType,
666  function: () => functionType,
667  getErrorMap: () => getErrorMap,
668  getParsedType: () => getParsedType,
669  instanceof: () => instanceOfType,
670  intersection: () => intersectionType,
671  isAborted: () => isAborted,
672  isAsync: () => isAsync,
673  isDirty: () => isDirty,
674  isValid: () => isValid,
675  late: () => late,
676  lazy: () => lazyType,
677  literal: () => literalType,
678  makeIssue: () => makeIssue,
679  map: () => mapType,
680  nan: () => nanType,
681  nativeEnum: () => nativeEnumType,
682  never: () => neverType,
683  null: () => nullType,
684  nullable: () => nullableType,
685  number: () => numberType,
686  object: () => objectType,
687  objectUtil: () => objectUtil,
688  oboolean: () => oboolean,
689  onumber: () => onumber,
690  optional: () => optionalType,
691  ostring: () => ostring,
692  pipeline: () => pipelineType,
693  preprocess: () => preprocessType,
694  promise: () => promiseType,
695  quotelessJson: () => quotelessJson,
696  record: () => recordType,
697  set: () => setType,
698  setErrorMap: () => setErrorMap,
699  strictObject: () => strictObjectType,
700  string: () => stringType,
701  symbol: () => symbolType,
702  transformer: () => effectsType,
703  tuple: () => tupleType,
704  undefined: () => undefinedType,
705  union: () => unionType,
706  unknown: () => unknownType,
707  util: () => util,
708  void: () => voidType
709});
710
711// node_modules/zod/v3/helpers/util.js
712var util;
713(function(util2) {
714  util2.assertEqual = (_) => {
715  };
716  function assertIs(_arg) {
717  }
718  util2.assertIs = assertIs;
719  function assertNever(_x) {
720    throw new Error();
721  }
722  util2.assertNever = assertNever;
723  util2.arrayToEnum = (items) => {
724    const obj = {};
725    for (const item of items) {
726      obj[item] = item;
727    }
728    return obj;
729  };
730  util2.getValidEnumValues = (obj) => {
731    const validKeys = util2.objectKeys(obj).filter((k) => typeof obj[obj[k]] !== "number");
732    const filtered = {};
733    for (const k of validKeys) {
734      filtered[k] = obj[k];
735    }
736    return util2.objectValues(filtered);
737  };
738  util2.objectValues = (obj) => {
739    return util2.objectKeys(obj).map(function(e) {
740      return obj[e];
741    });
742  };
743  util2.objectKeys = typeof Object.keys === "function" ? (obj) => Object.keys(obj) : (object) => {
744    const keys = [];
745    for (const key in object) {
746      if (Object.prototype.hasOwnProperty.call(object, key)) {
747        keys.push(key);
748      }
749    }
750    return keys;
751  };
752  util2.find = (arr, checker) => {
753    for (const item of arr) {
754      if (checker(item))
755        return item;
756    }
757    return void 0;
758  };
759  util2.isInteger = typeof Number.isInteger === "function" ? (val) => Number.isInteger(val) : (val) => typeof val === "number" && Number.isFinite(val) && Math.floor(val) === val;
760  function joinValues(array, separator = " | ") {
761    return array.map((val) => typeof val === "string" ? `'${val}'` : val).join(separator);
762  }
763  util2.joinValues = joinValues;
764  util2.jsonStringifyReplacer = (_, value) => {
765    if (typeof value === "bigint") {
766      return value.toString();
767    }
768    return value;
769  };
770})(util || (util = {}));
771var objectUtil;
772(function(objectUtil2) {
773  objectUtil2.mergeShapes = (first, second) => {
774    return {
775      ...first,
776      ...second
777      // second overwrites first
778    };
779  };
780})(objectUtil || (objectUtil = {}));
781var ZodParsedType = util.arrayToEnum([
782  "string",
783  "nan",
784  "number",
785  "integer",
786  "float",
787  "boolean",
788  "date",
789  "bigint",
790  "symbol",
791  "function",
792  "undefined",
793  "null",
794  "array",
795  "object",
796  "unknown",
797  "promise",
798  "void",
799  "never",
800  "map",
801  "set"
802]);
803var getParsedType = (data) => {
804  const t = typeof data;
805  switch (t) {
806    case "undefined":
807      return ZodParsedType.undefined;
808    case "string":
809      return ZodParsedType.string;
810    case "number":
811      return Number.isNaN(data) ? ZodParsedType.nan : ZodParsedType.number;
812    case "boolean":
813      return ZodParsedType.boolean;
814    case "function":
815      return ZodParsedType.function;
816    case "bigint":
817      return ZodParsedType.bigint;
818    case "symbol":
819      return ZodParsedType.symbol;
820    case "object":
821      if (Array.isArray(data)) {
822        return ZodParsedType.array;
823      }
824      if (data === null) {
825        return ZodParsedType.null;
826      }
827      if (data.then && typeof data.then === "function" && data.catch && typeof data.catch === "function") {
828        return ZodParsedType.promise;
829      }
830      if (typeof Map !== "undefined" && data instanceof Map) {
831        return ZodParsedType.map;
832      }
833      if (typeof Set !== "undefined" && data instanceof Set) {
834        return ZodParsedType.set;
835      }
836      if (typeof Date !== "undefined" && data instanceof Date) {
837        return ZodParsedType.date;
838      }
839      return ZodParsedType.object;
840    default:
841      return ZodParsedType.unknown;
842  }
843};
844
845// node_modules/zod/v3/ZodError.js
846var ZodIssueCode = util.arrayToEnum([
847  "invalid_type",
848  "invalid_literal",
849  "custom",
850  "invalid_union",
851  "invalid_union_discriminator",
852  "invalid_enum_value",
853  "unrecognized_keys",
854  "invalid_arguments",
855  "invalid_return_type",
856  "invalid_date",
857  "invalid_string",
858  "too_small",
859  "too_big",
860  "invalid_intersection_types",
861  "not_multiple_of",
862  "not_finite"
863]);
864var quotelessJson = (obj) => {
865  const json = JSON.stringify(obj, null, 2);
866  return json.replace(/"([^"]+)":/g, "$1:");
867};
868var ZodError = class _ZodError extends Error {
869  get errors() {
870    return this.issues;
871  }
872  constructor(issues) {
873    super();
874    this.issues = [];
875    this.addIssue = (sub) => {
876      this.issues = [...this.issues, sub];
877    };
878    this.addIssues = (subs = []) => {
879      this.issues = [...this.issues, ...subs];
880    };
881    const actualProto = new.target.prototype;
882    if (Object.setPrototypeOf) {
883      Object.setPrototypeOf(this, actualProto);
884    } else {
885      this.__proto__ = actualProto;
886    }
887    this.name = "ZodError";
888    this.issues = issues;
889  }
890  format(_mapper) {
891    const mapper = _mapper || function(issue) {
892      return issue.message;
893    };
894    const fieldErrors = { _errors: [] };
895    const processError = (error) => {
896      for (const issue of error.issues) {
897        if (issue.code === "invalid_union") {
898          issue.unionErrors.map(processError);
899        } else if (issue.code === "invalid_return_type") {
900          processError(issue.returnTypeError);
901        } else if (issue.code === "invalid_arguments") {
902          processError(issue.argumentsError);
903        } else if (issue.path.length === 0) {
904          fieldErrors._errors.push(mapper(issue));
905        } else {
906          let curr = fieldErrors;
907          let i = 0;
908          while (i < issue.path.length) {
909            const el = issue.path[i];
910            const terminal = i === issue.path.length - 1;
911            if (!terminal) {
912              curr[el] = curr[el] || { _errors: [] };
913            } else {
914              curr[el] = curr[el] || { _errors: [] };
915              curr[el]._errors.push(mapper(issue));
916            }
917            curr = curr[el];
918            i++;
919          }
920        }
921      }
922    };
923    processError(this);
924    return fieldErrors;
925  }
926  static assert(value) {
927    if (!(value instanceof _ZodError)) {
928      throw new Error(`Not a ZodError: ${value}`);
929    }
930  }
931  toString() {
932    return this.message;
933  }
934  get message() {
935    return JSON.stringify(this.issues, util.jsonStringifyReplacer, 2);
936  }
937  get isEmpty() {
938    return this.issues.length === 0;
939  }
940  flatten(mapper = (issue) => issue.message) {
941    const fieldErrors = {};
942    const formErrors = [];
943    for (const sub of this.issues) {
944      if (sub.path.length > 0) {
945        const firstEl = sub.path[0];
946        fieldErrors[firstEl] = fieldErrors[firstEl] || [];
947        fieldErrors[firstEl].push(mapper(sub));
948      } else {
949        formErrors.push(mapper(sub));
950      }
951    }
952    return { formErrors, fieldErrors };
953  }
954  get formErrors() {
955    return this.flatten();
956  }
957};
958ZodError.create = (issues) => {
959  const error = new ZodError(issues);
960  return error;
961};
962
963// node_modules/zod/v3/locales/en.js
964var errorMap = (issue, _ctx) => {
965  let message;
966  switch (issue.code) {
967    case ZodIssueCode.invalid_type:
968      if (issue.received === ZodParsedType.undefined) {
969        message = "Required";
970      } else {
971        message = `Expected ${issue.expected}, received ${issue.received}`;
972      }
973      break;
974    case ZodIssueCode.invalid_literal:
975      message = `Invalid literal value, expected ${JSON.stringify(issue.expected, util.jsonStringifyReplacer)}`;
976      break;
977    case ZodIssueCode.unrecognized_keys:
978      message = `Unrecognized key(s) in object: ${util.joinValues(issue.keys, ", ")}`;
979      break;
980    case ZodIssueCode.invalid_union:
981      message = `Invalid input`;
982      break;
983    case ZodIssueCode.invalid_union_discriminator:
984      message = `Invalid discriminator value. Expected ${util.joinValues(issue.options)}`;
985      break;
986    case ZodIssueCode.invalid_enum_value:
987      message = `Invalid enum value. Expected ${util.joinValues(issue.options)}, received '${issue.received}'`;
988      break;
989    case ZodIssueCode.invalid_arguments:
990      message = `Invalid function arguments`;
991      break;
992    case ZodIssueCode.invalid_return_type:
993      message = `Invalid function return type`;
994      break;
995    case ZodIssueCode.invalid_date:
996      message = `Invalid date`;
997      break;
998    case ZodIssueCode.invalid_string:
999      if (typeof issue.validation === "object") {
1000        if ("includes" in issue.validation) {
1001          message = `Invalid input: must include "${issue.validation.includes}"`;
1002          if (typeof issue.validation.position === "number") {
1003            message = `${message} at one or more positions greater than or equal to ${issue.validation.position}`;
1004          }
1005        } else if ("startsWith" in issue.validation) {
1006          message = `Invalid input: must start with "${issue.validation.startsWith}"`;
1007        } else if ("endsWith" in issue.validation) {
1008          message = `Invalid input: must end with "${issue.validation.endsWith}"`;
1009        } else {
1010          util.assertNever(issue.validation);
1011        }
1012      } else if (issue.validation !== "regex") {
1013        message = `Invalid ${issue.validation}`;
1014      } else {
1015        message = "Invalid";
1016      }
1017      break;
1018    case ZodIssueCode.too_small:
1019      if (issue.type === "array")
1020        message = `Array must contain ${issue.exact ? "exactly" : issue.inclusive ? `at least` : `more than`} ${issue.minimum} element(s)`;
1021      else if (issue.type === "string")
1022        message = `String must contain ${issue.exact ? "exactly" : issue.inclusive ? `at least` : `over`} ${issue.minimum} character(s)`;
1023      else if (issue.type === "number")
1024        message = `Number must be ${issue.exact ? `exactly equal to ` : issue.inclusive ? `greater than or equal to ` : `greater than `}${issue.minimum}`;
1025      else if (issue.type === "bigint")
1026        message = `Number must be ${issue.exact ? `exactly equal to ` : issue.inclusive ? `greater than or equal to ` : `greater than `}${issue.minimum}`;
1027      else if (issue.type === "date")
1028        message = `Date must be ${issue.exact ? `exactly equal to ` : issue.inclusive ? `greater than or equal to ` : `greater than `}${new Date(Number(issue.minimum))}`;
1029      else
1030        message = "Invalid input";
1031      break;
1032    case ZodIssueCode.too_big:
1033      if (issue.type === "array")
1034        message = `Array must contain ${issue.exact ? `exactly` : issue.inclusive ? `at most` : `less than`} ${issue.maximum} element(s)`;
1035      else if (issue.type === "string")
1036        message = `String must contain ${issue.exact ? `exactly` : issue.inclusive ? `at most` : `under`} ${issue.maximum} character(s)`;
1037      else if (issue.type === "number")
1038        message = `Number must be ${issue.exact ? `exactly` : issue.inclusive ? `less than or equal to` : `less than`} ${issue.maximum}`;
1039      else if (issue.type === "bigint")
1040        message = `BigInt must be ${issue.exact ? `exactly` : issue.inclusive ? `less than or equal to` : `less than`} ${issue.maximum}`;
1041      else if (issue.type === "date")
1042        message = `Date must be ${issue.exact ? `exactly` : issue.inclusive ? `smaller than or equal to` : `smaller than`} ${new Date(Number(issue.maximum))}`;
1043      else
1044        message = "Invalid input";
1045      break;
1046    case ZodIssueCode.custom:
1047      message = `Invalid input`;
1048      break;
1049    case ZodIssueCode.invalid_intersection_types:
1050      message = `Intersection results could not be merged`;
1051      break;
1052    case ZodIssueCode.not_multiple_of:
1053      message = `Number must be a multiple of ${issue.multipleOf}`;
1054      break;
1055    case ZodIssueCode.not_finite:
1056      message = "Number must be finite";
1057      break;
1058    default:
1059      message = _ctx.defaultError;
1060      util.assertNever(issue);
1061  }
1062  return { message };
1063};
1064var en_default = errorMap;
1065
1066// node_modules/zod/v3/errors.js
1067var overrideErrorMap = en_default;
1068function setErrorMap(map) {
1069  overrideErrorMap = map;
1070}
1071function getErrorMap() {
1072  return overrideErrorMap;
1073}
1074
1075// node_modules/zod/v3/helpers/parseUtil.js
1076var makeIssue = (params) => {
1077  const { data, path, errorMaps, issueData } = params;
1078  const fullPath = [...path, ...issueData.path || []];
1079  const fullIssue = {
1080    ...issueData,
1081    path: fullPath
1082  };
1083  if (issueData.message !== void 0) {
1084    return {
1085      ...issueData,
1086      path: fullPath,
1087      message: issueData.message
1088    };
1089  }
1090  let errorMessage = "";
1091  const maps = errorMaps.filter((m) => !!m).slice().reverse();
1092  for (const map of maps) {
1093    errorMessage = map(fullIssue, { data, defaultError: errorMessage }).message;
1094  }
1095  return {
1096    ...issueData,
1097    path: fullPath,
1098    message: errorMessage
1099  };
1100};
1101var EMPTY_PATH = [];
1102function addIssueToContext(ctx, issueData) {
1103  const overrideMap = getErrorMap();
1104  const issue = makeIssue({
1105    issueData,
1106    data: ctx.data,
1107    path: ctx.path,
1108    errorMaps: [
1109      ctx.common.contextualErrorMap,
1110      // contextual error map is first priority
1111      ctx.schemaErrorMap,
1112      // then schema-bound map if available
1113      overrideMap,
1114      // then global override map
1115      overrideMap === en_default ? void 0 : en_default
1116      // then global default map
1117    ].filter((x) => !!x)
1118  });
1119  ctx.common.issues.push(issue);
1120}
1121var ParseStatus = class _ParseStatus {
1122  constructor() {
1123    this.value = "valid";
1124  }
1125  dirty() {
1126    if (this.value === "valid")
1127      this.value = "dirty";
1128  }
1129  abort() {
1130    if (this.value !== "aborted")
1131      this.value = "aborted";
1132  }
1133  static mergeArray(status, results) {
1134    const arrayValue = [];
1135    for (const s of results) {
1136      if (s.status === "aborted")
1137        return INVALID;
1138      if (s.status === "dirty")
1139        status.dirty();
1140      arrayValue.push(s.value);
1141    }
1142    return { status: status.value, value: arrayValue };
1143  }
1144  static async mergeObjectAsync(status, pairs) {
1145    const syncPairs = [];
1146    for (const pair of pairs) {
1147      const key = await pair.key;
1148      const value = await pair.value;
1149      syncPairs.push({
1150        key,
1151        value
1152      });
1153    }
1154    return _ParseStatus.mergeObjectSync(status, syncPairs);
1155  }
1156  static mergeObjectSync(status, pairs) {
1157    const finalObject = {};
1158    for (const pair of pairs) {
1159      const { key, value } = pair;
1160      if (key.status === "aborted")
1161        return INVALID;
1162      if (value.status === "aborted")
1163        return INVALID;
1164      if (key.status === "dirty")
1165        status.dirty();
1166      if (value.status === "dirty")
1167        status.dirty();
1168      if (key.value !== "__proto__" && (typeof value.value !== "undefined" || pair.alwaysSet)) {
1169        finalObject[key.value] = value.value;
1170      }
1171    }
1172    return { status: status.value, value: finalObject };
1173  }
1174};
1175var INVALID = Object.freeze({
1176  status: "aborted"
1177});
1178var DIRTY = (value) => ({ status: "dirty", value });
1179var OK = (value) => ({ status: "valid", value });
1180var isAborted = (x) => x.status === "aborted";
1181var isDirty = (x) => x.status === "dirty";
1182var isValid = (x) => x.status === "valid";
1183var isAsync = (x) => typeof Promise !== "undefined" && x instanceof Promise;
1184
1185// node_modules/zod/v3/helpers/errorUtil.js
1186var errorUtil;
1187(function(errorUtil2) {
1188  errorUtil2.errToObj = (message) => typeof message === "string" ? { message } : message || {};
1189  errorUtil2.toString = (message) => typeof message === "string" ? message : message == null ? void 0 : message.message;
1190})(errorUtil || (errorUtil = {}));
1191
1192// node_modules/zod/v3/types.js
1193var ParseInputLazyPath = class {
1194  constructor(parent, value, path, key) {
1195    this._cachedPath = [];
1196    this.parent = parent;
1197    this.data = value;
1198    this._path = path;
1199    this._key = key;
1200  }
hooks/git-parse.ts 41 lines
1export interface GitFields {
2  repo: boolean;
3  root: string;
4  branch: string;
5  worktree: string;
6  added: number;
7  modified: number;
8  deleted: number;
9}
10
11const none: GitFields = {
12  repo: false,
13  root: '',
14  branch: '',
15  worktree: '',
16  added: 0,
17  modified: 0,
18  deleted: 0,
19};
20
21/**
22 * Parse the tab-separated output of the git snapshot shell command.
23 * @param stdout - The raw stdout from the git probe command.
24 * @returns      The parsed git fields, or an all-empty set when not in a repository.
25 */
26export function parseGit(stdout: string): GitFields {
27  const t = (stdout || '').trim().split('\t');
28  if (t[0] !== 'YES') {
29    return { ...none };
30  }
31  return {
32    repo: true,
33    root: t[1] ?? '',
34    branch: t[2] ?? '',
35    worktree: t[3] ?? '',
36    added: Number(t[4]) || 0,
37    modified: Number(t[5]) || 0,
38    deleted: Number(t[6]) || 0,
39  };
40}
41
hooks/paint.tsx 41 lines
1import type { RenderModel } from './core.js';
2import { rendererBg, rendererColor } from './core.js';
3
4/**
5 * An element factory taking one props object (with `children` among the props).
6 */
7type ElementFactory = (props: Record<string, unknown>) => unknown;
8
9// No JSX: the element constructors from `$.ui.resolve(e)` take one props object
10// with `children` among the props (what `h(Box, props, ...children)` builds), so
11// they are called directly. This also works with stub factories in tests.
12/**
13 * Paint a render model into engine elements as a column of styled rows.
14 * @param model   - The surface-agnostic render model to paint.
15 * @param el      - The element factories to build the tree with.
16 * @param el.Box  - The `Box` element factory.
17 * @param el.Text - The `Text` element factory.
18 * @returns       The root element produced by the `Box` factory.
19 */
20export function paintModel(
21  model: RenderModel,
22  el: { Box: ElementFactory; Text: ElementFactory }
23): unknown {
24  const { Box, Text } = el;
25  const lines = model.lines.map((line, li) =>
26    Box({
27      key: `l${li}`,
28      flexDirection: 'row',
29      children: line.segments.map((seg, si) =>
30        Text({
31          key: `s${li}-${si}`,
32          color: rendererColor(seg.fg),
33          backgroundColor: rendererBg(seg.bg),
34          children: seg.text,
35        })
36      ),
37    })
38  );
39  return Box({ flexDirection: 'column', children: lines });
40}
41
hooks/snapshot-build.ts 81 lines
1import type { Snapshot } from './core.js';
2import type { GitFields } from './git-parse.js';
3
4export type RawUsage = {
5  ctxTokens: number;
6  ctxPct: number;
7  cached: number;
8  input: number;
9  output: number;
10  startedAt: number;
11  fivePct: number | null;
12  fiveReset: string | null;
13  weekPct: number | null;
14  weekReset: string | null;
15} | null;
16
17const emptyUsage: NonNullable<RawUsage> = {
18  ctxTokens: 0,
19  ctxPct: 0,
20  cached: 0,
21  input: 0,
22  output: 0,
23  startedAt: 0,
24  fivePct: null,
25  fiveReset: null,
26  weekPct: null,
27  weekReset: null,
28};
29
30/**
31 * Assemble a full `Snapshot` from the raw session, git, and usage fields.
32 * @param raw         - The gathered raw inputs.
33 * @param raw.version - The Claude Code version string.
34 * @param raw.model   - The active model identifier.
35 * @param raw.effort  - The active reasoning effort, or `null` when unknown.
36 * @param raw.cwd     - The current working directory.
37 * @param raw.now     - The current clock time in milliseconds.
38 * @param raw.git     - The parsed git fields for the working directory.
39 * @param raw.usage   - The raw usage figures, or `null` when unavailable.
40 * @returns           The complete snapshot with usage defaults filled in.
41 */
42export function buildSnapshot(raw: {
43  version: string;
44  model: string;
45  effort: string | null;
46  cwd: string;
47  now: number;
48  git: GitFields;
49  usage: RawUsage;
50}): Snapshot {
51  const u = raw.usage ?? emptyUsage;
52  return {
53    version: raw.version,
54    model: raw.model,
55    effort: raw.effort,
56    cwd: raw.cwd,
57    repo: raw.git.repo,
58    gitRoot: raw.git.root,
59    gitBranch: raw.git.branch,
60    gitWorktree: raw.git.worktree,
61    added: raw.git.added,
62    modified: raw.git.modified,
63    deleted: raw.git.deleted,
64    ctxTokens: u.ctxTokens,
65    ctxPct: u.ctxPct,
66    cached: u.cached,
67    input: u.input,
68    output: u.output,
69    total: u.cached + u.input + u.output,
70    startedAt: u.startedAt,
71    cost: null,
72    fivePct: u.fivePct,
73    fiveReset: u.fiveReset,
74    weekPct: u.weekPct,
75    weekReset: u.weekReset,
76    blockReset: null,
77    terminalWidth: 0,
78    now: raw.now,
79  };
80}
81
hooks/toasts.ts 31 lines
1import type { Snapshot, ToastRule } from './core.js';
2import { evalWhen } from './core.js';
3
4// Re-exported so existing importers (register.tsx, tests) keep their import path.
5export { evalWhen };
6
7/**
8 * Select the toast rules whose conditions are currently met and not suppressed.
9 * @param rules    - The configured toast rules to evaluate.
10 * @param snapshot - The current snapshot the rules are evaluated against.
11 * @param fired    - A map of rule keys that have already fired once.
12 * @returns        The toasts that are due, each with its display text and dedupe key.
13 */
14export function dueToasts(
15  rules: ToastRule[],
16  snapshot: Snapshot,
17  fired: Record<string, boolean>
18): { text: string; key: string }[] {
19  const out: { text: string; key: string }[] = [];
20  for (const rule of rules) {
21    if (!evalWhen(rule.when, snapshot)) {
22      continue;
23    }
24    if (rule.once && fired[rule.when]) {
25      continue;
26    }
27    out.push({ text: rule.text, key: rule.when });
28  }
29  return out;
30}
31
types/index.d.ts 46 lines
1// The types contract must be self-contained (no imports), so the shapes the
2// plugin keeps in state are restated here; they mirror hooks/core.d.ts.
3
4export interface CcstatusSnapshot {
5  version: string;
6  model: string;
7  effort: string | null;
8  cwd: string;
9  repo: boolean;
10  gitRoot: string;
11  gitBranch: string;
12  gitWorktree: string;
13  added: number;
14  modified: number;
15  deleted: number;
16  ctxTokens: number;
17  ctxPct: number;
18  cached: number;
19  input: number;
20  output: number;
21  total: number;
22  startedAt: number;
23  cost: number | null;
24  fivePct: number | null;
25  fiveReset: string | null;
26  weekPct: number | null;
27  weekReset: string | null;
28  blockReset: string | null;
29  terminalWidth: number;
30  now: number;
31}
32
33declare module 'claude-code' {
34  interface PluginState {
35    ccstatus: {
36      visible: boolean;
37      snapshot: CcstatusSnapshot | null;
38      config: Record<string, unknown> | null;
39      effort: string | null;
40      paneOpen: boolean;
41      configMtime: number | null;
42      toastFired: Record<string, boolean>;
43    };
44  }
45}
46