SLOPSHOPPER

ccbar

ccbar — a quiet status band above the prompt, drawn natively by Claude Code's function hooks: the model and its effort, the repository (GitHub owner/name…

newbandprocesstimer
★ 4v0.2.0MITupdated 2026-10-05emaballarin/ccplugins/plugins/ccbar
A shopper browsing a rack in a slop shop
README

ccbar

ccbar is a quiet status band above the prompt, drawn natively by Claude Code's function hooks. Nothing runs outside the session: no statusLine command, no polling process.

  Opus 5.5 xhigh  ·  emaballarin/ccplugins ⎇ main (+12 −3)  ·  ██████░░░░░░░░░░░░░░░░░▒ 246k/1M (25%)  ·  Tok Σ 2.4M  ·  Session 9% (4h 21m) · Weekly 27% (2d 3h 7m)

Here █ stands for the used window, ░ for the free track and ▒ for the auto-compaction reserve; the band draws all three as coloured cells, not as these glyphs. Groups are joined by one faint · and packed into rows by measured width; the rate-limit windows (Session, Weekly, and Spend behind a gateway) form one group, joined by a closer ·, so they always wrap together. On a narrower terminal the bar first shrinks from 24 to 16 cells (and further below 36 columns); only then does the band wrap, always between whole groups. Wrapped rows hang under the first row's second segment, unless the indent would cost a row or leave a group too wide for the room beside it: then the whole band wraps flush left. Below about 45 columns the joined limits no longer fit a row and are cut off at its end.

Install

/plugin marketplace add emaballarin/ccplugins
/plugin install ccbar@ccplugins

Requirements

  • Claude Code with function hooks enabled. The function-hook API is early access and can change between releases. ccbar was built against Claude Code 2.1.288 and checked on 2.1.289. Function hooks also sit behind a rollout switch. Where it is off, ccbar does not load, and Claude Code says so in the debug log (claude --debug).
  • The terminal or the desktop app. These are the two surfaces that draw the band above the prompt.
  • git, and sh, tail, head, wc and tr (Linux, macOS; head -c is GNU and BSD, not POSIX). Without git the repository group is absent; without the others the token total is.

ccbar coexists with a statusLine command such as ccstatusline: that one draws under the prompt, ccbar above it.

What it shows

GroupExampleDefinition
ModelOpus 5.5 xhighThe main loop's model by name. The effort level follows while extended thinking is on, and nothing follows while it is off.
Repositoryemaballarin/ccplugins ⎇ main (+12 −3)GitHub owner/name from the origin remote (nothing for other hosts), the branch (@<sha> when detached), and inserted/deleted lines from git diff-files --shortstat plus git diff-index --cached --shortstat against HEAD (the empty tree before the first commit), which never touch the index. (+0 −0) is a clean tree; when git cannot read the tree the counter is left out, never shown as clean. Untracked files are not counted.
Context██████░░░░░░░░░░░░░░░░░▒ 246k/1M (25%)Input tokens the last API response was answered over (uncached, cache write and cache read) against the model's window. The percentage is Claude Code's own; its colour measures load against the auto-compaction point, not against the window.
Session tokensTok Σ 2.4MNew tokens: uncached input + cache write + output over every API response of the session, subagents included, counted once per response, at the largest value any of its transcript lines records. Cache reads, the conversation re-sent with every request, are left out. A cache write after the cache has expired (after 5 minutes or an hour idle, depending on the cache lifetime in use) counts the rewritten history again. Subagent transcripts record their responses only as streaming lines, with the output count at the last update written, so a subagent's output tokens can read low; its input and cache-write counts are complete.
Rate limitsSession 9% (4h 21m) · Weekly 27% (2d 3h 7m)Session is the five-hour window, Weekly the seven-day one: usage as the last API response reported it, and the time to reset, to the minute. Behind a Claude gateway, Spend shows the spend limit, which can pass 100%. Absent off a subscription.

Colours

Every colour is a Claude Code theme key, so the band follows the light or dark theme. The swatches below describe the default dark theme.

Text: one colour, one meaning

ColourTheme keyMeansUsed for
clayclaudeidentity: which modelthe model name
lavenderpermissiona mode settingthe effort level
green → amber → redsuccess → warning → errorload against a cap: below 60%, below 85%, from 85%context %, Session %, Weekly %
diff green / diff reddiffAddedWord / diffRemovedWordlines added / removed (dim when zero)(+12 −3)
default—a plain valueused tokens, the Tok Σ count, repository name, branch
light greyinactivelabels, units, secondary figures/1M, Tok Σ, Session, Weekly, countdowns, the owner
dark greysubtlestructurethe · separators

The context bar

The bar spans the model's whole window, left to right. Its used part has the exact length the API reported. That part is split between the /context categories, in /context's order and in /context's own colours, so the bar reads like a one-row /context. The split is Claude Code's local estimate (breakdown: "summary"), recomputed after every main-loop turn. Category boundaries are drawn to an eighth of a cell.

Colour (dark theme)Theme key/context category
mid greypromptBorderSystem prompt
light greyinactiveSystem tools
cyancyan_FOR_SUBAGENTS_ONLYMCP tools
greengreen_FOR_SUBAGENTS_ONLYMCP server instructions
lavenderpermissionCustom agents
clayclaudeMemory files
amberwarningSkills
violetpurple_FOR_SUBAGENTS_ONLYMessages
darkest greyuserMessageBackgroundFree space
dark greysubtleAuto-compaction reserve: compaction runs where it starts

Deferred tool schemas are loaded on demand and sit outside the window, so the bar leaves them out, as /context's grid does. Until the first breakdown arrives, the used part is drawn in light grey (inactive).

Refresh and cost

TriggerRefreshes
Session start, main-loop turn endeverything, including the /context category split
Subagent turn endthe session-token total
Every 60 severything but the category split, so countdowns and limits stay current

Each source also keeps a minimum gap, so a burst of events folds into one run:

SourceGap
in-process reads5 s
transcript tally10 s
git15 s
/context category split30 s

An idle minute costs a few in-process calls, plus four git processes inside a repository (five before its first commit). One sh process is added only if the transcript has grown since the last read. Before a session's first response its transcript does not exist yet; if it is not where the session root says, every project folder is searched for it at most once in 5 minutes.

Design notes

  • Why above the prompt. ccbar would ideally sit where a statusLine command draws. That area is not one of the function-hook drawing sites. The nearest site under the prompt, PromptHint, is mounted below Claude Code's mode-and-hint row (⏵⏵ auto mode on), so it would draw one row lower than a status line does. The band above the prompt is the native place left. If the under-prompt route is ever taken, the tree must keep core's { type: "engine" } element, which is what next(e) returns. With that element in place, Claude Code keeps its own hint row live and only adds the plugin's rows. Without it, the plugin "stands in", and Claude Code swaps its row for a static dim copy of the hint.
  • What Tok Σ counts, and why it is far below ccstatusline's Total. Within one API response the usage fields do not overlap: uncached input, cache write and cache read split the prompt, and output comes on top. Across responses they do: every request re-sends the whole conversation, mostly as cache reads, so a sum that includes them counts the history once per request. On the session measured, cache reads were 97.9% of the metered 115.7M tokens, against 2.4M new; they are also billed at about a tenth of the input price, so the metered sum says little about cost or limits (the Session and Weekly percentages say that). ccbar counts new tokens only. ccstatusline 2.2.30 counts cache reads and, besides, sums every transcript line: a session transcript writes one line per content block, each repeating its response's full usage, which puts it a further 2.1–2.3× over on the sessions measured. ccbar counts each message.id once, at the largest value its lines record for each count, and adds only the rise when a later line raises it. Finished main-loop responses repeat the same counts on every line, so this equals counting each once. Subagent responses are written only as streaming lines (stop_reason null) that are never closed: counting only finished lines would miss about nine in ten of them.
  • Reading the transcript. $.fs.read and $.process.run both stop at 4 MiB, but transcripts grow far past that. ccbar keeps a byte cursor per file (main transcript and each subagent's) and reads only the new whole lines, in 2 MiB ranges. A line longer than a range is stepped over by byte counts (wc), never by decoded text, which cannot say how many bytes a split character held; a response on such a line goes uncounted. A range that reads empty or reports an error is an error, never a skip. A reload rescans from the start.
  • Effort. Taken from the last main-loop turn's Stop hook input, which is the level actually used, after any downgrade for the model. Before the first turn ends it falls back to the effortLevel setting. Whether thinking is on comes from the /config row thinking.
  • Footprint. Four hooks: session.start, turn.complete and classic.Stop observe and pass on; ui.render draws the band and gives way to surveys. Nothing touches tool calls or prompts. The transcript gets one dim notice line per distinct error, and nothing else.

Development

Load the working copy with claude --plugin-dir plugins/ccbar. Claude Code then lays this build's API typings under .claude-plugin/types/ (git-ignored); tsconfig.json extends them. Edits hot-reload at each turn end.

claude plugin validate plugins/ccbar
claude plugin test plugins/ccbar
tsc -p plugins/ccbar

These checks need Claude Code itself and are not part of the repository's CI, which is Python-only: run all three before a release. One rule the validator enforces trips most first attempts. $ is never stored, passed to a function of your own, or returned; it is only ever spelled $.noun.method(…) where it is called. The state helpers read and update are the exception. That is why the refreshers are closures inside the session.start hook.

Source 4 files
hooks/register.tsx 387 lines
1/** ccbar: a quiet status band above the prompt, fed by background refreshers and drawn from one snapshot. */
2import { atom, read, update } from "claude-code";
3import type { Register } from "claude-code";
4
5import type { CcbarContext, CcbarSnapshot } from "../types";
6import { layout } from "./band";
7import type { Counts, Cursor } from "./lib";
8import { drainLines, parseGithub, parseShortstat, prettyModel, tallyTranscript } from "./lib";
9
10const EMPTY: CcbarSnapshot = {
11    model: null,
12    thinking: null,
13    repo: null,
14    context: null,
15    total: null,
16    limits: [],
17    now: 0,
18};
19const snap = atom({ plugin: "ccbar", key: "snap" } as const, EMPTY, { shape: "v3" });
20
21/** Idle cadence; turn ends refresh on their own, and each refresher keeps its own minimum gap. */
22const TICK_MS = 60_000;
23const GAP_MS = { cheap: 5_000, tokens: 10_000, git: 15_000, breakdown: 30_000 };
24/** How often a session whose transcript is not where its root says may rescan every project folder for it. */
25const RESCAN_MS = 5 * 60_000;
26const CHUNK_BYTES = 2 * 1024 * 1024;
27/**
28 * Columns taken off `bodyColumns` before packing: the 2 of left padding, plus 4 for the `[-]` mark. Claude Code 2.1.288
29 * hands the full width; 2.1.289 already takes the mark's five off, where the 4 only make the band wrap a little early.
30 */
31const EDGE_COLUMNS = 6;
32/** Reads a byte range of a file (`$1` from 1, `$2` the path, `$3` the length). */
33const READ_RANGE = 'tail -c "+$1" -- "$2" | head -c "$3"';
34/** Measures the same range: the bytes through its first newline, then how many newlines it holds. */
35const PROBE_RANGE =
36    'r() { tail -c "+$1" -- "$2" | head -c "$3"; }; ' +
37    'printf "%s %s\\n" "$(r "$1" "$2" "$3" | head -n 1 | wc -c)" "$(r "$1" "$2" "$3" | tr -dc "\\n" | wc -c)"';
38
39/** Runs `task` one at a time; a call that lands mid-run schedules one more run instead of overlapping. */
40function singleFlight(task: () => Promise<void>): () => Promise<void> {
41    let running: Promise<void> | null = null;
42    let again = false;
43    return () => {
44        if (running) {
45            again = true;
46            return running;
47        }
48        running = (async () => {
49            do {
50                again = false;
51                await task();
52            } while (again);
53        })().finally(() => {
54            running = null;
55        });
56        return running;
57    };
58}
59
60type Jobs = { all: (withBreakdown: boolean) => void; tokens: () => void; mode: () => void };
61
62export const register: Register = (on) => {
63    // Set by session.start: the refreshers close over that hook's `$`, which is never stored or passed.
64    let jobs: Jobs | null = null;
65    // The effort the last main-loop turn ran at, after any downgrade for the model (classic.Stop):
66    // undefined before any turn ends, null after one ran on a model that takes no effort.
67    let observedEffort: string | null | undefined = undefined;
68
69    on("session.start", async ($, e, next) => {
70        const started = await next(e);
71        const reported = new Set<string>();
72
73        // Transcript cursors live in the module: a reload rescans from the start, rebuilding `seen`.
74        const cursors = new Map<string, Cursor>();
75        const seen = new Map<string, Counts>();
76        let total = 0;
77        let located: { dir: string; id: string } | null = null;
78        let scanned: { id: string; at: number } | null = null;
79
80        const report = (where: string, err: unknown): void => {
81            const text = `ccbar: ${where}: ${err instanceof Error ? err.message : String(err)}`;
82            $.ui.log(text, { to: "debug" });
83            if (!reported.has(text)) {
84                reported.add(text);
85                $.ui.log(text);
86            }
87        };
88
89        /** Applies `change` to the snapshot at write time, writing (and so redrawing) only when it changes something. */
90        const patchWith = async (change: (s: CcbarSnapshot) => CcbarSnapshot): Promise<void> => {
91            const current = await read($, snap);
92            if (JSON.stringify(change(current)) === JSON.stringify(current)) return;
93            await update($, snap, change);
94        };
95        const patch = (fields: Partial<CcbarSnapshot>): Promise<void> => patchWith((s) => ({ ...s, ...fields }));
96
97        /** One at a time and at most once per `gapMs`; calls inside the gap fold into one trailing run. */
98        const paced = (where: string, gapMs: number, task: () => Promise<void>): (() => Promise<void>) => {
99            const run = singleFlight(() => task().catch((err: unknown) => report(where, err)));
100            let last = Number.NEGATIVE_INFINITY;
101            let waiting = false;
102            const call = async (): Promise<void> => {
103                if (waiting) return;
104                waiting = true;
105                let now: number;
106                try {
107                    now = await $.clock.now();
108                    const wait = last + gapMs - now;
109                    if (wait > 0) {
110                        $.clock.after(wait, () => {
111                            waiting = false;
112                            void call();
113                        });
114                        return;
115                    }
116                } catch (err: unknown) {
117                    // A refresher whose pacing fails stays callable rather than going quiet for the session.
118                    waiting = false;
119                    report(where, err);
120                    return;
121                }
122                waiting = false;
123                last = now;
124                await run();
125            };
126            return call;
127        };
128
129        const refreshModel = async (): Promise<void> => {
130            await patch({ model: prettyModel(await $.session.model()) });
131        };
132
133        const refreshMode = async (): Promise<void> => {
134            const row = (await $.config.list()).find((r) => r.key === "thinking");
135            if (row?.value !== true) {
136                await patch({ thinking: null });
137                return;
138            }
139            const configured = (await $.settings.read()).effortLevel;
140            await patch({
141                thinking: {
142                    effort:
143                        observedEffort !== undefined
144                            ? observedEffort
145                            : typeof configured === "string"
146                              ? configured
147                              : null,
148                },
149            });
150        };
151
152        // Plumbing only: `git diff` may refresh the index and take its lock, colliding with a commit in flight.
153        const refreshGit = async (): Promise<void> => {
154            const repo = await $.session.repo();
155            if (!repo) {
156                await patch({ repo: null });
157                return;
158            }
159            const [branch, head] = await Promise.all([
160                $.process.run(["git", "branch", "--show-current"]),
161                $.process.run(["git", "rev-parse", "--verify", "-q", "HEAD"]),
162            ]);
163            // A repository with no commit yet diffs the index against the empty tree.
164            const base =
165                head.exitCode === 0
166                    ? "HEAD"
167                    : (await $.process.run(["git", "hash-object", "-t", "tree", "/dev/null"])).stdout.trim();
168            const [unstaged, staged] = await Promise.all([
169                $.process.run(["git", "diff-files", "--shortstat"]),
170                $.process.run(["git", "diff-index", "--cached", "--shortstat", base]),
171            ]);
172            let name = branch.exitCode === 0 ? branch.stdout.trim() : "";
173            if (!name && head.exitCode === 0) name = `@${head.stdout.trim().slice(0, 7)}`;
174            // A failed read is no clean tree: without both counts the counter is left out.
175            const counted = base !== "" && unstaged.exitCode === 0 && staged.exitCode === 0;
176            const a = parseShortstat(unstaged.stdout);
177            const b = parseShortstat(staged.stdout);
178            const github = parseGithub(repo.remote);
179            await patch({
180                repo: {
181                    owner: github?.owner ?? null,
182                    name: github?.name ?? null,
183                    branch: name,
184                    changes: counted ? { added: a.added + b.added, removed: a.removed + b.removed } : null,
185                },
186            });
187        };
188
189        /** The live figures and the limits; the category split and the compaction point are left as they are. */
190        const refreshUsage = async (): Promise<void> => {
191            const usage = await $.session.usage();
192            const figures = {
193                tokens: usage.context.tokens ?? null,
194                window: usage.context.window,
195                percent: usage.context.percent ?? null,
196            };
197            const limits = usage.rateLimits.map((r) => ({
198                kind: r.kind,
199                percent: r.percentUsed,
200                resetsAt: r.resetsAt ?? null,
201            }));
202            await patchWith((s) => ({
203                ...s,
204                context: { segments: [], threshold: null, ...s.context, ...figures },
205                limits,
206            }));
207        };
208
209        /** The /context category split and the compaction point; the live figures are left to `refreshUsage`. */
210        const refreshBreakdown = async (): Promise<void> => {
211            const usage = await $.session.usage({ breakdown: "summary" });
212            const b = usage.context.breakdown;
213            if (!b) return;
214            const split = {
215                threshold: b.autoCompactThreshold ?? null,
216                segments: b.categories
217                    .filter((c) => c.kind === "used")
218                    .map((c) => ({ color: c.color, tokens: c.tokens })),
219            };
220            const fresh: CcbarContext = {
221                tokens: usage.context.tokens ?? null,
222                window: usage.context.window,
223                percent: usage.context.percent ?? null,
224                ...split,
225            };
226            await patchWith((s) => ({ ...s, context: s.context ? { ...s.context, ...split } : fresh }));
227        };
228
229        /** Finds `<config>/projects/<slug>` holding `<session id>.jsonl`; resets the tally when the id changes (/clear). */
230        const locate = async (): Promise<{ dir: string; id: string } | null> => {
231            const id = await $.session.id();
232            if (located?.id === id) return located;
233            if (located !== null || (scanned !== null && scanned.id !== id)) {
234                cursors.clear();
235                seen.clear();
236                total = 0;
237                located = null;
238            }
239            const home = await $.env.get("HOME");
240            const config = (await $.env.get("CLAUDE_CONFIG_DIR")) ?? (home ? `${home}/.claude` : null);
241            if (!config) return null;
242            const projects = `${config}/projects`;
243            const guess = `${projects}/${(await $.session.root()).replace(/[^a-zA-Z0-9]/g, "-")}`;
244            if (await $.fs.exists(`${guess}/${id}.jsonl`)) {
245                located = { dir: guess, id };
246                return located;
247            }
248            // Before the first response there is no transcript yet: scan every project folder only now and then.
249            const now = await $.clock.now();
250            if (scanned?.id === id && now - scanned.at < RESCAN_MS) return null;
251            scanned = { id, at: now };
252            for (const entry of await $.fs.list(projects)) {
253                if (entry.kind === "dir" && (await $.fs.exists(`${projects}/${entry.name}/${id}.jsonl`))) {
254                    located = { dir: `${projects}/${entry.name}`, id };
255                    return located;
256                }
257            }
258            return null;
259        };
260
261        const runRange = async (script: string, path: string, offset: number, length: number): Promise<string> => {
262            const run = await $.process.run(["sh", "-c", script, "ccbar", String(offset + 1), path, String(length)]);
263            // The pipe reports `head`'s status alone, so `tail`'s stderr carries its failures; "Broken pipe" is no
264            // failure but `tail` cut off by `head`, which it says aloud where SIGPIPE is ignored.
265            const errors = run.stderr
266                .split("\n")
267                .filter((l) => l.trim() !== "" && !/broken pipe/i.test(l))
268                .join("; ");
269            if (run.exitCode !== 0 || errors !== "") {
270                throw new Error(`reading ${path}: ${errors || `exit ${run.exitCode}`}`);
271            }
272            return run.stdout;
273        };
274
275        /** Tallies a JSONL file's new whole lines from its cursor, in ranges under the 4 MiB read limits. */
276        const drain = async (path: string): Promise<void> => {
277            const { size } = await $.fs.stat(path);
278            const cursor = cursors.get(path) ?? { offset: 0, skipping: false };
279            cursors.set(path, cursor);
280            await drainLines(
281                cursor,
282                size,
283                CHUNK_BYTES,
284                (offset, length) => runRange(READ_RANGE, path, offset, length),
285                async (offset, length) => {
286                    const [first = NaN, newlines = NaN] = (await runRange(PROBE_RANGE, path, offset, length))
287                        .trim()
288                        .split(/\s+/)
289                        .map(Number);
290                    if (!Number.isInteger(first) || !Number.isInteger(newlines)) throw new Error(`probing ${path}`);
291                    return { first, newlines };
292                },
293                (lines) => {
294                    total += tallyTranscript(lines, seen);
295                }
296            );
297        };
298
299        const refreshTotal = async (): Promise<void> => {
300            const where = await locate();
301            if (!where) {
302                await patch({ total: null });
303                return;
304            }
305            const paths = [`${where.dir}/${where.id}.jsonl`];
306            const subagents = `${where.dir}/${where.id}/subagents`;
307            if (await $.fs.exists(subagents)) {
308                for (const entry of await $.fs.list(subagents)) {
309                    if (entry.kind === "file" && /^agent-.+\.jsonl$/.test(entry.name)) {
310                        paths.push(`${subagents}/${entry.name}`);
311                    }
312                }
313            }
314            for (const path of paths) await drain(path);
315            await patch({ total });
316        };
317
318        const tick = async (): Promise<void> => {
319            await patch({ now: await $.clock.now() });
320        };
321
322        const clock = paced("clock", 0, tick);
323        const model = paced("model", GAP_MS.cheap, refreshModel);
324        const mode = paced("thinking", GAP_MS.cheap, refreshMode);
325        const usage = paced("usage", GAP_MS.cheap, refreshUsage);
326        const breakdown = paced("context", GAP_MS.breakdown, refreshBreakdown);
327        const git = paced("git", GAP_MS.git, refreshGit);
328        const tokens = paced("tokens", GAP_MS.tokens, refreshTotal);
329
330        jobs = {
331            all: (withBreakdown) => {
332                void clock();
333                void model();
334                void mode();
335                void usage();
336                if (withBreakdown) void breakdown();
337                void git();
338                void tokens();
339            },
340            tokens: () => void tokens(),
341            mode: () => void mode(),
342        };
343
344        $.clock.after(0, () => jobs?.all(true));
345        $.clock.every(TICK_MS, () => jobs?.all(false));
346        return started;
347    });
348
349    on("turn.complete", async ($, e, next) => {
350        const done = await next(e);
351        // A main-loop turn may have moved everything; a subagent's only the token tally.
352        $.clock.after(0, () => (e.agentId === undefined ? jobs?.all(true) : jobs?.tokens()));
353        return done;
354    });
355
356    on("classic.Stop", async ($, e, next) => {
357        const result = await next(e);
358        const effort = e.effort?.level || null;
359        if (effort !== observedEffort) {
360            observedEffort = effort;
361            $.clock.after(0, () => jobs?.mode());
362        }
363        return result;
364    });
365
366    on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
367        if (e.props.hasSurvey) return next(e);
368        const s = await read($, snap);
369        if (!s.model && !s.context) return next(e);
370        const { Box, Text } = $.ui.resolve(e);
371        const rows = layout(s, Math.max(20, e.props.bodyColumns - EDGE_COLUMNS));
372        return (
373            <Box flexDirection="column" paddingLeft={2} marginTop={2}>
374                {rows.map((row) => (
375                    <Text wrap="truncate-end">
376                        {row.map((r) => (
377                            <Text color={r.color} backgroundColor={r.bg} bold={r.bold}>
378                                {r.text}
379                            </Text>
380                        ))}
381                    </Text>
382                ))}
383            </Box>
384        );
385    });
386};
387
hooks/band.ts 142 lines
1/** The band's layout as plain data: coloured runs, grouped, packed into rows under a width. */
2import type { CcbarSnapshot } from "../types";
3import { contextPieces, formatCountdown, formatTokens, layBar, limitLabel } from "./lib";
4
5/** A stretch of text in one style; colours are theme keys. */
6export type Run = { text: string; color?: string; bg?: string; bold?: boolean };
7
8/** Runs that stay together on one row. */
9export type Group = Run[];
10
11/** What sits between two groups on a row. */
12export const SEPARATOR: Run = { text: "  ·  ", color: "subtle" };
13
14/** The closer join inside a group of figures of one kind: the rate-limit windows (Session, Weekly, Spend). */
15export const TIGHT: Run = { text: " · ", color: "subtle" };
16
17/** Theme keys by meaning: one colour, one sense. */
18export const COLOR = {
19    identity: "claude",
20    mode: "permission",
21    label: "inactive",
22    added: "diffAddedWord",
23    removed: "diffRemovedWord",
24} as const;
25
26const BAR = { fill: "inactive", track: "userMessageBackground", reserve: "subtle" };
27
28/** Load against a cap: green below 60%, yellow below 85%, red from there. */
29export function loadColor(percent: number): string {
30    if (percent >= 85) return "error";
31    if (percent >= 60) return "warning";
32    return "success";
33}
34
35/** Columns a run list takes; every glyph the band draws is one cell wide. */
36export function width(runs: Run[]): number {
37    return runs.reduce((sum, r) => sum + [...r.text].length, 0);
38}
39
40/**
41 * Packs groups into rows no wider than `columns`, `SEPARATOR` between neighbours on a row. Rows after
42 * the first start `indent` columns in, and have that much less room.
43 */
44export function pack(groups: Group[], columns: number, indent = 0): Run[][] {
45    const rows: Run[][] = [];
46    let row: Run[] = [];
47    for (const group of groups) {
48        const room = rows.length === 0 ? columns : columns - indent;
49        const joined = row.length > 0 ? [...row, SEPARATOR, ...group] : group;
50        if (row.length > 0 && width(joined) > room) {
51            rows.push(row);
52            row = [...group];
53        } else {
54            row = joined;
55        }
56    }
57    if (row.length > 0) rows.push(row);
58    return indent > 0 ? rows.map((r, i) => (i === 0 ? r : [{ text: " ".repeat(indent) }, ...r])) : rows;
59}
60
61/** The band's groups in reading order: model, repository, context, tokens, then the rate limits together. */
62export function groups(s: CcbarSnapshot, barWidth: number): Group[] {
63    const out: Group[] = [];
64    if (s.model) {
65        const effort = s.thinking?.effort;
66        out.push([
67            { text: s.model, color: COLOR.identity, bold: true },
68            ...(effort ? [{ text: ` ${effort}`, color: COLOR.mode }] : []),
69        ]);
70    }
71    if (s.repo) {
72        const r = s.repo;
73        const group: Run[] = [];
74        if (r.owner && r.name) group.push({ text: `${r.owner}/`, color: COLOR.label }, { text: r.name });
75        if (r.branch) group.push({ text: group.length > 0 ? " ⎇ " : "⎇ ", color: COLOR.label }, { text: r.branch });
76        // Drawn whenever git could read the tree: a quiet (+0 −0) says it is clean.
77        if (r.changes) {
78            const { added, removed } = r.changes;
79            group.push(
80                { text: group.length > 0 ? " (" : "(", color: COLOR.label },
81                { text: `+${added}`, color: added > 0 ? COLOR.added : COLOR.label },
82                { text: " ", color: COLOR.label },
83                { text: `−${removed}`, color: removed > 0 ? COLOR.removed : COLOR.label },
84                { text: ")", color: COLOR.label }
85            );
86        }
87        if (group.length > 0) out.push(group);
88    }
89    if (s.context) {
90        const c = s.context;
91        const used = c.tokens;
92        const cells = layBar(contextPieces(c, barWidth, BAR), barWidth);
93        const group: Run[] = cells.map((cell) => ({ text: cell.ch, color: cell.fg, bg: cell.bg }));
94        group.push(
95            { text: ` ${used === null ? "–" : formatTokens(used)}` },
96            { text: `/${formatTokens(c.window)}`, color: COLOR.label }
97        );
98        if (c.percent !== null && used !== null) {
99            const load = (used / (c.threshold ?? c.window)) * 100;
100            group.push(
101                { text: " (", color: COLOR.label },
102                { text: `${c.percent}%`, color: loadColor(load) },
103                { text: ")", color: COLOR.label }
104            );
105        }
106        out.push(group);
107    }
108    if (s.total) out.push([{ text: "Tok Σ ", color: COLOR.label }, { text: formatTokens(s.total) }]);
109    // The rate-limit windows wrap as one group: a row never splits Session from Weekly.
110    const limits: Run[] = [];
111    for (const l of s.limits) {
112        if (limits.length > 0) limits.push(TIGHT);
113        limits.push(
114            { text: `${limitLabel(l.kind)} `, color: COLOR.label },
115            { text: `${l.percent}%`, color: loadColor(l.percent) }
116        );
117        if (l.resetsAt && s.now) {
118            limits.push({ text: ` (${formatCountdown(Date.parse(l.resetsAt) - s.now)})`, color: COLOR.label });
119        }
120    }
121    if (limits.length > 0) out.push(limits);
122    return out;
123}
124
125/**
126 * Rows for `columns`: one row with a 24-cell bar, else one with 16, else wrapped rows with up to 16 (fewer
127 * below 36 columns). Wrapped rows hang under the first row's second segment, unless the indent would cost a
128 * row or leave a group too wide for the room beside it: then the whole band wraps flush left.
129 */
130export function layout(s: CcbarSnapshot, columns: number): Run[][] {
131    for (const bar of [24, 16]) {
132        const rows = pack(groups(s, bar), columns);
133        if (rows.length <= 1) return rows;
134    }
135    const wrapped = groups(s, Math.max(6, Math.min(16, columns - 20)));
136    const first = wrapped[0];
137    const flat = pack(wrapped, columns);
138    const hanging = first ? pack(wrapped, columns, width(first) + width([SEPARATOR])) : [];
139    const fits = hanging.length > 0 && hanging.length <= flat.length && hanging.every((r) => width(r) <= columns);
140    return fits ? hanging : flat;
141}
142
hooks/lib.ts 252 lines
1/** Pure helpers for ccbar: formatting, git and transcript parsing, and the context bar's layout. */
2
3/** A run of one colour along the bar, in eighths of a cell. */
4export type BarPiece = { color: string; units: number };
5
6/** One terminal cell of the bar: a left-aligned eighths block in `fg` over `bg`, or a blank in `bg`. */
7export type BarCell = { ch: string; fg: string | undefined; bg: string };
8
9/** Theme keys the bar draws its non-category runs in. */
10export type BarColors = { fill: string; track: string; reserve: string };
11
12const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
13
14const trimZero = (s: string): string => s.replace(/\.0$/, "");
15
16/** Formats a token count compactly: 940, 9.4k, 49k, 1M, 3.9M. */
17export function formatTokens(n: number): string {
18    if (n < 1000) return String(Math.round(n));
19    if (n < 9950) return `${trimZero((n / 1000).toFixed(1))}k`;
20    if (n < 999_500) return `${Math.round(n / 1000)}k`;
21    return `${trimZero((n / 1e6).toFixed(1))}M`;
22}
23
24/** Formats a duration down to the minute: `2d 3h 7m`, `4h 54m`, `12m`. */
25export function formatCountdown(ms: number): string {
26    const minutes = Math.max(0, Math.floor(ms / 60_000));
27    const d = Math.floor(minutes / 1440);
28    const h = Math.floor((minutes % 1440) / 60);
29    const m = minutes % 60;
30    if (d > 0) return `${d}d ${h}h ${m}m`;
31    if (h > 0) return `${h}h ${m}m`;
32    return `${m}m`;
33}
34
35/** Turns a model id (`claude-opus-5-5[1m]`) into its name (`Opus 5.5`); other spellings pass through. */
36export function prettyModel(raw: string): string {
37    const id = /claude-([a-z]+)-(\d{1,2})(?:-(\d{1,2}))?(?!\d)/i.exec(raw);
38    if (id?.[1] && id[2]) {
39        const family = id[1].charAt(0).toUpperCase() + id[1].slice(1).toLowerCase();
40        return id[3] ? `${family} ${id[2]}.${id[3]}` : `${family} ${id[2]}`;
41    }
42    return raw.replace(/\s*\[1m\]\s*$/i, "").trim();
43}
44
45/** Reads `owner/name` off a GitHub remote URL (https, ssh or scp-like); null for any other host. */
46export function parseGithub(remote: string | null): { owner: string; name: string } | null {
47    if (!remote) return null;
48    const m =
49        /^(?:git@github\.com:|ssh:\/\/git@github\.com(?::\d+)?\/|https?:\/\/(?:[^@/]+@)?github\.com\/|git:\/\/github\.com\/)([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(
50            remote.trim()
51        );
52    return m?.[1] && m[2] ? { owner: m[1], name: m[2] } : null;
53}
54
55/** Reads inserted and deleted line counts off `git diff --shortstat` output. */
56export function parseShortstat(text: string): { added: number; removed: number } {
57    const added = /(\d+) insertions?\(\+\)/.exec(text)?.[1];
58    const removed = /(\d+) deletions?\(-\)/.exec(text)?.[1];
59    return { added: added ? Number(added) : 0, removed: removed ? Number(removed) : 0 };
60}
61
62const count = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0);
63
64type TranscriptLine = {
65    message?: { id?: unknown; usage?: Record<string, unknown> };
66};
67
68/** A response's new-token counts so far (uncached input, output, cache write): the largest each of its lines has recorded. */
69export type Counts = readonly [number, number, number];
70
71/**
72 * Adds the new tokens in whole JSONL lines to a per-response tally, and returns by how much the total grew.
73 *
74 * New tokens are a request's uncached input, its cache writes and its output; cache reads are the
75 * conversation re-sent with every request and are left out, so the total grows by what each request
76 * added, not by the history it carried again. A response is written as several lines (one per
77 * content block), each repeating its usage, so a response counts once, at the largest value any of
78 * its lines records for each count. A finished main-loop response repeats the same counts on every
79 * line; a subagent's is written only as streaming lines (`stop_reason` null) that are never closed,
80 * and the largest they record is the best there is. A later line that raises a response's counts
81 * adds only the rise, so lines read in separate passes still count once.
82 */
83export function tallyTranscript(text: string, seen: Map<string, Counts>): number {
84    let added = 0;
85    for (const line of text.split("\n")) {
86        if (!line.includes('"usage"')) continue;
87        let parsed: TranscriptLine;
88        try {
89            parsed = JSON.parse(line) as TranscriptLine;
90        } catch {
91            continue;
92        }
93        const message = parsed.message;
94        const usage = message?.usage;
95        if (!message || !usage || typeof message.id !== "string") continue;
96        const previous = seen.get(message.id) ?? [0, 0, 0];
97        const merged: Counts = [
98            Math.max(previous[0], count(usage.input_tokens)),
99            Math.max(previous[1], count(usage.output_tokens)),
100            Math.max(previous[2], count(usage.cache_creation_input_tokens)),
101        ];
102        added += merged[0] + merged[1] + merged[2] - (previous[0] + previous[1] + previous[2]);
103        seen.set(message.id, merged);
104    }
105    return added;
106}
107
108/** Where a read of an append-only JSONL file stands: the byte offset of the next unread byte, and whether it lies inside a line longer than a chunk. */
109export type Cursor = { offset: number; skipping: boolean };
110
111/** Reads at most `length` bytes of the file from byte `offset`, as UTF-8 text. */
112export type ReadRange = (offset: number, length: number) => Promise<string>;
113
114/** Measures at most `length` bytes from byte `offset`: the bytes through the first newline (all of them without one), and how many newlines there are. */
115export type ProbeRange = (offset: number, length: number) => Promise<{ first: number; newlines: number }>;
116
117/**
118 * Advances `cursor` over a file of `size` bytes, handing each run of whole new lines to `take`.
119 *
120 * A range read in text starts on a line boundary and is cut after its last newline, so the text
121 * re-encodes to its exact byte length. A line longer than `chunk` is stepped over by byte counts
122 * alone (`probe`), never by decoded text, which cannot say how many bytes a split character held.
123 * An empty read before the end of the file is an error, not a skip.
124 */
125export async function drainLines(
126    cursor: Cursor,
127    size: number,
128    chunk: number,
129    read: ReadRange,
130    probe: ProbeRange,
131    take: (lines: string) => void
132): Promise<void> {
133    if (size < cursor.offset) {
134        cursor.offset = 0;
135        cursor.skipping = false;
136    }
137    while (cursor.offset < size) {
138        if (cursor.skipping) {
139            const { first, newlines } = await probe(cursor.offset, chunk);
140            if (newlines === 0) {
141                // Still inside the over-long line; at the end of the file it is still being written.
142                if (cursor.offset + chunk >= size) return;
143                cursor.offset += chunk;
144                continue;
145            }
146            cursor.offset += first;
147            cursor.skipping = false;
148            continue;
149        }
150        const text = await read(cursor.offset, chunk);
151        if (text === "") throw new Error(`no bytes at offset ${cursor.offset} of ${size}`);
152        const cut = text.lastIndexOf("\n");
153        if (cut < 0) {
154            // A partial last line waits for its end; a full chunk with no newline is an over-long line.
155            if (cursor.offset + chunk >= size) return;
156            cursor.skipping = true;
157            continue;
158        }
159        const lines = text.slice(0, cut + 1);
160        cursor.offset += new TextEncoder().encode(lines).length;
161        take(lines);
162    }
163}
164
165/**
166 * Splits a bar of `width` cells into coloured runs: the used window by category, the free window,
167 * then the auto-compaction reserve at the end.
168 *
169 * The used length is exact (`tokens` over `window`); the categories share it in proportion to
170 * their estimates, by largest remainder. Without categories the used run takes `colors.fill`.
171 */
172export function contextPieces(
173    ctx: {
174        tokens: number | null;
175        window: number;
176        threshold: number | null;
177        segments: { color: string; tokens: number }[];
178    },
179    width: number,
180    colors: BarColors
181): BarPiece[] {
182    const total = width * 8;
183    const toUnits = (tokens: number): number => Math.round(Math.min(1, tokens / ctx.window) * total);
184    const used = ctx.tokens && ctx.tokens > 0 ? Math.max(1, toUnits(ctx.tokens)) : 0;
185    const reserveStart = ctx.threshold !== null && ctx.threshold < ctx.window ? toUnits(ctx.threshold) : total;
186
187    const pieces: BarPiece[] = [];
188    const segments = ctx.segments.filter((s) => s.tokens > 0);
189    const estimated = segments.reduce((sum, s) => sum + s.tokens, 0);
190    if (used > 0 && estimated > 0) {
191        const exact = segments.map((s) => (s.tokens / estimated) * used);
192        const units = exact.map(Math.floor);
193        let left = used - units.reduce((a, b) => a + b, 0);
194        const byRemainder = exact.map((x, i) => ({ i, r: x - Math.floor(x) })).sort((a, b) => b.r - a.r);
195        for (const { i } of byRemainder) {
196            if (left <= 0) break;
197            units[i] = (units[i] ?? 0) + 1;
198            left -= 1;
199        }
200        segments.forEach((s, i) => pieces.push({ color: s.color, units: units[i] ?? 0 }));
201    } else if (used > 0) {
202        pieces.push({ color: colors.fill, units: used });
203    }
204    const free = Math.max(0, reserveStart - used);
205    pieces.push({ color: colors.track, units: free });
206    pieces.push({ color: colors.reserve, units: total - used - free });
207    return pieces.filter((p) => p.units > 0);
208}
209
210/**
211 * Lays runs into cells. A cell inside one run is a blank on that colour; a cell where runs meet
212 * draws the first as a left eighths block over the largest of the rest, so a boundary keeps
213 * eighth-cell precision with two colours per cell.
214 */
215export function layBar(pieces: BarPiece[], width: number): BarCell[] {
216    const queue = pieces.map((p) => ({ ...p }));
217    const fallback = pieces.at(-1)?.color ?? "subtle";
218    const cells: BarCell[] = [];
219    for (let c = 0; c < width; c++) {
220        const parts: BarPiece[] = [];
221        let room = 8;
222        while (room > 0 && queue.length > 0) {
223            const head = queue[0];
224            if (!head) break;
225            const take = Math.min(head.units, room);
226            const last = parts.at(-1);
227            if (last && last.color === head.color) last.units += take;
228            else if (take > 0) parts.push({ color: head.color, units: take });
229            head.units -= take;
230            room -= take;
231            if (head.units <= 0) queue.shift();
232        }
233        const first = parts[0];
234        if (!first || parts.length === 1) {
235            cells.push({ ch: " ", fg: undefined, bg: first?.color ?? fallback });
236            continue;
237        }
238        const rest = parts.slice(1).reduce((a, b) => (b.units > a.units ? b : a));
239        cells.push({ ch: EIGHTHS[first.units] ?? " ", fg: first.color, bg: rest.color });
240    }
241    return cells;
242}
243
244/** A rate-limit window's label, as Claude's usage pages name it: `Session`, `Weekly`, `Spend`, else its kind. */
245export function limitLabel(kind: string): string {
246    if (kind === "five_hour") return "Session";
247    if (kind === "seven_day") return "Weekly";
248    if (kind === "spend_limit") return "Spend";
249    const words = kind.replace(/_/g, " ");
250    return words.charAt(0).toUpperCase() + words.slice(1);
251}
252
types/index.d.ts 53 lines
1/** The figures the band draws, gathered by background refreshers. */
2export type CcbarSnapshot = {
3    /** The main loop's model, prettified (`Opus 5.5`). */
4    model: string | null;
5    /** Extended thinking: null when off; on, the effort level when one is known. */
6    thinking: { effort: string | null } | null;
7    /** The working copy's repository; null outside one. */
8    repo: CcbarRepo | null;
9    /** The live context window; null before the first refresh. */
10    context: CcbarContext | null;
11    /** New tokens (uncached input, cache writes, output) over every API response of the session, subagents included. */
12    total: number | null;
13    /** The account's rate-limit windows, as the last API response reported them. */
14    limits: CcbarLimit[];
15    /** The clock at the last tick, that reset countdowns are drawn against. */
16    now: number;
17};
18
19/** Repository identity and working-tree change counts. */
20export type CcbarRepo = {
21    /** GitHub owner and name; null for a remote elsewhere or none. */
22    owner: string | null;
23    name: string | null;
24    /** The branch, or `@<short sha>` when detached; empty when unknown. */
25    branch: string;
26    /** Inserted and deleted lines, staged and unstaged together; null when git could not read them. */
27    changes: { added: number; removed: number } | null;
28};
29
30/** Context-window occupancy: exact totals, with the category split estimated. */
31export type CcbarContext = {
32    tokens: number | null;
33    window: number;
34    percent: number | null;
35    /** Where auto-compaction runs, in tokens; null when it is off. */
36    threshold: number | null;
37    /** The `used` rows of the /context breakdown, in its order, by theme colour. */
38    segments: { color: string; tokens: number }[];
39};
40
41/** One rate-limit window. */
42export type CcbarLimit = {
43    kind: string;
44    percent: number;
45    resetsAt: string | null;
46};
47
48declare module "claude-code" {
49    interface PluginState {
50        ccbar: { snap: Shaped<CcbarSnapshot> };
51    }
52}
53