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…

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.
/plugin marketplace add emaballarin/ccplugins
/plugin install ccbar@ccplugins
claude --debug).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.
| Group | Example | Definition |
|---|---|---|
| Model | Opus 5.5 xhigh | The main loop's model by name. The effort level follows while extended thinking is on, and nothing follows while it is off. |
| Repository | emaballarin/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 tokens | Tok Σ 2.4M | New 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 limits | Session 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. |
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.
| Colour | Theme key | Means | Used for |
|---|---|---|---|
| clay | claude | identity: which model | the model name |
| lavender | permission | a mode setting | the effort level |
| green → amber → red | success → warning → error | load against a cap: below 60%, below 85%, from 85% | context %, Session %, Weekly % |
| diff green / diff red | diffAddedWord / diffRemovedWord | lines added / removed (dim when zero) | (+12 −3) |
| default | — | a plain value | used tokens, the Tok Σ count, repository name, branch |
| light grey | inactive | labels, units, secondary figures | /1M, Tok Σ, Session, Weekly, countdowns, the owner |
| dark grey | subtle | structure | the · separators |
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 grey | promptBorder | System prompt |
| light grey | inactive | System tools |
| cyan | cyan_FOR_SUBAGENTS_ONLY | MCP tools |
| green | green_FOR_SUBAGENTS_ONLY | MCP server instructions |
| lavender | permission | Custom agents |
| clay | claude | Memory files |
| amber | warning | Skills |
| violet | purple_FOR_SUBAGENTS_ONLY | Messages |
| darkest grey | userMessageBackground | Free space |
| dark grey | subtle | Auto-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).
| Trigger | Refreshes |
|---|---|
| Session start, main-loop turn end | everything, including the /context category split |
| Subagent turn end | the session-token total |
| Every 60 s | everything 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:
| Source | Gap |
|---|---|
| in-process reads | 5 s |
| transcript tally | 10 s |
| git | 15 s |
/context category split | 30 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.
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.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.$.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.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.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.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.
hooks/register.tsx 387 lines1/** 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};
387hooks/band.ts 142 lines1/** 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}
142hooks/lib.ts 252 lines1/** 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}
252types/index.d.ts 53 lines1/** 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