A chef station tray above the prompt: plan limits, today's spend, the hourly trend, models and projects, and thirteen weeks of activity.

A Claude Code mod that puts a chef station tray directly above your prompt, so you can keep an eye on the kitchen without leaving the session. The repository is called claude-chef, but the plugin is named chef-station because Claude Code reserves plugin names that begin with claude-.
https://github.com/user-attachments/assets/33ad4494-3bfb-48da-80f3-af303e1bfc2a
The tray has five stations. Press the button for a station, or focus the tray with ctrl+x tab and press its number. You can also type /chef <station>, or just /chef to cycle to the next one. The tray remembers the station you picked across sessions. /chef backfill rescans your Claude Code history. Keyboard shortcuts lists every key and command in one place.
/context breaks it down: the system prompt, system tools, MCP tools, custom agents, memory files, skills and messages, then the free space and the auto-compact buffer. A legend below the bar names each category with its tokens and its share of the window. Tool schemas that load on demand sit outside the window, so they are left out. The breakdown is estimated locally, the same way /context estimates it, and refreshes after every turn. Below the legend, Compact (hotkey c) compacts the conversation the way /compact does and says how many tokens it saved. Clear (hotkey x) ends the conversation and starts a new one, as /clear does, after asking you to confirm with y or cancel with n. Neither can run during a turn, so while Claude is working the buttons give way to a short note.The Context station's colors come from a categorical palette checked with a data-visualization validator against both a light and a dark terminal background, because the tray cannot rely on knowing which one you use. Segments that touch are always colors validated as a pair, including for protanopia and deuteranopia. The colors are handed out in the bar's order to guarantee that, so a category's color can change when another category appears or disappears. A one-cell gap separates segments, free space and the buffer are drawn as textures rather than hues, and the legend names every color, so nothing is told apart by color alone.
The station and button keys work only while the tray has focus, so they never interfere with typing a prompt. Focus the tray with ctrl+x tab (or click it), and press Esc to return to the prompt.
| Keys | Where | What it does | | :- | :- | :- | | ctrl+x tab | Anywhere | Focuses the tray. | | Esc | Tray focused | Returns to the prompt. | | ctrl+x ctrl+a | Anywhere | Collapses the tray, as its [-] mark does. | | 1 to 5 | Tray focused | Switches to Usage, Trend, Breakdown, Activity or Context. | | c | Context station | Compacts the conversation. | | x | Context station | Asks to clear the conversation. | | y | Clear confirmation | Clears the conversation and starts a new one. | | n | Clear confirmation | Cancels the clear. |
The tray also answers to /chef at the prompt:
| Command | What it does | | :- | :- | | /chef | Cycles to the next station. | | /chef usage, /chef trend, /chef breakdown, /chef activity, /chef context | Switches to that station. | | /chef backfill | Rescans your Claude Code history. |
The "Now" row shows "cache warm · ~42m left" while the main conversation's prompt cache is likely still warm, and "cache expired" once it has likely lapsed. "Warm" means the cache entries have likely not reached the end of their lifetime, not that the next prompt is guaranteed a cache read: a read also needs the start of the prompt to match what was cached, so anything that changes earlier content, such as switching models or compacting, misses the cache even while it is warm. An expired cache means the next prompt pays to write the cache again. The countdown is the tray's own estimate, which the tilde marks: Claude Code does not report it. A cache entry lives for a fixed time from the start of the last request that read or wrote it, so the tray restarts the countdown as each request of the main conversation starts (a subagent's requests use a cache of their own). How long an entry lives, five minutes or an hour, depends on how it was written, which only the session's transcript records. The tray reads the end of the transcript as each turn ends, and until then it assumes the shorter five minutes, so it never calls a cold cache warm. A /clear starts the countdown over.
The rate-limit windows, the session cost and the context fill come straight from Claude Code ($.session.usage()), so they match the status line and /cost. Rate-limit windows only appear on a subscription and only after the first reply of a session.
Today's spend, the trend, the breakdown and the activity grid come from two sources that the tray adds together.
The first is what the mod records live. Each time a turn completes, in the main conversation or in a subagent, the mod records the session cost added since the previous turn, along with the tokens the turn reported, the model that answered and the project. Every session stores its turns under a store key of its own (turns:<session id>), and the tray adds all of them up when it reads them, so two sessions running at the same time never overwrite each other's turns. The tray picks up other sessions' spending once a minute, and sessions older than thirteen weeks are removed. The cost attributed to each model is an approximation: when a subagent runs, the parent's requests made before the subagent finished are counted against the subagent's model.
The second is a backfill from the session transcripts Claude Code keeps in ~/.claude/projects (or $CLAUDE_CONFIG_DIR/projects). The backfill reads the last thirteen weeks, a few seconds after the first session of each day starts, and again whenever you run /chef backfill. It leaves out every session the tray has already recorded live, so nothing is counted twice. Each response appears on several lines of a transcript, so the backfill counts each response once by its message and request IDs. Transcripts hold token counts but no cost, so the backfill estimates cost from first-party API list prices, including the separate rates for cache reads, five-minute and one-hour cache writes, and fast mode. Those prices live in hooks/transcript.mjs. A model with no known price still counts its tokens, and /chef backfill names it.
A mod can only read files of up to 4 MiB, and long sessions write larger transcripts. So when Node is installed, the mod runs the helper script scripts/backfill.mjs, which streams files of any size and prints the tally as JSON. Without Node, the mod reads the transcripts itself and skips the large ones, and /chef backfill says how many it skipped. A transcript that cannot be read at all, for example because it was removed during the scan, adds nothing rather than part of its usage, and /chef backfill reports the backfill as incomplete and says how many transcripts were affected. The helper also works on its own, which is handy for checking the numbers:
echo '[]' | node scripts/backfill.mjs --projects ~/.claude/projects
The JSON array on standard input lists session IDs to leave out.
Claude Code does not tell plugins which plan you are on, so the plan badge is a setting. Set it in /config under the chef-station rows, for example to Max 5×, or leave it empty to hide the badge.
At the prompt of a Claude Code terminal session, run:
/plugin install chef-station --marketplace schalkneethling/claude-chef
Answer y to add the marketplace, then pick a scope. The user scope makes the tray appear in every session.
Claude Code keeps the copy of the plugin it made when you installed it. Auto-update is off by default for marketplaces outside Anthropic's own, including this one, so new versions do not arrive on their own until you turn it on. To turn it on, run /plugin, open the Marketplaces tab, select chef-station and choose Enable auto-update. Claude Code then refreshes the marketplace in the background shortly after a session starts and updates the plugin on disk.
To update right away instead, fetch the latest marketplace listing and then update the plugin:
claude plugin marketplace update chef-station
claude plugin update chef-station
Either way, a session that is already running keeps the version it loaded. Run /reload-plugins to load the new version there, or start a new session. If you load the mod from a clone with --plugin-dir, a git pull is enough, because Claude Code reads that folder directly.
An update installs the version named in .claude-plugin/plugin.json, so every change meant to reach people who installed the plugin raises that version.
Load the mod straight from your clone for a single session:
claude --plugin-dir /path/to/claude-chef
Claude Code watches the folder in an interactive session, so saving a file reloads the mod. The ledger and the selected station survive a reload. Run with claude --debug to see why a hook was skipped or a drawing was refused.
To test the marketplace install flow against your working copy instead of GitHub, add the folder as a marketplace and install from it. Edits are then picked up with /reload-plugins:
claude plugin marketplace add /path/to/claude-chef
claude plugin install chef-station@chef-station
claude plugin validate .
claude plugin test .
validate reads the manifest, the marketplace file and the hooks module the way Claude Code will. test runs the tests/*.test.ts files against the engine. format.test.ts, ledger.test.ts and transcript.test.ts cover the pure formatting, bookkeeping, transcript parsing and pricing. register.test.ts mounts the tray on the terminal and desktop surfaces with a stubbed session, clock, store and helper script.
Once Claude Code has loaded the mod from your folder it writes its type declarations to .claude-plugin/types/, and npx tsc -p . type-checks the mod against them.
hooks/register.tsx 596 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register, SessionMeasureInput, SessionUsage, Timer } from "claude-code";
3
4import type {
5 ChefStationBackfill,
6 ChefStationCache,
7 ChefStationContext,
8 ChefStationContextAction,
9 ChefStationLedger,
10 ChefStationLive,
11 ChefStationName,
12} from "../types";
13import { cacheTtlFromTranscript } from "./cache";
14import { describeBackfill, MAX_READ_BYTES, SCAN_TIMEOUT_MS, type Tallied, toBackfill } from "./backfill";
15import { modelName, short } from "./format";
16import { backfillSince, composeLedger, dayKey, emptyLedger, isStale, recordTurn } from "./ledger";
17import { STATIONS, tray } from "./stations";
18import { addTranscript, createTally } from "./transcript.mjs";
19
20/**
21 * Each session stores its own turns under `turns:<session id>`, and the tray
22 * adds every session's up when it reads them, so two sessions never write
23 * the same key. `$.store` has no conditional write to make a shared key safe.
24 */
25const SESSION_KEY_PREFIX = "turns:";
26/** The last scan of the transcripts, which any session may replace whole. */
27const BACKFILL_KEY = "backfill";
28/** The single shared ledger of version 0.2.0, still read so its turns are not lost. */
29const LEGACY_LEDGER_KEY = "ledger";
30const STATION_KEY = "station";
31/** Countdowns and other sessions' spending move while this one is idle. */
32const TICK_MS = 60_000;
33/** The turn's stopwatch moves every second while a turn runs. */
34const TURN_TICK_MS = 1_000;
35/** The daily scan waits until the session has settled in. */
36const SCAN_DELAY_MS = 5_000;
37
38// Held by the host, so the tray survives a hot reload of this file.
39const station = atom({ plugin: "chef-station", key: "station" } as const, "usage" as ChefStationName);
40const ledger = atom({ plugin: "chef-station", key: "ledger" } as const, emptyLedger());
41const live = atom({ plugin: "chef-station", key: "live" } as const, null as ChefStationLive | null);
42const now = atom({ plugin: "chef-station", key: "now" } as const, 0);
43const recordedUsd = atom({ plugin: "chef-station", key: "recordedUsd" } as const, 0);
44const cache = atom({ plugin: "chef-station", key: "cache" } as const, {} as ChefStationCache);
45const contextBreakdown = atom({ plugin: "chef-station", key: "context" } as const, null as ChefStationContext | null);
46const contextAction = atom({ plugin: "chef-station", key: "contextAction" } as const, {
47 isRunning: false,
48 isConfirmingClear: false,
49} as ChefStationContextAction);
50const backfillStatus = atom({ plugin: "chef-station", key: "backfillStatus" } as const, { isRunning: false } as {
51 isRunning: boolean;
52 message?: string;
53});
54
55export const register: Register = (on, options) => {
56 const plan = typeof options.plan === "string" && options.plan.trim() !== "" ? options.plan.trim() : undefined;
57
58 // Turns that complete together (a subagent and its parent) are recorded one
59 // at a time, so neither reads the session cost the other is about to claim.
60 let recording: Promise<unknown> = Promise.resolve();
61 let turnTicker: Timer | undefined;
62
63 on("session.start", async ($, e, next) => {
64 const result = await next(e);
65 const usage = await $.session.usage();
66 const root = await $.session.root();
67 const model = modelName(await $.session.model());
68 const saved = await $.store.get(STATION_KEY);
69
70 await update($, live, () => ({
71 project: basename(root),
72 model,
73 outputTokens: 0,
74 usd: usage.cost?.usd ?? 0,
75 startedAt: usage.startedAt,
76 contextPercent: usage.context.percent,
77 rateLimits: [...usage.rateLimits],
78 }));
79 // Whatever the session cost before this load is already in the ledger, or predates the tray.
80 await update($, recordedUsd, () => usage.cost?.usd ?? 0);
81 await tick($);
82 await refreshContext($).catch(() => undefined);
83
84 if (isStation(saved)) {
85 await update($, station, () => saved);
86 }
87
88 await $.command.register({
89 name: "chef",
90 description: "Show a station of the chef tray, or rescan your Claude Code history with backfill.",
91 argumentHint: "[usage|trend|breakdown|activity|context|backfill]",
92 immediate: true,
93 });
94
95 $.clock.every(TICK_MS, () => {
96 tick($).catch(() => undefined);
97 });
98
99 // Fill in the history from Claude Code's transcripts once a day, in the background.
100 const stored = await read($, ledger);
101 const at = await $.clock.now();
102
103 if (!stored.backfill || dayKey(stored.backfill.scannedAt) !== dayKey(at)) {
104 $.clock.after(SCAN_DELAY_MS, () => {
105 runBackfill($).catch(() => undefined);
106 });
107 }
108
109 return result;
110 });
111
112 // Every model request is a step. A request reads or writes the prompt cache and restarts its
113 // lifetime from the moment it starts, so the main conversation's steps start the countdown.
114 on("turn.step", async function* ($, e, next) {
115 if (!e.agentId) {
116 const at = await $.clock.now();
117 await update($, cache, (before) => ({ ...before, lastRequestAt: at }));
118 }
119
120 return yield* next(e);
121 });
122
123 // The settings hooks' Stop fires as the main conversation's turn ends and names its transcript,
124 // whose latest cache write says whether the cache lives for five minutes or an hour.
125 on("classic.Stop", async ($, e, next) => {
126 const result = await next(e);
127 const ttl = cacheTtlFromTranscript(await readTranscriptTail($, e.transcript_path));
128
129 if (ttl) {
130 await update($, cache, (before) => ({ ...before, ttl }));
131 }
132
133 return result;
134 });
135
136 // A /clear raises no session.start: the conversation ends here, its cost starts over from nothing,
137 // and the new conversation has no cache yet.
138 on("session.end", async ($, e, next) => {
139 const result = await next(e);
140
141 if (e.reason === "clear") {
142 await update($, recordedUsd, () => 0);
143 // The breakdown on screen is the previous conversation's; the next turn measures the new one.
144 await update($, contextBreakdown, () => null);
145 await update($, cache, () => ({}));
146 }
147
148 return result;
149 });
150
151 // Raised for the main conversation's turns only; subagents' runs raise none.
152 on("turn.start", async ($, e, next) => {
153 const at = await $.clock.now();
154 // Read again each turn, so a /model switch shows before the turn completes.
155 const model = modelName(await $.session.model());
156 await update($, live, (before) => (before === null ? before : { ...before, model, turnStartedAt: at }));
157 await update($, now, () => at);
158
159 turnTicker?.cancel();
160 turnTicker = $.clock.every(TURN_TICK_MS, () => {
161 $.clock
162 .now()
163 .then((moment) => update($, now, () => moment))
164 .catch(() => undefined);
165 });
166
167 return next(e);
168 });
169
170 on("session.measure", async ($, e, next) => {
171 await applyMeasure($, e);
172 return next(e);
173 });
174
175 on("turn.complete", async ($, e, next) => {
176 const result = await next(e);
177
178 if (!e.agentId) {
179 turnTicker?.cancel();
180 turnTicker = undefined;
181 await update($, live, (before) =>
182 before === null ? before : { ...before, turnStartedAt: undefined, lastTurnMs: e.durationMs },
183 );
184 }
185
186 recording = recording.then(async () => {
187 const usage = await $.session.usage();
188 const totalUsd = usage.cost?.usd ?? 0;
189 const alreadyRecorded = await read($, recordedUsd);
190 // A total below what was recorded means the session's cost started over (a /clear): all of it is new.
191 const usd = totalUsd < alreadyRecorded ? totalUsd : totalUsd - alreadyRecorded;
192 const current = await read($, live);
193 const model = e.usage ? modelName(e.usage.model) : current?.model ?? "—";
194
195 if (usd === 0 && !e.usage) {
196 return;
197 }
198
199 const at = await $.clock.now();
200 const sessionId = await $.session.id();
201 const sessionKey = `${SESSION_KEY_PREFIX}${sessionId}`;
202 const own = asLedger(await $.store.get(sessionKey));
203 const recorded = recordTurn(own, {
204 at,
205 usd,
206 model,
207 project: current?.project ?? "unknown",
208 sessionId,
209 usage: e.usage && {
210 input: e.usage.input_tokens,
211 output: e.usage.output_tokens,
212 cacheRead: e.usage.cache_read_input_tokens,
213 cacheCreation: e.usage.cache_creation_input_tokens,
214 },
215 });
216
217 await $.store.set(sessionKey, recorded);
218 await refreshLedger($);
219 await update($, recordedUsd, () => totalUsd);
220 await update($, live, (before) => ({
221 ...(before ?? { project: "unknown", model, outputTokens: 0, usd: 0, startedAt: at, rateLimits: [] }),
222 // The "Now" row names the main conversation's model, not a subagent's.
223 model: e.agentId && before ? before.model : model,
224 outputTokens: (before?.outputTokens ?? 0) + (e.usage?.output_tokens ?? 0),
225 usd: totalUsd,
226 contextPercent: usage.context.percent,
227 rateLimits: [...usage.rateLimits],
228 }));
229 await update($, now, () => at);
230 });
231
232 await recording.catch(() => undefined);
233
234 // The window changes with every turn of the main conversation; a subagent has its own.
235 if (!e.agentId) {
236 await refreshContext($).catch(() => undefined);
237 }
238
239 return result;
240 });
241
242 on("command.run", { command: "chef" }, async ($, e) => {
243 const asked = e.args.trim().toLowerCase();
244
245 if (asked === "backfill") {
246 return { text: await runBackfill($) };
247 }
248
249 if (asked === "") {
250 const current = await read($, station);
251 const index = STATIONS.findIndex((entry) => entry.name === current);
252 const following = STATIONS[(index + 1) % STATIONS.length]!.name;
253 await select($, following);
254 return { text: `Chef tray: ${following}` };
255 }
256
257 if (!isStation(asked)) {
258 return {
259 text: `No station called "${asked}". Try ${STATIONS.map((entry) => entry.name).join(", ")}, or backfill to rescan your history.`,
260 };
261 }
262
263 await select($, asked);
264 return { text: `Chef tray: ${asked}` };
265 });
266
267 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
268 if (e.props.hasSurvey) {
269 return next(e);
270 }
271
272 const currentLive = await read($, live);
273 const currentLedger = await read($, ledger);
274 const currentNow = await read($, now);
275
276 // Nothing to show before the session has started.
277 if (currentLive === null) {
278 return next(e);
279 }
280
281 const ui = $.ui.resolve(e);
282
283 return tray({
284 ui,
285 station: await read($, station),
286 ledger: currentLedger,
287 live: currentLive,
288 now: currentNow || currentLive.startedAt,
289 plan,
290 backfillStatus: await read($, backfillStatus),
291 cache: await read($, cache),
292 context: await read($, contextBreakdown),
293 contextAction: await read($, contextAction),
294 onContextAction: (action) => void runContextAction($, action),
295 isWorking: e.props.isWorking,
296 columns: e.props.bodyColumns,
297 onSelect: (name) => void select($, name),
298 });
299 });
300};
301
302/**
303 * Scans Claude Code's transcripts for every session the tray did not record
304 * live and stores the result beside the live turns. Returns a line saying how
305 * it went, for `/chef backfill` and the activity station.
306 */
307async function runBackfill($: EngineInterface): Promise<string> {
308 const status = await read($, backfillStatus);
309
310 if (status.isRunning) {
311 return "Already reading your Claude Code history.";
312 }
313
314 await update($, backfillStatus, () => ({ isRunning: true, message: status.message }));
315 let message: string;
316
317 try {
318 const at = await $.clock.now();
319 const before = await loadLedger($);
320 const options = {
321 since: backfillSince(at),
322 excludeSessions: [...Object.keys(before.liveSessions ?? {}), await $.session.id()],
323 };
324 const projects = await projectsDirectory($);
325 const viaNode = await scanWithNode($, projects, options);
326 const backfill = toBackfill(viaNode ?? (await scanWithFs($, projects, options)), viaNode ? "node" : "fs", at);
327
328 await $.store.set(BACKFILL_KEY, backfill);
329 await refreshLedger($);
330 message = describeBackfill(backfill);
331 } catch (error) {
332 message = `Could not read your Claude Code history: ${error instanceof Error ? error.message : String(error)}`;
333 }
334
335 await update($, backfillStatus, () => ({ isRunning: false, message }));
336 return message;
337}
338
339type ScanOptions = { since: number; excludeSessions: string[] };
340
341async function projectsDirectory($: EngineInterface): Promise<string> {
342 const configured = await $.env.get("CLAUDE_CONFIG_DIR");
343
344 if (configured) {
345 return `${configured}/projects`;
346 }
347
348 const home = (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE")) ?? "";
349 return `${home}/.claude/projects`;
350}
351
352/** The Node helper streams transcripts of any size; a mod may read 4 MiB at most. */
353async function scanWithNode($: EngineInterface, projects: string, options: ScanOptions): Promise<Tallied | undefined> {
354 try {
355 const { exitCode, stdout, isStdoutTruncated } = await $.process.run(
356 ["node", `${$.plugin.root}/scripts/backfill.mjs`, "--projects", projects, "--since", String(options.since)],
357 { stdin: JSON.stringify(options.excludeSessions), timeoutMs: SCAN_TIMEOUT_MS },
358 );
359
360 if (exitCode !== 0 || isStdoutTruncated) {
361 return undefined;
362 }
363
364 return JSON.parse(stdout) as Tallied;
365 } catch {
366 // Node is not installed, or the scan could not finish: read the files directly instead.
367 return undefined;
368 }
369}
370
371async function scanWithFs($: EngineInterface, projects: string, options: ScanOptions): Promise<Tallied> {
372 const tally = createTally(options);
373 let files = 0;
374 let skippedFiles = 0;
375 let unreadableFiles = 0;
376
377 for (const file of await listTranscripts($, projects)) {
378 files += 1;
379
380 if (file.size > MAX_READ_BYTES) {
381 skippedFiles += 1;
382 continue;
383 }
384
385 let text: string;
386
387 try {
388 text = await $.fs.read(file.path);
389 } catch {
390 // Counted apart from the size-limited skips, so the backfill can say it is incomplete.
391 unreadableFiles += 1;
392 continue;
393 }
394
395 await addTranscript(text.split("\n"), tally);
396 }
397
398 return { ...tally.result(), files, skippedFiles, unreadableFiles };
399}
400
401/** Every .jsonl file under `directory`, subagent transcripts included. */
402async function listTranscripts($: EngineInterface, directory: string): Promise<{ path: string; size: number }[]> {
403 const entries = await $.fs.list(directory).catch(() => []);
404 const found: { path: string; size: number }[] = [];
405
406 for (const entry of entries) {
407 const path = `${directory}/${entry.name}`;
408
409 if (entry.kind === "dir") {
410 found.push(...(await listTranscripts($, path)));
411 } else if (entry.kind === "file" && entry.name.endsWith(".jsonl")) {
412 found.push({ path, size: entry.size });
413 }
414 }
415
416 return found;
417}
418
419/** How much of a transcript's end is enough to find the latest response that wrote the cache. */
420const TRANSCRIPT_TAIL_BYTES = 1024 * 1024;
421
422/**
423 * The end of a transcript: the whole file when the mod may read it (4 MiB at
424 * most), otherwise its last megabyte through `tail`. Empty when neither works,
425 * which leaves the lifetime as it was.
426 */
427async function readTranscriptTail($: EngineInterface, path: string): Promise<string> {
428 try {
429 const { size } = await $.fs.stat(path);
430
431 if (size <= MAX_READ_BYTES) {
432 return await $.fs.read(path);
433 }
434
435 const { exitCode, stdout } = await $.process.run(["tail", "-c", String(TRANSCRIPT_TAIL_BYTES), path]);
436 return exitCode === 0 ? stdout : "";
437 } catch {
438 return "";
439 }
440}
441
442/** Shows a station, remembers it for the next session, and measures the context when that station opens. */
443async function select($: EngineInterface, name: ChefStationName) {
444 await update($, station, () => name);
445 await $.store.set(STATION_KEY, name);
446
447 if (name === "context") {
448 await refreshContext($).catch(() => undefined);
449 }
450}
451
452/** What the Context station's Compact and Clear buttons, and Clear's confirmation, do. */
453async function runContextAction($: EngineInterface, action: "compact" | "clear" | "confirm-clear" | "cancel-clear") {
454 if (action === "clear") {
455 await update($, contextAction, (before) => ({ ...before, isConfirmingClear: true, message: undefined }));
456 return;
457 }
458
459 if (action === "cancel-clear") {
460 await update($, contextAction, (before) => ({ ...before, isConfirmingClear: false }));
461 return;
462 }
463
464 // Two quick presses can both arrive before the redraw hides the buttons. Claiming the
465 // running flag in one read-and-write lets only the first of them start the action.
466 let isClaimed = false;
467 await update($, contextAction, (before) => {
468 isClaimed = !before.isRunning;
469 return isClaimed ? { isRunning: true, isConfirmingClear: false } : before;
470 });
471
472 if (!isClaimed) {
473 return;
474 }
475
476 let message: string | undefined;
477
478 try {
479 if (action === "compact") {
480 const result = await $.session.compact();
481
482 if (result.skip !== undefined) {
483 message = `Compacting was skipped: ${result.skip}`;
484 } else if (result.tokensBefore !== undefined && result.tokensAfter !== undefined) {
485 message = `Compacted from ${short(result.tokensBefore)} to ${short(result.tokensAfter)} tokens.`;
486 } else {
487 message = "Compacted.";
488 }
489 } else {
490 // There is no call for this on $; the button runs /clear as if it were typed.
491 await $.command.run({ command: "clear" });
492 message = "Cleared. A new conversation has started.";
493 }
494 } catch (error) {
495 message = `Could not ${action === "compact" ? "compact" : "clear"}: ${error instanceof Error ? error.message : String(error)}`;
496 }
497
498 await update($, contextAction, () => ({ isRunning: false, isConfirmingClear: false, message }));
499 await refreshContext($).catch(() => undefined);
500}
501
502/**
503 * Measures the context window by category, as /context does. The `summary`
504 * level estimates locally and sends no token-count requests, so it is cheap
505 * enough to run after every turn.
506 */
507async function refreshContext($: EngineInterface) {
508 const { context } = await $.session.usage({ breakdown: "summary" });
509 const breakdown = context.breakdown;
510
511 if (!breakdown) {
512 return;
513 }
514
515 const measured: ChefStationContext = {
516 categories: breakdown.categories.map(({ name, tokens, kind }) => ({ name, tokens, kind })),
517 totalTokens: breakdown.totalTokens,
518 maxTokens: breakdown.rawMaxTokens,
519 };
520
521 await update($, contextBreakdown, () => measured);
522}
523
524/** Moves the tray's clock on and picks up what other sessions have recorded. */
525async function tick($: EngineInterface) {
526 const at = await $.clock.now();
527 await update($, now, () => at);
528 await refreshLedger($);
529}
530
531async function refreshLedger($: EngineInterface) {
532 const composed = await loadLedger($);
533 await update($, ledger, () => composed);
534}
535
536/**
537 * Every session's turns and the last scan, read from the store and added up.
538 * A session whose days have all fallen out of the thirteen weeks is deleted
539 * on the way, so the store does not grow without end.
540 */
541async function loadLedger($: EngineInterface): Promise<ChefStationLedger> {
542 const at = await $.clock.now();
543 const sessions: Record<string, ChefStationLedger> = {};
544
545 for (const key of await $.store.keys()) {
546 if (!key.startsWith(SESSION_KEY_PREFIX) && key !== LEGACY_LEDGER_KEY) {
547 continue;
548 }
549
550 const part = asLedger(await $.store.get(key));
551
552 if (isStale(part, at)) {
553 await $.store.delete(key);
554 } else {
555 sessions[key] = part;
556 }
557 }
558
559 const backfill = asBackfill(await $.store.get(BACKFILL_KEY)) ?? sessions[LEGACY_LEDGER_KEY]?.backfill;
560 return composeLedger(sessions, backfill, at);
561}
562
563async function applyMeasure($: EngineInterface, e: SessionMeasureInput | SessionUsage) {
564 await update($, live, (before) =>
565 before === null
566 ? before
567 : {
568 ...before,
569 usd: e.cost?.usd ?? before.usd,
570 contextPercent: e.context.percent ?? before.contextPercent,
571 rateLimits: [...e.rateLimits],
572 },
573 );
574}
575
576function asLedger(value: unknown): ChefStationLedger {
577 const isLedger =
578 typeof value === "object" && value !== null && (value as ChefStationLedger).version === 1 && typeof (value as ChefStationLedger).days === "object";
579
580 return isLedger ? (value as ChefStationLedger) : emptyLedger();
581}
582
583function asBackfill(value: unknown): ChefStationBackfill | undefined {
584 const isBackfill = typeof value === "object" && value !== null && typeof (value as ChefStationBackfill).days === "object";
585 return isBackfill ? (value as ChefStationBackfill) : undefined;
586}
587
588function isStation(value: unknown): value is ChefStationName {
589 return STATIONS.some((entry) => entry.name === value);
590}
591
592function basename(path: string): string {
593 const parts = path.split(/[\\/]/).filter(Boolean);
594 return parts[parts.length - 1] ?? path;
595}
596hooks/cache.ts 83 lines1import type { ChefStationCache, ChefStationCacheTtl } from "../types";
2import { countdown } from "./format";
3
4const TTL_MS: Record<ChefStationCacheTtl, number> = { "5m": 5 * 60_000, "1h": 60 * 60_000 };
5
6export type CacheStatus = { state: "warm"; remainingMs: number } | { state: "expired" } | { state: "none" };
7
8/**
9 * The lifetime of the main conversation's latest prompt-cache write, read from
10 * the end of its transcript: `1h` or `5m`, or undefined when no write shows.
11 *
12 * Responses that only read the cache are skipped, since a read keeps the
13 * lifetime its entry was written with. So are subagents' responses, whose
14 * cache is their own. A text cut from the middle of a file may begin with a
15 * partial line, which does not parse and is skipped too.
16 */
17export function cacheTtlFromTranscript(text: string): ChefStationCacheTtl | undefined {
18 const lines = text.split("\n");
19
20 for (let index = lines.length - 1; index >= 0; index--) {
21 const line = lines[index]!;
22
23 if (!line.includes('"usage"') || !line.includes('"assistant"')) {
24 continue;
25 }
26
27 let record;
28
29 try {
30 record = JSON.parse(line);
31 } catch {
32 continue;
33 }
34
35 const usage = record?.message?.usage;
36
37 if (record.type !== "assistant" || record.isSidechain === true || !usage) {
38 continue;
39 }
40
41 const byTtl = usage.cache_creation;
42
43 if ((byTtl?.ephemeral_1h_input_tokens ?? 0) > 0) {
44 return "1h";
45 }
46
47 if ((byTtl?.ephemeral_5m_input_tokens ?? 0) > 0) {
48 return "5m";
49 }
50
51 // Older responses report cache writes without the breakdown; those are five-minute writes.
52 if (!byTtl && (usage.cache_creation_input_tokens ?? 0) > 0) {
53 return "5m";
54 }
55 }
56
57 return undefined;
58}
59
60/**
61 * Whether the main conversation's prompt cache is likely still warm, and for
62 * how long. An entry lives for its lifetime from the start of the last request
63 * that read or wrote it; with no lifetime known, the shorter five minutes is
64 * assumed, so the tray never calls a cold cache warm.
65 */
66export function cacheStatus(cache: ChefStationCache, now: number): CacheStatus {
67 if (cache.lastRequestAt === undefined) {
68 return { state: "none" };
69 }
70
71 const remainingMs = cache.lastRequestAt + TTL_MS[cache.ttl ?? "5m"] - now;
72 return remainingMs > 0 ? { state: "warm", remainingMs } : { state: "expired" };
73}
74
75/** The Now row's words for the cache: an estimate while warm, so it says so with a tilde. */
76export function cacheLabel(status: CacheStatus): string | undefined {
77 if (status.state === "warm") {
78 return `cache warm · ${status.remainingMs < 60_000 ? "<1m" : `~${countdown(status.remainingMs)}`} left`;
79 }
80
81 return status.state === "expired" ? "cache expired" : undefined;
82}
83hooks/backfill.ts 66 lines1import type { ChefStationBackfill, ChefStationDay } from "../types";
2import { modelName, money } from "./format";
3
4/** The most a mod may read in one `$.fs.read`. */
5export const MAX_READ_BYTES = 4 * 1024 * 1024;
6/** A first scan of thirteen weeks of transcripts can take a while. */
7export const SCAN_TIMEOUT_MS = 10 * 60_000;
8
9/** What a scan tallied, from the Node helper or from the mod's own reads. */
10export type Tallied = {
11 days: Record<string, ChefStationDay>;
12 unpricedModels: string[];
13 files: number;
14 /** Transcripts over 4 MiB that the mod could not read itself. */
15 skippedFiles: number;
16 /** Transcripts whose read failed; absent from helpers older than 0.3.1. */
17 unreadableFiles?: number;
18};
19
20/** Turns a tally into the backfill the ledger keeps, with models named as people say them. */
21export function toBackfill(tallied: Tallied, source: ChefStationBackfill["source"], scannedAt: number): ChefStationBackfill {
22 return {
23 days: Object.fromEntries(Object.entries(tallied.days).map(([key, day]) => [key, withModelNames(day)])),
24 scannedAt,
25 source,
26 files: tallied.files,
27 skippedFiles: tallied.skippedFiles,
28 unreadableFiles: tallied.unreadableFiles ?? 0,
29 unpricedModels: tallied.unpricedModels,
30 };
31}
32
33/** A line saying what a scan found, for `/chef backfill` and the activity station. */
34export function describeBackfill(backfill: ChefStationBackfill): string {
35 const usd = Object.values(backfill.days).reduce((sum, day) => sum + day.usd, 0);
36 const files = `${backfill.files} ${backfill.files === 1 ? "transcript" : "transcripts"}`;
37 const notes = [
38 `Read ${files}${backfill.source === "node" ? " with Node" : ""}: ${money(usd)} across ${Object.keys(backfill.days).length} days.`,
39 ];
40
41 if (backfill.skippedFiles > 0) {
42 notes.push(`${backfill.skippedFiles} over 4 MiB were skipped; install Node to include them.`);
43 }
44
45 if ((backfill.unreadableFiles ?? 0) > 0) {
46 notes.push(`Incomplete: ${backfill.unreadableFiles} could not be read, so their usage is missing.`);
47 }
48
49 if (backfill.unpricedModels.length > 0) {
50 notes.push(`No price for ${backfill.unpricedModels.join(", ")}; their tokens count but their cost does not.`);
51 }
52
53 return notes.join(" ");
54}
55
56function withModelNames(day: ChefStationDay): ChefStationDay {
57 const models: Record<string, number> = {};
58
59 for (const [id, usd] of Object.entries(day.models)) {
60 const name = modelName(id);
61 models[name] = (models[name] ?? 0) + usd;
62 }
63
64 return { ...day, models };
65}
66hooks/format.ts 113 lines1const EIGHTHS = " ▁▂▃▄▅▆▇█";
2
3export function short(n: number): string {
4 const units: [number, string][] = [
5 [1e9, "B"],
6 [1e6, "M"],
7 [1e3, "k"],
8 ];
9
10 for (const [size, suffix] of units) {
11 if (n >= size) {
12 return `${+(n / size).toFixed(1)}${suffix}`;
13 }
14 }
15
16 return String(Math.round(n));
17}
18
19export function money(usd: number): string {
20 return `$${usd.toFixed(2)}`;
21}
22
23/**
24 * A model id as people say it: `claude-opus-5-5` is "Opus 5.5",
25 * `claude-3-5-sonnet-20241022` is "Sonnet 3.5". A context-window suffix such
26 * as `[1m]`, which `/model` shows, is dropped. Anything else is kept as given.
27 */
28export function modelName(model: string): string {
29 const id = model.replace(/\[[^\]]*\]$/, "");
30 const familyFirst = /^claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/.exec(id);
31
32 if (familyFirst) {
33 const [, family = "", major = "", minor] = familyFirst;
34 return `${capitalize(family)} ${minor ? `${major}.${minor}` : major}`;
35 }
36
37 const versionFirst = /^claude-(\d+)(?:-(\d{1,2}))?-(opus|sonnet|haiku)(?:-\d{8})?$/.exec(id);
38
39 if (versionFirst) {
40 const [, major = "", minor, family = ""] = versionFirst;
41 return `${capitalize(family)} ${minor ? `${major}.${minor}` : major}`;
42 }
43
44 return model;
45}
46
47function capitalize(word: string): string {
48 return word.charAt(0).toUpperCase() + word.slice(1);
49}
50
51/** Time until a reset: "1d 23h", "2h 10m", "9m". */
52export function countdown(ms: number): string {
53 if (ms <= 0) {
54 return "now";
55 }
56
57 const minutes = Math.floor(ms / 60_000);
58 const hours = Math.floor(minutes / 60);
59 const days = Math.floor(hours / 24);
60
61 if (days > 0) {
62 return `${days}d ${hours % 24}h`;
63 }
64
65 if (hours > 0) {
66 return `${hours}h ${minutes % 60}m`;
67 }
68
69 return `${minutes}m`;
70}
71
72/** A stopwatch reading: "0:33", "12:05", and past an hour "1:02:03". */
73export function elapsed(ms: number): string {
74 const seconds = Math.max(0, Math.floor(ms / 1_000));
75 const minutes = Math.floor(seconds / 60);
76 const hours = Math.floor(minutes / 60);
77 const pad = (value: number) => String(value).padStart(2, "0");
78
79 if (hours > 0) {
80 return `${hours}:${pad(minutes % 60)}:${pad(seconds % 60)}`;
81 }
82
83 return `${minutes}:${pad(seconds % 60)}`;
84}
85
86/** A horizontal meter `width` cells wide, split so each part can take its own color. */
87export function bar(percent: number, width: number): { filled: string; empty: string } {
88 const cells = Math.round((Math.min(Math.max(percent, 0), 100) / 100) * width);
89 return { filled: "█".repeat(cells), empty: "░".repeat(width - cells) };
90}
91
92/**
93 * Columns of eighth blocks, `rows` tall, one character per value, returned top
94 * row first. The largest value fills the height; any value above zero shows
95 * at least one eighth so a quiet hour is still visible.
96 */
97export function verticalBars(values: number[], rows: number): string[] {
98 const top = Math.max(...values, 0);
99 const levels = rows * 8;
100 const heights = values.map((value) => {
101 if (value <= 0 || top === 0) {
102 return 0;
103 }
104
105 return Math.max(1, Math.round((value / top) * levels));
106 });
107
108 return Array.from({ length: rows }, (_, rowFromTop) => {
109 const floor = (rows - 1 - rowFromTop) * 8;
110 return heights.map((height) => EIGHTHS[Math.min(Math.max(height - floor, 0), 8)]).join("");
111 });
112}
113hooks/ledger.ts 233 lines1import type { ChefStationBackfill, ChefStationDay, ChefStationLedger } from "../types";
2import { dayKey } from "./transcript.mjs";
3
4export { dayKey };
5
6/** How far back the ledger remembers: the thirteen weeks the activity grid shows. */
7export const WEEKS = 13;
8
9export type TurnUsage = {
10 input: number;
11 output: number;
12 cacheRead: number;
13 cacheCreation: number;
14};
15
16export type TurnRecord = {
17 /** When the turn completed, in milliseconds since the epoch. */
18 at: number;
19 /** What the turn cost, in US dollars. */
20 usd: number;
21 model: string;
22 project: string;
23 /** The session the turn ran in, so a scan of the transcripts does not count it again. */
24 sessionId: string;
25 usage?: TurnUsage;
26};
27
28export type Ranked = { name: string; usd: number };
29
30export type Activity = {
31 /** Thirteen columns of Monday to Sunday; `null` for a day still ahead. */
32 weeks: (number | null)[][];
33 totalTokens: number;
34 activeDays: number;
35 busiest?: { key: string; tokens: number };
36 /** Consecutive active days up to today, or up to yesterday before today's first turn. */
37 streak: number;
38};
39
40export function emptyLedger(): ChefStationLedger {
41 return { version: 1, days: {} };
42}
43
44function emptyDay(): ChefStationDay {
45 return {
46 usd: 0,
47 tokens: 0,
48 cacheReadTokens: 0,
49 inputTokens: 0,
50 hours: new Array(24).fill(0),
51 models: {},
52 projects: {},
53 };
54}
55
56/** Midnight, local time, `days` days before the day of `ms`. */
57function startOfDay(ms: number, daysBack = 0): number {
58 const date = new Date(ms);
59 return new Date(date.getFullYear(), date.getMonth(), date.getDate() - daysBack).getTime();
60}
61
62/**
63 * Adds one completed turn to the ledger and drops the days that have fallen
64 * out of the thirteen weeks. Returns a new ledger; the one passed in is left as it was.
65 */
66export function recordTurn(ledger: ChefStationLedger, turn: TurnRecord): ChefStationLedger {
67 const key = dayKey(turn.at);
68 const before = ledger.days[key] ?? emptyDay();
69 const usage = turn.usage ?? { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
70 const inputTokens = usage.input + usage.cacheRead + usage.cacheCreation;
71 const hour = new Date(turn.at).getHours();
72 const hours = before.hours.map((usd, index) => (index === hour ? usd + turn.usd : usd));
73
74 const day: ChefStationDay = {
75 usd: before.usd + turn.usd,
76 tokens: before.tokens + inputTokens + usage.output,
77 cacheReadTokens: before.cacheReadTokens + usage.cacheRead,
78 inputTokens: before.inputTokens + inputTokens,
79 hours,
80 models: addTo(before.models, turn.model, turn.usd),
81 projects: addTo(before.projects, turn.project, turn.usd),
82 };
83
84 return prune(
85 {
86 ...ledger,
87 days: { ...ledger.days, [key]: day },
88 liveSessions: { ...ledger.liveSessions, [turn.sessionId]: key },
89 },
90 turn.at,
91 );
92}
93
94/** Puts a fresh scan of the transcripts in place of the last one. */
95export function withBackfill(ledger: ChefStationLedger, backfill: ChefStationBackfill): ChefStationLedger {
96 return prune({ ...ledger, backfill }, backfill.scannedAt);
97}
98
99/** The earliest moment a scan needs to read: the first day the ledger keeps. */
100export function backfillSince(now: number): number {
101 return startOfDay(now, WEEKS * 7 - 1);
102}
103
104function addTo(record: Record<string, number>, name: string, usd: number): Record<string, number> {
105 return { ...record, [name]: (record[name] ?? 0) + usd };
106}
107
108function prune(ledger: ChefStationLedger, now: number): ChefStationLedger {
109 const oldest = dayKey(backfillSince(now));
110 const isKept = ([key]: [string, unknown]) => key >= oldest;
111 const keepDays = (days: Record<string, ChefStationDay>) => Object.fromEntries(Object.entries(days).filter(isKept));
112 // Sessions are keyed by id with their last day as the value.
113 const liveSessions = Object.fromEntries(Object.entries(ledger.liveSessions ?? {}).filter(([, key]) => key >= oldest));
114
115 return {
116 version: 1,
117 days: keepDays(ledger.days),
118 liveSessions,
119 ...(ledger.backfill ? { backfill: { ...ledger.backfill, days: keepDays(ledger.backfill.days) } } : {}),
120 };
121}
122
123/** One day as the tray shows it: what was recorded live plus what the last scan found. */
124function dayOf(ledger: ChefStationLedger, key: string): ChefStationDay | undefined {
125 const live = ledger.days[key];
126 const scanned = ledger.backfill?.days[key];
127
128 return live && scanned ? addDays(live, scanned) : (live ?? scanned);
129}
130
131function addDays(first: ChefStationDay, second: ChefStationDay): ChefStationDay {
132 return {
133 usd: first.usd + second.usd,
134 tokens: first.tokens + second.tokens,
135 cacheReadTokens: first.cacheReadTokens + second.cacheReadTokens,
136 inputTokens: first.inputTokens + second.inputTokens,
137 hours: first.hours.map((usd, hour) => usd + (second.hours[hour] ?? 0)),
138 models: sumRecords(first.models, second.models),
139 projects: sumRecords(first.projects, second.projects),
140 };
141}
142
143/**
144 * The ledger the tray reads, put together from the turns each session stored
145 * under a key of its own and the last scan of the transcripts. Each session
146 * writing only its own key keeps two sessions from overwriting each other.
147 */
148export function composeLedger(
149 sessions: Record<string, ChefStationLedger>,
150 backfill: ChefStationBackfill | undefined,
151 now: number,
152): ChefStationLedger {
153 const days: Record<string, ChefStationDay> = {};
154 const liveSessions: Record<string, string> = {};
155
156 for (const part of Object.values(sessions)) {
157 for (const [key, day] of Object.entries(part.days)) {
158 const before = days[key];
159 days[key] = before ? addDays(before, day) : day;
160 }
161
162 Object.assign(liveSessions, part.liveSessions);
163 }
164
165 return prune({ version: 1, days, liveSessions, ...(backfill ? { backfill } : {}) }, now);
166}
167
168/** Whether every day a session stored has fallen out of the thirteen weeks. */
169export function isStale(ledger: ChefStationLedger, now: number): boolean {
170 return Object.keys(prune(ledger, now).days).length === 0;
171}
172
173function sumRecords(first: Record<string, number>, second: Record<string, number>): Record<string, number> {
174 return Object.entries(second).reduce((sum, [name, usd]) => addTo(sum, name, usd), first);
175}
176
177export function today(ledger: ChefStationLedger, now: number): ChefStationDay {
178 return dayOf(ledger, dayKey(now)) ?? emptyDay();
179}
180
181export function hourly(ledger: ChefStationLedger, now: number): number[] {
182 return today(ledger, now).hours;
183}
184
185/** Entries sorted by cost, most expensive first, at most `limit` of them. */
186export function ranked(record: Record<string, number>, limit = Infinity): Ranked[] {
187 return Object.entries(record)
188 .map(([name, usd]) => ({ name, usd }))
189 .sort((a, b) => b.usd - a.usd)
190 .slice(0, limit);
191}
192
193export function activity(ledger: ChefStationLedger, now: number): Activity {
194 // getDay() counts from Sunday; the grid counts from Monday.
195 const weekday = (new Date(now).getDay() + 6) % 7;
196 const daysShown = (WEEKS - 1) * 7 + weekday + 1;
197 const weeks: (number | null)[][] = Array.from({ length: WEEKS }, () => new Array(7).fill(null));
198
199 let totalTokens = 0;
200 let activeDays = 0;
201 let busiest: Activity["busiest"];
202
203 for (let index = 0; index < daysShown; index++) {
204 const key = dayKey(startOfDay(now, daysShown - 1 - index));
205 const tokens = dayOf(ledger, key)?.tokens ?? 0;
206 weeks[Math.floor(index / 7)]![index % 7] = tokens;
207
208 if (tokens > 0) {
209 totalTokens += tokens;
210 activeDays += 1;
211
212 if (busiest === undefined || tokens > busiest.tokens) {
213 busiest = { key, tokens };
214 }
215 }
216 }
217
218 return { weeks, totalTokens, activeDays, busiest, streak: streak(ledger, now) };
219}
220
221function streak(ledger: ChefStationLedger, now: number): number {
222 const isActive = (daysBack: number) => (dayOf(ledger, dayKey(startOfDay(now, daysBack)))?.tokens ?? 0) > 0;
223 let daysBack = isActive(0) ? 0 : 1;
224 let count = 0;
225
226 while (daysBack <= WEEKS * 7 && isActive(daysBack)) {
227 count += 1;
228 daysBack += 1;
229 }
230
231 return count;
232}
233hooks/stations.tsx 457 lines1import type { Elements, RenderChildren } from "claude-code";
2
3import type { ChefStationCache, ChefStationContext, ChefStationContextAction, ChefStationLedger, ChefStationLive, ChefStationName } from "../types";
4import { cacheLabel, cacheStatus } from "./cache";
5import { allocateCells, contextSegments } from "./context";
6import { bar, countdown, elapsed, money, short, verticalBars } from "./format";
7import { activity, hourly, ranked, today } from "./ledger";
8
9/** The elements a station draws with; the terminal's and the desktop's tables both have them. */
10export type Ui = Pick<Elements["terminal"], "Box" | "Text" | "Button">;
11
12export type TrayView = {
13 ui: Ui;
14 station: ChefStationName;
15 ledger: ChefStationLedger;
16 live?: ChefStationLive;
17 now: number;
18 plan?: string;
19 /** Whether a scan of the transcripts is running, and what the last one said. */
20 backfillStatus?: { isRunning: boolean; message?: string };
21 /** The main conversation's prompt cache: when it was last used, and how long its entries live. */
22 cache?: ChefStationCache;
23 /** The context window by category; null until the first measurement. */
24 context?: ChefStationContext | null;
25 /** Whether Clear or Compact is running, whether Clear awaits confirmation, and what the last one said. */
26 contextAction?: ChefStationContextAction;
27 onContextAction?: (action: "compact" | "clear" | "confirm-clear" | "cancel-clear") => void;
28 isWorking: boolean;
29 columns: number;
30 onSelect: (station: ChefStationName) => void;
31};
32
33export const STATIONS: { name: ChefStationName; label: string; hotkey: string }[] = [
34 { name: "usage", label: "Usage", hotkey: "1" },
35 { name: "trend", label: "Trend", hotkey: "2" },
36 { name: "breakdown", label: "Breakdown", hotkey: "3" },
37 { name: "activity", label: "Activity", hotkey: "4" },
38 { name: "context", label: "Context", hotkey: "5" },
39];
40
41const RATE_LIMIT_LABELS: Record<string, string> = {
42 five_hour: "Session",
43 seven_day: "Week",
44 seven_day_opus: "Week Opus",
45 seven_day_sonnet: "Week Sonnet",
46 spend_limit: "Spend",
47};
48
49const LABEL_WIDTH = 10;
50const ACCENT = "claude";
51const MUTED = "subtle";
52
53export function tray(view: TrayView) {
54 const { Box } = view.ui;
55
56 return (
57 <Box flexDirection="column" paddingX={1}>
58 {header(view)}
59 {station(view)}
60 </Box>
61 );
62}
63
64function header({ ui, station, plan, onSelect }: TrayView) {
65 const { Box, Text, Button } = ui;
66
67 return (
68 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
69 <Text color={ACCENT} bold>
70 ✻ Claude
71 </Text>
72 {plan ? <Text color={ACCENT}>{plan}</Text> : null}
73 <Text dimColor>·</Text>
74 {STATIONS.map((entry) => (
75 <Button
76 key={`station-${entry.name}`}
77 label={entry.label}
78 hotkey={entry.hotkey}
79 variant={entry.name === station ? "primary" : "secondary"}
80 onPress={() => onSelect(entry.name)}
81 />
82 ))}
83 </Box>
84 );
85}
86
87/** The body of the station the tray shows. */
88function station(view: TrayView) {
89 switch (view.station) {
90 case "trend":
91 return trend(view);
92 case "breakdown":
93 return breakdown(view);
94 case "activity":
95 return activityGrid(view);
96 case "context":
97 return contextWindow(view);
98 default:
99 return usage(view);
100 }
101}
102
103function row(ui: Ui, label: string, ...children: RenderChildren[]) {
104 const { Box, Text } = ui;
105
106 return (
107 <Box flexDirection="row">
108 <Box width={LABEL_WIDTH} flexShrink={0}>
109 <Text dimColor>{label}</Text>
110 </Box>
111 <Box flexDirection="row" flexShrink={1}>
112 {children}
113 </Box>
114 </Box>
115 );
116}
117
118/**
119 * The Usage station: the plan's rate-limit windows, today's spend, and the
120 * Now row for this session, its prompt cache countdown included.
121 */
122function usage({ ui, ledger, live, now, isWorking, columns, cache }: TrayView) {
123 const { Box, Text } = ui;
124 const day = today(ledger, now);
125 const cachePercent = day.inputTokens > 0 ? Math.round((day.cacheReadTokens / day.inputTokens) * 100) : 0;
126 const meterWidth = Math.min(Math.max(columns - LABEL_WIDTH - 20, 10), 40);
127
128 const limits = (live?.rateLimits ?? []).map((limit) => {
129 const meter = bar(limit.percentUsed, meterWidth);
130 const color = limit.percentUsed >= 90 ? "error" : limit.percentUsed >= 75 ? "warning" : ACCENT;
131 const resetsIn = limit.resetsAt ? Date.parse(limit.resetsAt) - now : undefined;
132
133 return row(
134 ui,
135 RATE_LIMIT_LABELS[limit.kind] ?? limit.kind,
136 <Text color={color}>{meter.filled}</Text>,
137 <Text color={MUTED}>{meter.empty}</Text>,
138 <Text bold>{` ${Math.round(limit.percentUsed)}%`.padStart(5)}</Text>,
139 resetsIn === undefined || Number.isNaN(resetsIn) ? null : <Text dimColor>{` ↻ ${countdown(resetsIn)}`}</Text>,
140 );
141 });
142
143 const todayRow = row(
144 ui,
145 "Today",
146 <Text bold>{money(day.usd)}</Text>,
147 <Text dimColor>{` API value · ${short(day.tokens)} tokens · ${cachePercent}% from cache`}</Text>,
148 );
149
150 // The running turn's stopwatch, or how long the last turn took once it is done.
151 const turnTime =
152 live?.turnStartedAt !== undefined ? (
153 <Text color={ACCENT} bold>{` ${elapsed(now - live.turnStartedAt)}`}</Text>
154 ) : live?.lastTurnMs !== undefined ? (
155 <Text dimColor>{` last turn ${elapsed(live.lastTurnMs)}`}</Text>
156 ) : null;
157
158 const cacheText = cacheLabel(cacheStatus(cache ?? {}, now));
159
160 const nowRow = live
161 ? row(
162 ui,
163 "Now",
164 <Text color={isWorking ? ACCENT : MUTED}>● </Text>,
165 <Text bold wrap="truncate-end">
166 {live.project}
167 </Text>,
168 <Text dimColor wrap="truncate-end">
169 {[
170 ` · ${live.model}`,
171 ` · ${short(live.outputTokens)} written`,
172 ` · ${money(live.usd)}`,
173 live.contextPercent === undefined ? "" : ` · context ${Math.round(live.contextPercent)}%`,
174 ].join("")}
175 </Text>,
176 cacheText ? <Text dimColor>{` · ${cacheText}`}</Text> : null,
177 turnTime,
178 )
179 : null;
180
181 return (
182 <Box flexDirection="column" marginTop={1}>
183 {limits}
184 {todayRow}
185 {nowRow}
186 </Box>
187 );
188}
189
190function trend({ ui, ledger, now, columns }: TrayView) {
191 const { Box, Text } = ui;
192 const hours = hourly(ledger, now);
193 const total = hours.reduce((sum, usd) => sum + usd, 0);
194 // Two cells and a gap per hour when there is room, then one and a gap, then one.
195 const barWidth = columns >= 75 ? 2 : 1;
196 const gap = columns >= 50 ? 1 : 0;
197 const rows = verticalBars(hours, 4);
198 const currentHour = new Date(now).getHours();
199 const step = barWidth + gap;
200
201 const barRows = rows.map((line, rowIndex) => {
202 const isBaseline = rowIndex === rows.length - 1;
203
204 return (
205 <Box flexDirection="row">
206 {[...line].map((glyph, hour) => {
207 const isEmptyBaseline = isBaseline && glyph === " " && hour <= currentHour;
208 const cell = (isEmptyBaseline ? "▁" : glyph).repeat(barWidth) + " ".repeat(gap);
209
210 return <Text color={isEmptyBaseline ? MUTED : ACCENT}>{cell}</Text>;
211 })}
212 </Box>
213 );
214 });
215
216 const axisWidth = 24 * step;
217 const axis = "00".padEnd(12 * step) + "12".padEnd(axisWidth - 12 * step - 2) + "23";
218
219 return (
220 <Box flexDirection="column" marginTop={1}>
221 <Box flexDirection="row" width={axisWidth} justifyContent="space-between">
222 <Text bold>Spend by hour</Text>
223 <Text dimColor>{`Today · ${money(total)}`}</Text>
224 </Box>
225 {barRows}
226 <Text dimColor>{axis}</Text>
227 </Box>
228 );
229}
230
231function breakdown({ ui, ledger, now, columns }: TrayView) {
232 const { Box, Text } = ui;
233 const day = today(ledger, now);
234 const isSideBySide = columns >= 90;
235 const listWidth = isSideBySide ? Math.floor((columns - 2) / 2) : columns;
236
237 const list = (title: string, record: Record<string, number>) => {
238 const entries = ranked(record, 5);
239 const top = entries[0]?.usd ?? 0;
240 const meterWidth = Math.min(16, Math.max(6, listWidth - 34));
241
242 return (
243 <Box flexDirection="column" width={listWidth}>
244 <Text bold>{title}</Text>
245 {entries.length === 0 ? <Text dimColor>Nothing cooked yet today</Text> : null}
246 {entries.map((entry) => {
247 const meter = bar(top > 0 ? (entry.usd / top) * 100 : 0, meterWidth);
248
249 return (
250 <Box flexDirection="row" columnGap={1}>
251 <Box width={20} flexShrink={1}>
252 <Text wrap="truncate-middle">{entry.name}</Text>
253 </Box>
254 <Text color={ACCENT}>{meter.filled}</Text>
255 <Text>{meter.empty.replaceAll("░", " ")}</Text>
256 <Text dimColor>{money(entry.usd).padStart(8)}</Text>
257 </Box>
258 );
259 })}
260 </Box>
261 );
262 };
263
264 return (
265 <Box flexDirection={isSideBySide ? "row" : "column"} columnGap={2} rowGap={1} marginTop={1}>
266 {list("Models", day.models)}
267 {list("Projects", day.projects)}
268 </Box>
269 );
270}
271
272const SHADES = ["░░", "▒▒", "▓▓", "██"];
273const WEEKDAYS = ["Mon", "", "Wed", "", "Fri", "", "Sun"];
274
275function activityGrid({ ui, ledger, now, columns, backfillStatus }: TrayView) {
276 const { Box, Text } = ui;
277 const grid = activity(ledger, now);
278 const top = Math.max(...grid.weeks.flat().map((tokens) => tokens ?? 0), 0);
279
280 const cell = (tokens: number | null) => {
281 if (tokens === null) {
282 return <Text>{" "}</Text>;
283 }
284
285 if (tokens === 0 || top === 0) {
286 return <Text color={MUTED}>{"· "}</Text>;
287 }
288
289 const level = Math.min(SHADES.length - 1, Math.ceil((tokens / top) * SHADES.length) - 1);
290 return <Text color={ACCENT}>{SHADES[level]}</Text>;
291 };
292
293 const rows = WEEKDAYS.map((weekday, dayIndex) => (
294 <Box flexDirection="row">
295 <Box width={4}>
296 <Text dimColor>{weekday}</Text>
297 </Box>
298 {grid.weeks.map((week) => cell(week[dayIndex] ?? null))}
299 </Box>
300 ));
301
302 const busiest = grid.busiest
303 ? `${new Date(`${grid.busiest.key}T12:00:00`).toLocaleDateString("en-US", { day: "2-digit", month: "short" })} · ${short(grid.busiest.tokens)}`
304 : "—";
305
306 const stats = (
307 <Box flexDirection="column" marginLeft={columns >= 60 ? 3 : 0}>
308 <Text bold>{`${short(grid.totalTokens)} tokens`}</Text>
309 <Text dimColor>{`${grid.weeks.length} weeks`}</Text>
310 <Text> </Text>
311 <Text>
312 <Text dimColor>Active days </Text>
313 {String(grid.activeDays)}
314 </Text>
315 <Text>
316 <Text dimColor>Busiest day </Text>
317 {busiest}
318 </Text>
319 <Text>
320 <Text dimColor>Streak </Text>
321 <Text color={ACCENT}>{`${grid.streak} ${grid.streak === 1 ? "day" : "days"}`}</Text>
322 {today(ledger, now).tokens > 0 ? "" : " (cook today to keep it)"}
323 </Text>
324 </Box>
325 );
326
327 const history = backfillStatus?.isRunning
328 ? "Reading your Claude Code history…"
329 : ledger.backfill
330 ? `History from ${ledger.backfill.files} transcripts, estimated at list prices. /chef backfill rescans it.`
331 : "Only turns since the tray was installed. /chef backfill reads your earlier history.";
332
333 return (
334 <Box flexDirection="column" marginTop={1}>
335 <Box flexDirection={columns >= 60 ? "row" : "column"}>
336 <Box flexDirection="column">{rows}</Box>
337 {stats}
338 </Box>
339 <Text dimColor wrap="wrap">
340 {history}
341 </Text>
342 </Box>
343 );
344}
345
346/** Each legend entry's width, so entries line up in columns as they wrap. */
347const LEGEND_ENTRY_WIDTH = 36;
348
349/**
350 * The Context station: the window as one bar of categories, a legend that
351 * names each color with its tokens and share, and the Compact and Clear buttons.
352 */
353function contextWindow({ ui, context, columns, isWorking, contextAction, onContextAction }: TrayView) {
354 const { Box, Text } = ui;
355
356 if (!context) {
357 return (
358 <Box marginTop={1}>
359 <Text dimColor>Measuring the context window…</Text>
360 </Box>
361 );
362 }
363
364 const segments = contextSegments(context.categories);
365 // A one-cell gap separates segments, so a boundary shows without relying on color.
366 const gaps = Math.max(0, segments.length - 1);
367 const cells = allocateCells(
368 segments.map((segment) => segment.tokens),
369 Math.max(10, columns - 2 - gaps),
370 );
371 const share = (tokens: number) => {
372 const percent = (tokens / context.maxTokens) * 100;
373 return percent > 0 && percent < 1 ? "<1%" : `${Math.round(percent)}%`;
374 };
375
376 return (
377 <Box flexDirection="column" marginTop={1}>
378 <Box flexDirection="row" columnGap={1}>
379 <Text bold>Context window</Text>
380 <Text dimColor>{`${short(context.totalTokens)} of ${short(context.maxTokens)} tokens (${share(context.totalTokens)})`}</Text>
381 </Box>
382 <Box flexDirection="row">
383 {segments.map((segment, index) => (
384 <Text color={segment.color}>{(index > 0 ? " " : "") + segment.glyph.repeat(cells[index] ?? 0)}</Text>
385 ))}
386 </Box>
387 {/* Identity is never color alone: every color in the bar is named here, in the text color. */}
388 <Box flexDirection="row" flexWrap="wrap" marginTop={1}>
389 {segments.map((segment) => (
390 <Box flexDirection="row" width={LEGEND_ENTRY_WIDTH} columnGap={1}>
391 <Text color={segment.color}>{segment.glyph.repeat(2)}</Text>
392 <Box width={20} flexShrink={1}>
393 <Text wrap="truncate-end">{segment.name}</Text>
394 </Box>
395 <Text dimColor>{`${short(segment.tokens)} · ${share(segment.tokens)}`}</Text>
396 </Box>
397 ))}
398 </Box>
399 {contextActions(ui, isWorking, contextAction, onContextAction)}
400 </Box>
401 );
402}
403
404/**
405 * Compact and Clear. Neither can run during a turn (compacting is refused,
406 * and a /clear would wait for the turn to end), so while Claude works the
407 * buttons give way to a note. Clear ends the conversation, so it asks first.
408 */
409function contextActions(
410 ui: Ui,
411 isWorking: boolean,
412 action: ChefStationContextAction | undefined,
413 onAction: TrayView["onContextAction"],
414) {
415 const { Box, Text, Button } = ui;
416 const message = action?.message ? <Text dimColor>{action.message}</Text> : null;
417
418 if (action?.isRunning) {
419 return (
420 <Box marginTop={1}>
421 <Text dimColor>Working on it…</Text>
422 </Box>
423 );
424 }
425
426 if (isWorking) {
427 return (
428 <Box flexDirection="column" marginTop={1}>
429 <Text dimColor>Clear and Compact are available once Claude finishes.</Text>
430 {message}
431 </Box>
432 );
433 }
434
435 if (action?.isConfirmingClear) {
436 return (
437 <Box flexDirection="column" marginTop={1}>
438 <Text>Clear the conversation? It ends here and a new one starts with an empty context.</Text>
439 <Box flexDirection="row" columnGap={1}>
440 <Button key="context-clear-confirm" label="Clear conversation" hotkey="y" variant="primary" onPress={() => onAction?.("confirm-clear")} />
441 <Button key="context-clear-cancel" label="Cancel" hotkey="n" onPress={() => onAction?.("cancel-clear")} />
442 </Box>
443 </Box>
444 );
445 }
446
447 return (
448 <Box flexDirection="column" marginTop={1}>
449 <Box flexDirection="row" columnGap={1}>
450 <Button key="context-compact" label="Compact" hotkey="c" onPress={() => onAction?.("compact")} />
451 <Button key="context-clear" label="Clear" hotkey="x" onPress={() => onAction?.("clear")} />
452 </Box>
453 {message}
454 </Box>
455 );
456}
457hooks/transcript.mjs 222 lines1// Reads Claude Code's session transcripts (~/.claude/projects/**/*.jsonl) and
2// tallies what each day cost. Plain JavaScript so the mod and the Node helper
3// in scripts/backfill.mjs share one implementation.
4
5/**
6 * @typedef {{ input: number, output: number, cacheRead: number, cacheWrite5m: number, cacheWrite1h: number }} EntryUsage
7 * @typedef {{ id: string, at: number, sessionId: string, project: string, model: string, isFast: boolean, usage: EntryUsage }} Entry
8 * @typedef {{ usd: number, tokens: number, cacheReadTokens: number, inputTokens: number, hours: number[], models: Record<string, number>, projects: Record<string, number> }} Day
9 * @typedef {{ days: Record<string, Day>, unpricedModels: string[], responses: number }} TallyResult
10 */
11
12/**
13 * US dollars per million tokens, first-party API list prices. Cache writes
14 * cost 1.25x input for the five-minute TTL and 2x input for the one-hour TTL.
15 * The first pattern that matches a model id wins, so specific ids come first.
16 */
17const PRICES = [
18 { pattern: /^claude-(fable|mythos)-5-1/, input: 10, output: 50, cacheRead: 0.25 },
19 { pattern: /^claude-(fable|mythos)-5/, input: 10, output: 50, cacheRead: 1 },
20 { pattern: /^claude-opus-5-5/, input: 4, output: 20, cacheRead: 0.2 },
21 { pattern: /^claude-opus-(5|4-8|4-7|4-6|4-5)/, input: 5, output: 25, cacheRead: 0.5 },
22 { pattern: /^claude-opus-4|^claude-3-opus/, input: 15, output: 75, cacheRead: 1.5 },
23 { pattern: /^claude-sonnet-5/, input: 2, output: 10, cacheRead: 0.2 },
24 { pattern: /^claude-sonnet-4|^claude-3-[57]-sonnet/, input: 3, output: 15, cacheRead: 0.3 },
25 { pattern: /^claude-haiku-4-5/, input: 1, output: 5, cacheRead: 0.1 },
26 { pattern: /^claude-3-5-haiku/, input: 0.8, output: 4, cacheRead: 0.08 },
27];
28
29/** Fast mode runs the same model at twice the price. */
30const FAST_MULTIPLIER = 2;
31
32/**
33 * One transcript line as a priced response, or undefined for every other line.
34 * Claude Code writes a response once per content block, so the same `id` can
35 * appear on several lines; the tally counts it once.
36 *
37 * @param {string} line
38 * @returns {Entry | undefined}
39 */
40export function readLine(line) {
41 // Most lines are prompts, tool results and content; skip them before parsing.
42 if (!line.includes('"usage"') || !line.includes('"assistant"')) {
43 return undefined;
44 }
45
46 let record;
47
48 try {
49 record = JSON.parse(line);
50 } catch {
51 return undefined;
52 }
53
54 const message = record?.message;
55 const usage = message?.usage;
56
57 if (record.type !== "assistant" || !usage || typeof message.model !== "string" || message.model === "<synthetic>") {
58 return undefined;
59 }
60
61 const at = Date.parse(record.timestamp);
62
63 if (Number.isNaN(at)) {
64 return undefined;
65 }
66
67 const cacheWrite = usage.cache_creation_input_tokens ?? 0;
68 const byTtl = usage.cache_creation;
69 const cacheWrite1h = byTtl?.ephemeral_1h_input_tokens ?? 0;
70 const cacheWrite5m = byTtl ? (byTtl.ephemeral_5m_input_tokens ?? 0) : cacheWrite;
71
72 return {
73 id: `${message.id}:${record.requestId}`,
74 at,
75 sessionId: String(record.sessionId ?? ""),
76 project: basename(String(record.cwd ?? "")) || "unknown",
77 model: message.model,
78 isFast: usage.speed === "fast",
79 usage: {
80 input: usage.input_tokens ?? 0,
81 output: usage.output_tokens ?? 0,
82 cacheRead: usage.cache_read_input_tokens ?? 0,
83 cacheWrite5m,
84 cacheWrite1h,
85 },
86 };
87}
88
89/**
90 * What a response cost in US dollars, or undefined when the model has no known price.
91 *
92 * @param {Entry} entry
93 * @returns {number | undefined}
94 */
95export function costOf(entry) {
96 const price = PRICES.find((candidate) => candidate.pattern.test(entry.model));
97
98 if (!price) {
99 return undefined;
100 }
101
102 const { input, output, cacheRead, cacheWrite5m, cacheWrite1h } = entry.usage;
103 const perMillion =
104 input * price.input +
105 output * price.output +
106 cacheRead * price.cacheRead +
107 cacheWrite5m * price.input * 1.25 +
108 cacheWrite1h * price.input * 2;
109
110 return (perMillion / 1_000_000) * (entry.isFast ? FAST_MULTIPLIER : 1);
111}
112
113/**
114 * Adds responses up by local calendar day, hour, model id and project.
115 *
116 * @param {{ since: number, excludeSessions?: Iterable<string> }} options
117 * `since`: responses before this moment are left out; `excludeSessions`:
118 * sessions the tray already recorded live, so they are not counted twice
119 */
120export function createTally({ since, excludeSessions = [] }) {
121 const excluded = new Set(excludeSessions);
122 const seen = new Set();
123 const unpriced = new Set();
124 /** @type {Record<string, Day>} */
125 const days = {};
126 let responses = 0;
127
128 return {
129 /** @param {Entry | undefined} entry */
130 add(entry) {
131 if (!entry || entry.at < since || excluded.has(entry.sessionId) || seen.has(entry.id)) {
132 return;
133 }
134
135 seen.add(entry.id);
136 responses += 1;
137
138 const usd = costOf(entry);
139
140 if (usd === undefined) {
141 unpriced.add(entry.model);
142 }
143
144 const cost = usd ?? 0;
145 const { input, output, cacheRead, cacheWrite5m, cacheWrite1h } = entry.usage;
146 const inputTokens = input + cacheRead + cacheWrite5m + cacheWrite1h;
147 const key = dayKey(entry.at);
148 const day = (days[key] ??= emptyDay());
149
150 day.usd += cost;
151 day.tokens += inputTokens + output;
152 day.cacheReadTokens += cacheRead;
153 day.inputTokens += inputTokens;
154 const hour = new Date(entry.at).getHours();
155 day.hours[hour] = (day.hours[hour] ?? 0) + cost;
156 day.models[entry.model] = (day.models[entry.model] ?? 0) + cost;
157 day.projects[entry.project] = (day.projects[entry.project] ?? 0) + cost;
158 },
159
160 /** @returns {TallyResult} */
161 result() {
162 return { days, unpricedModels: [...unpriced].sort(), responses };
163 },
164 };
165}
166
167/**
168 * Adds one transcript's responses to a tally, but only once the whole file
169 * has been read: a file that fails partway adds nothing, rather than part of
170 * its usage.
171 *
172 * @param {AsyncIterable<string> | Iterable<string>} lines
173 * @param {{ add: (entry: Entry | undefined) => void }} tally
174 * @returns {Promise<boolean>} whether the file was read to its end
175 */
176export async function addTranscript(lines, tally) {
177 /** @type {Entry[]} */
178 const staged = [];
179
180 try {
181 for await (const line of lines) {
182 const entry = readLine(line);
183
184 if (entry) {
185 staged.push(entry);
186 }
187 }
188 } catch {
189 return false;
190 }
191
192 for (const entry of staged) {
193 tally.add(entry);
194 }
195
196 return true;
197}
198
199/** @returns {Day} */
200function emptyDay() {
201 return { usd: 0, tokens: 0, cacheReadTokens: 0, inputTokens: 0, hours: new Array(24).fill(0), models: {}, projects: {} };
202}
203
204/**
205 * The local calendar date of a moment, as `YYYY-MM-DD`.
206 *
207 * @param {number} ms
208 */
209export function dayKey(ms) {
210 const date = new Date(ms);
211 const month = String(date.getMonth() + 1).padStart(2, "0");
212 const day = String(date.getDate()).padStart(2, "0");
213
214 return `${date.getFullYear()}-${month}-${day}`;
215}
216
217/** @param {string} path */
218function basename(path) {
219 const parts = path.split(/[\\/]/).filter(Boolean);
220 return parts[parts.length - 1] ?? "";
221}
222hooks/context.ts 116 lines1import type { ChefStationContextCategory } from "../types";
2
3/**
4 * Eight categorical colors, one per slot, in a fixed order. They are the
5 * dark-surface steps of the data-visualization reference palette, which pass
6 * every check on a light surface (#fcfcfb) and a dark surface (#1a1a19) alike:
7 * the lightness band, the chroma floor, protanopia and deuteranopia separation
8 * between neighbors (worst 8.4 against a target of 8), the normal-vision floor
9 * (worst 19.3 against 15) and contrast. The yellow sits at 2.99:1 on the light
10 * surface, which is why the legend always names each category beside its color.
11 *
12 * Only neighboring slots are validated as a pair: with all eight in play, some
13 * slots that are not neighbors (aqua and magenta under deuteranopia, red and
14 * orange for everyone) cannot be told apart. So the bar hands the slots out
15 * in its own order, and two segments that touch are always neighboring slots.
16 * The order is part of what makes neighbors distinguishable; never reorder it.
17 */
18export const CONTEXT_PALETTE = ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300", "#9085e9", "#e66767"];
19
20/** A neutral gray for the categories folded into "Other": not a hue, so it claims no identity. */
21const OTHER_COLOR = "#8a8986";
22
23export type ContextSegment = {
24 name: string;
25 tokens: number;
26 kind: "used" | "free" | "buffer";
27 /** A palette color for a category, or a theme key for free space and the buffer. */
28 color: string;
29 /** The character the segment is drawn with, so free space and the buffer differ by texture as well. */
30 glyph: string;
31};
32
33/**
34 * The breakdown as the bar draws it, left to right: what fills the window, in
35 * the order /context lists it, then the free space, then the compaction
36 * buffer. Schemas loaded on demand sit outside the window and are left out.
37 * Each filled category takes the next palette slot in bar order; past the
38 * eighth, the rest fold into "Other" rather than reuse a color.
39 */
40export function contextSegments(categories: readonly ChefStationContextCategory[]): ContextSegment[] {
41 const used = categories.filter((category) => category.kind === "used" && category.tokens > 0);
42 const segments: ContextSegment[] = [];
43 let otherTokens = 0;
44
45 for (const [slot, category] of used.entries()) {
46 const color = CONTEXT_PALETTE[slot];
47
48 if (color === undefined) {
49 otherTokens += category.tokens;
50 continue;
51 }
52
53 segments.push({ name: category.name, tokens: category.tokens, kind: "used", color, glyph: "█" });
54 }
55
56 if (otherTokens > 0) {
57 segments.push({ name: "Other", tokens: otherTokens, kind: "used", color: OTHER_COLOR, glyph: "█" });
58 }
59
60 for (const category of categories) {
61 if (category.kind === "free" && category.tokens > 0) {
62 segments.push({ name: category.name, tokens: category.tokens, kind: "free", color: "subtle", glyph: "░" });
63 }
64 }
65
66 for (const category of categories) {
67 if (category.kind === "buffer" && category.tokens > 0) {
68 segments.push({ name: category.name, tokens: category.tokens, kind: "buffer", color: "subtle", glyph: "▒" });
69 }
70 }
71
72 return segments;
73}
74
75/**
76 * Shares `width` cells among `values` in proportion, adding up to exactly
77 * `width`. Any value above zero gets at least one cell, so every category the
78 * legend names is visible in the bar; those cells come from the largest share.
79 */
80export function allocateCells(values: readonly number[], width: number): number[] {
81 const total = values.reduce((sum, value) => sum + value, 0);
82
83 if (total <= 0 || width <= 0) {
84 return values.map(() => 0);
85 }
86
87 const exact = values.map((value) => (value / total) * width);
88 const cells = exact.map((share, index) => (values[index]! > 0 ? Math.max(1, Math.floor(share)) : 0));
89 let assigned = cells.reduce((sum, count) => sum + count, 0);
90
91 // Hand the cells rounding left over to the largest fractional parts, earliest first on a tie.
92 const byRemainder = exact
93 .map((share, index) => ({ index, remainder: share - Math.floor(share) }))
94 .filter(({ index }) => values[index]! > 0)
95 .sort((a, b) => b.remainder - a.remainder || a.index - b.index);
96
97 for (let next = 0; assigned < width && byRemainder.length > 0; next = (next + 1) % byRemainder.length) {
98 cells[byRemainder[next]!.index]! += 1;
99 assigned += 1;
100 }
101
102 // The one-cell minimums can overshoot; take the excess from the largest share.
103 while (assigned > width) {
104 const largest = cells.indexOf(Math.max(...cells));
105
106 if (cells[largest]! <= 1) {
107 break;
108 }
109
110 cells[largest]! -= 1;
111 assigned -= 1;
112 }
113
114 return cells;
115}
116types/index.d.ts 146 lines1/**
2 * The four stations of the tray, one per card of the overview.
3 */
4export type ChefStationName = "usage" | "trend" | "breakdown" | "activity" | "context";
5
6/** One row of the context window's breakdown, as /context lists it. */
7export type ChefStationContextCategory = {
8 name: string;
9 tokens: number;
10 /** `used` fills the window, `free` is what is left, `buffer` the compaction reserve, `deferred` schemas outside the window. */
11 kind: "used" | "free" | "buffer" | "deferred";
12};
13
14export type ChefStationContextAction = {
15 isRunning: boolean;
16 isConfirmingClear: boolean;
17 message?: string;
18};
19
20/** The context window broken down by category, as the last measurement found it. */
21export type ChefStationContext = {
22 categories: ChefStationContextCategory[];
23 /** Tokens in use. */
24 totalTokens: number;
25 /** The window measured against: the model's limit, or a smaller compaction window. */
26 maxTokens: number;
27};
28
29/**
30 * What one calendar day (local time) has cost, across every session.
31 */
32export type ChefStationDay = {
33 /** US dollars, as `/cost` totals them. */
34 usd: number;
35 /** Every token the day's turns counted: input, output and both cache kinds. */
36 tokens: number;
37 /** Input tokens served from the prompt cache. */
38 cacheReadTokens: number;
39 /** Every input token: uncached, cache reads and cache writes. */
40 inputTokens: number;
41 /** US dollars per hour of the day, index 0 being midnight to 1am. */
42 hours: number[];
43 /** US dollars per model display name. */
44 models: Record<string, number>;
45 /** US dollars per project (the folder the session runs in). */
46 projects: Record<string, number>;
47};
48
49/**
50 * Every day the tray remembers, keyed `YYYY-MM-DD`; kept in `$.store` so it
51 * outlives the session, and pruned to the last thirteen weeks.
52 */
53export type ChefStationLedger = {
54 version: 1;
55 /** What the tray recorded live, turn by turn. */
56 days: Record<string, ChefStationDay>;
57 /** Sessions the tray recorded live, with the last day it saw each, so a backfill skips them. */
58 liveSessions?: Record<string, string>;
59 /** What the last scan of Claude Code's transcripts found, for every other session. */
60 backfill?: ChefStationBackfill;
61};
62
63/**
64 * The days a scan of `~/.claude/projects` tallied. Models are keyed by
65 * display name and costs are estimated from list prices, as the transcripts
66 * hold token counts and no cost.
67 */
68export type ChefStationBackfill = {
69 days: Record<string, ChefStationDay>;
70 /** When the scan finished, in milliseconds since the epoch. */
71 scannedAt: number;
72 /** `node` when the helper script ran, `fs` when the mod read the files itself. */
73 source: "node" | "fs";
74 files: number;
75 /** Transcripts too large for the mod to read itself (only when Node was not found). */
76 skippedFiles: number;
77 /** Transcripts whose read failed; the backfill is incomplete when there are any. Absent before 0.3.1. */
78 unreadableFiles?: number;
79 /** Model ids with no known price; their tokens count but their cost does not. */
80 unpricedModels: string[];
81};
82
83/**
84 * What this session alone is doing right now: the "Now" row.
85 */
86export type ChefStationLive = {
87 project: string;
88 model: string;
89 /** Output tokens this session's turns generated. */
90 outputTokens: number;
91 /** This session's cost so far, in US dollars. */
92 usd: number;
93 /** When the session began, in milliseconds since the epoch. */
94 startedAt: number;
95 /** When the running turn began; absent while the session is idle. */
96 turnStartedAt?: number;
97 /** How long the last completed turn took, in milliseconds. */
98 lastTurnMs?: number;
99 /** The context window's fill, 0 to 100, when a response reported one. */
100 contextPercent?: number;
101 /** The account's rate-limit windows, as the last response reported them. */
102 rateLimits: ChefStationRateLimit[];
103};
104
105export type ChefStationRateLimit = {
106 kind: string;
107 percentUsed: number;
108 resetsAt?: string;
109};
110
111/** How long a prompt-cache entry lives after the request that last read or wrote it. */
112export type ChefStationCacheTtl = "5m" | "1h";
113
114/** What the tray knows about the main conversation's prompt cache. */
115export type ChefStationCache = {
116 /** When the main conversation's last model request started, in milliseconds since the epoch. */
117 lastRequestAt?: number;
118 /** The lifetime of the latest cache write, as the transcript records it; unknown until a turn ends. */
119 ttl?: ChefStationCacheTtl;
120};
121
122declare module "claude-code" {
123 interface PluginState {
124 "chef-station": {
125 /** The station the tray shows. */
126 station: ChefStationName;
127 /** The persisted ledger, mirrored here so the band redraws when it moves. */
128 ledger: ChefStationLedger;
129 /** This session's live figures. */
130 live: ChefStationLive | null;
131 /** The time the tray last ticked, so countdowns move without a turn. */
132 now: number;
133 /** The session cost already written to the ledger, so no turn counts twice. */
134 recordedUsd: number;
135 /** The main conversation's prompt cache: when it was last used, and how long its entries live. */
136 cache: ChefStationCache;
137 /** The context window by category, refreshed after each turn; null before the first measurement. */
138 context: ChefStationContext | null;
139 /** The Context station's Clear and Compact: whether one is running, whether Clear awaits confirmation, and what the last one said. */
140 contextAction: ChefStationContextAction;
141 /** Whether a scan of the transcripts is running, and what the last one said. */
142 backfillStatus: { isRunning: boolean; message?: string };
143 };
144 }
145}
146