A minimal HUD in the prompt footer: model, effort, folder and git branch, context bar, and subscription usage (5-hour and weekly), rotating, side by side or…

A minimal HUD for Claude Code, drawn in the prompt footer.
* when there are uncommitted changes; a worktree shows its own branch), and the context fill as a bar and a percentage, like the usage meter, measured against the autocompact window (autoCompactWindow, or the model's own window), the way /context measures it.⏵⏵ bypass permissions on · Opus 5.5 · high · mini-hud git:(main*) · ctx ━━━━━───── 48% 5h ━━━━────── 42% ↻ 2h 3m
The context bar turns yellow from 50% and red from 75%. A ~ before the percentage means an estimate, before the first response reports tokens.
Run /mini-hud:configure: it asks what to show and writes the options for you. The options:
| Option | Default | |
|---|---|---|
position | footer | footer or above |
showModel | true | the model |
showEffort | true | the effort level |
showGit | true | folder + git branch |
showContext | true | context bar + % |
showUsage | true | subscription usage at the right |
display | rotate | see below |
rotateSeconds | 8 | seconds per window when rotating |
hideCycleHint | true | hide "(shift+tab to cycle)" |
hideInterruptHint | true | hide "esc to interrupt" |
Set with the display option:
rotate (default): one window at a time, switching every rotateSeconds (8 by default)both: the 5-hour and weekly windows side by sidesession: only the 5-hour windowweek: only the weekly windowWhen the row is narrow it gives up, in order: the context bar (the % stays), the usage bar, the second usage window (with both, the one nearest its limit stays), the folder (the branch stays), the reset time, the branch, the effort, the context %, the model. The usage meter goes last.
At the prompt of a terminal session:
/plugin install mini-hud --marketplace Jaime9745/mini-hud
Answer y to add the marketplace, then pick a scope.
From a shell:
claude plugin install mini-hud --marketplace Jaime9745/mini-hud
In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with Jaime9745/mini-hud, then Add plugin.
Based on sub-meter by midudev (MIT).
hooks/register.tsx 838 lines1// Based on sub-meter by midudev (MIT): https://github.com/midudev/better-claude
2import { atom, read, update } from "claude-code";
3import type {
4 Elements,
5 EngineInterface,
6 Register,
7 RenderNode,
8 RenderViewport,
9 SessionContextUsage,
10 SessionRateLimit,
11} from "claude-code";
12
13import type { Fill, Git, Limit } from "../types";
14
15const limits = atom(
16 { plugin: "mini-hud", key: "limits" } as const,
17 [] as Limit[],
18);
19const view = atom({ plugin: "mini-hud", key: "view" } as const, 0); // which window the rotating meter shows
20const tick = atom({ plugin: "mini-hud", key: "tick" } as const, 0); // redraws the countdown
21const model = atom({ plugin: "mini-hud", key: "model" } as const, "");
22const effort = atom({ plugin: "mini-hud", key: "effort" } as const, "");
23const git = atom({ plugin: "mini-hud", key: "git" } as const, null as Git);
24const context = atom(
25 { plugin: "mini-hud", key: "context" } as const,
26 null as Fill,
27);
28
29const contextColor = (percent: number) =>
30 percent >= 75 ? "error" : percent >= 50 ? "warning" : "claude";
31/** The last segment of a path, either separator. */
32export const folderOf = (path: string) =>
33 path.split(/[/\\]/).filter(Boolean).pop() ?? path;
34
35let lastGit = "";
36let isReadingGit = false;
37
38/**
39 * The session's folder and, in a git repository, its branch (a worktree's own) and whether it has
40 * uncommitted changes, as claude-hud shows them by default. Read on a timer, not per drawing.
41 */
42async function syncGit($: EngineInterface) {
43 if (isReadingGit) return;
44 isReadingGit = true;
45 try {
46 const cwd = await $.session.cwd();
47 // One process for branch and changes: `# branch.head <name>` lines, then one line per change.
48 const status = await $.process.run(
49 ["git", "--no-optional-locks", "status", "--porcelain=v2", "--branch"],
50 { cwd, timeoutMs: 1000 },
51 );
52 const lines = status.exitCode === 0 ? status.stdout.split("\n") : [];
53 const head =
54 lines
55 .find((line) => line.startsWith("# branch.head "))
56 ?.slice(14)
57 .trim() ?? "";
58 const branch = head === "(detached)" ? "HEAD" : head;
59 const next: Git = {
60 folder: folderOf(cwd),
61 branch,
62 isDirty: lines.some((line) => line !== "" && !line.startsWith("#")),
63 };
64 const key = JSON.stringify(next);
65 if (key === lastGit) return;
66 lastGit = key;
67 await update($, git, () => next);
68 } finally {
69 isReadingGit = false;
70 }
71}
72
73/** Cells of a 10-cell bar a fill takes, 0 to 10. */
74export const cellsFor = (percent: number) =>
75 Math.round((Math.min(Math.max(percent, 0), 100) / 100) * 10);
76
77// The compaction window (autoCompactWindow, or the model's own) and the model it was read for.
78let compactTokens = 0;
79let compactFor = "";
80let lastFill: Fill = null;
81let isCounting = false;
82
83/**
84 * The fill against the window autocompact measures, as /context does: the engine's own `percent` is
85 * against the model's whole window. The window is read once per model (a local estimate, no request),
86 * and the fill is that estimate until a response reports tokens.
87 */
88async function syncContext($: EngineInterface, usage: SessionContextUsage) {
89 if (isCounting) return;
90 let estimate: number | undefined;
91 if (compactFor !== lastSessionModel || usage.tokens === undefined) {
92 isCounting = true;
93 try {
94 const { breakdown } = (await $.session.usage({ breakdown: "summary" }))
95 .context;
96 if (breakdown) {
97 compactTokens = breakdown.rawMaxTokens;
98 compactFor = lastSessionModel;
99 estimate = breakdown.percentage;
100 }
101 } finally {
102 isCounting = false;
103 }
104 }
105 const max = compactTokens || usage.window;
106 const value =
107 usage.tokens !== undefined && max > 0
108 ? (usage.tokens / max) * 100
109 : estimate;
110 const fill: Fill =
111 value === undefined
112 ? null
113 : { value, isEstimate: usage.tokens === undefined };
114 if (
115 lastFill?.value === fill?.value &&
116 lastFill?.isEstimate === fill?.isEstimate
117 )
118 return;
119 lastFill = fill;
120 await update($, context, () => fill);
121}
122
123/** "claude-opus-5-5" → "Opus 5.5", "claude-sonnet-4-5-20250929[1m]" → "Sonnet 4.5 1M", "opus" → "Opus". */
124export function prettyModel(id: string) {
125 const m =
126 /^(?:claude-)?([a-z]+)(?:-(\d+))?(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/i.exec(
127 id,
128 );
129 if (!m) return id;
130 const [, word = "", major, minor, big] = m;
131 return (
132 word.charAt(0).toUpperCase() +
133 word.slice(1) +
134 (major ? ` ${major}` : "") +
135 (minor ? `.${minor}` : "") +
136 (big ? " 1M" : "")
137 );
138}
139
140let lastModel = "";
141let lastEffort = "";
142let lastSessionModel = "";
143
144/** Writes model and effort only when they change, so each request does not redraw the footer. */
145async function showModel($: EngineInterface, id: string, level: string) {
146 const name = prettyModel(id);
147 const writes: Promise<unknown>[] = [];
148 if (name !== lastModel) {
149 lastModel = name;
150 writes.push(update($, model, () => name));
151 }
152 if (level !== lastEffort) {
153 lastEffort = level;
154 writes.push(update($, effort, () => level));
155 }
156 await Promise.all(writes);
157}
158
159const WINDOWS = ["five_hour", "seven_day"];
160const LABELS: Record<string, string> = { five_hour: "5h", seven_day: "week" };
161const ROTATE_SECONDS = 8;
162const TICK_MS = 8000;
163const DISPLAYS = ["rotate", "both", "session", "week"];
164// Where the HUD draws: in the footer row beside the mode label, or in the band over the prompt.
165// A row of its own under the footer row is not offered: it would start after the mode label the
166// engine draws, which no API measures.
167const POSITIONS = ["footer", "above"];
168const WINDOW_OF: Record<string, string> = {
169 session: "five_hour",
170 week: "seven_day",
171};
172const CELLS = 10;
173const CYCLE_HINT = /\s*\([^()]+ to cycle\)/;
174const INTERRUPT_HINT = /\s*esc to interrupt/;
175// The terminal footer's row: two columns of padding at each end, the mode label at the left
176// (MODE_ROW) and the hint after it, two columns at least between that and what is drawn here.
177const FOOTER_PADDING = 4;
178const FOOTER_GAP = 2;
179const SEPARATOR = " · ";
180const BETWEEN = " | ";
181
182// Columns, as last drawn on the terminal, of the engine's hint after the left part and of what is
183// beneath the meter (mode labels, another plugin's drawing): each hook tells the other.
184let hintWidth = 0;
185let beneathWidth = 0;
186let lastHint = "";
187let lastTidied = "";
188let windowCount = 0;
189let tickTimer: { cancel: () => void } | undefined;
190let rotateTimer: { cancel: () => void } | undefined;
191
192/** The subscription windows the meter shows, in order, from what the engine measured. */
193const windowsOf = (rateLimits: readonly SessionRateLimit[]): Limit[] =>
194 WINDOWS.flatMap((kind) => {
195 const found = rateLimits.find((limit) => limit.kind === kind);
196 return found
197 ? [{ kind, percentUsed: found.percentUsed, resetsAt: found.resetsAt }]
198 : [];
199 });
200
201/** Whether a drawing beneath shows nothing: no text, no control (the engine's own node draws `modes`, counted apart). */
202function isBlank(node: RenderNode | undefined): boolean {
203 if (node === undefined || node === null) return true;
204 if (typeof node === "string") return node.trim() === "";
205 if (node.type === "engine") return true;
206 return (
207 (node.type === "Box" || node.type === "Text") &&
208 (node.children ?? []).every(isBlank)
209 );
210}
211
212/** Columns a text takes: one per character, none for a variation selector. */
213const widthOf = (text: string) =>
214 [...text.replace(/[\uFE0E\uFE0F]/g, "")].length;
215
216/** Columns a drawing beneath takes on one row; the engine's own node draws `modes`, joined by ` & `. */
217function widthOfNode(
218 node: RenderNode | undefined,
219 modes: readonly string[],
220): number {
221 if (node === undefined || node === null) return 0;
222 if (typeof node === "string") return widthOf(node);
223 if (node.type === "engine") return widthOf(modes.join(" & "));
224 return (node.children ?? []).reduce(
225 (sum, child) => sum + widthOfNode(child, modes),
226 0,
227 );
228}
229
230// What the engine draws before the left part, measured on the terminal: `⏵⏵ bypass permissions on · `.
231// shortcut: no API names the permission mode, so the longest label is reserved; other modes get spare room.
232const MODE_ROW = 27;
233// Longest folder or branch name drawn whole.
234const NAME_MAX = 20;
235
236/** A text cut to `max` columns, ending in `…` when cut. */
237export const cut = (text: string, max: number) =>
238 widthOf(text) <= max ? text : [...text].slice(0, max - 1).join("") + "…";
239
240/** What the left part can show, already formatted; `value` is the context fill, for its color. */
241export type Left = {
242 name: string;
243 level: string;
244 folder: string;
245 branch: string;
246 percent: string;
247 filled: number;
248 value: number;
249};
250/** One window of the usage meter, formatted. */
251export type Meter = {
252 kind: string;
253 label: string;
254 percent: number;
255 filled: number;
256 used: string;
257 reset: string;
258};
259
260// Where the row is short it gives up, in this order, one more piece per step: the usage meter goes
261// last of all, git before it. With both windows shown, the one nearest its limit stays.
262const GIVE_UP = [
263 "hasContextBar",
264 "hasMeterBar",
265 "hasBothWindows",
266 "hasFolder",
267 "hasReset",
268 "hasBranch",
269 "hasEffort",
270 "hasContext",
271 "hasModel",
272 "hasMeter",
273] as const;
274export type Step = Record<(typeof GIVE_UP)[number], boolean>;
275const STEPS: Step[] = Array.from(
276 { length: GIVE_UP.length + 1 },
277 (_, i) => Object.fromEntries(GIVE_UP.map((key, j) => [key, j >= i])) as Step,
278);
279
280const barOf = (filled: number) =>
281 "━".repeat(filled) + "─".repeat(CELLS - filled);
282
283/** The left part's pieces as text for a step, joined by SEPARATOR when drawn. */
284function partsOf(left: Left, step: Step) {
285 const { name, level, folder, branch, percent, filled } = left;
286 const git = step.hasBranch && branch ? `git:(${branch})` : "";
287 const where = [
288 step.hasFolder || (step.hasBranch && !branch) ? folder : "",
289 git,
290 ]
291 .filter(Boolean)
292 .join(" ");
293 const fill =
294 step.hasContext && percent
295 ? `ctx ${step.hasContextBar ? barOf(filled) + " " : ""}${percent}`
296 : "";
297 return [
298 step.hasModel ? name : "",
299 step.hasEffort ? level : "",
300 where,
301 fill,
302 ].filter(Boolean);
303}
304
305/** The meter's windows for a step: none, all, or the one nearest its limit. */
306export function windowsFor(meter: Meter[], step: Step) {
307 if (!step.hasMeter) return [];
308 if (step.hasBothWindows || meter.length < 2) return meter;
309 return [meter.reduce((top, w) => (w.percent > top.percent ? w : top))];
310}
311
312/** The meter as text for a step. */
313const meterText = (meter: Meter[], step: Step) =>
314 windowsFor(meter, step)
315 .map(
316 (w) =>
317 w.label +
318 (step.hasMeterBar ? " " + barOf(w.filled) : "") +
319 w.used +
320 (step.hasReset ? w.reset : ""),
321 )
322 .join(BETWEEN);
323
324/**
325 * The first step whose row fits in `room` columns: the left part, the engine's hint after it, a gap,
326 * the meter and what is beneath it. Both hooks ask it with the same values, so both draw the same step.
327 */
328export function fitRow(
329 left: Left,
330 meter: Meter[],
331 room: number,
332 hint = 0,
333 beneath = 0,
334): Step {
335 return (
336 STEPS.find((step) => {
337 const ours = widthOf(partsOf(left, step).join(SEPARATOR));
338 const right = widthOf(meterText(meter, step));
339 const after = hint > 0 ? hint + (ours > 0 ? SEPARATOR.length : 0) : 0;
340 const end =
341 beneath > 0 ? beneath + (right > 0 ? SEPARATOR.length : 0) : 0;
342 return (
343 ours + after + (right + end > 0 ? FOOTER_GAP : 0) + right + end <= room
344 );
345 }) ?? (STEPS[GIVE_UP.length] as Step)
346 );
347}
348
349/** Columns the footer has on the terminal after the mode label; elsewhere, no limit. */
350function roomOf(e: { surface: string; viewport?: RenderViewport }) {
351 if (e.surface !== "terminal" || e.viewport === undefined) return Infinity;
352 return e.viewport.columns - FOOTER_PADDING - MODE_ROW;
353}
354
355const colorFor = (percent: number) =>
356 percent >= 90 ? "error" : percent >= 75 ? "warning" : "claude";
357
358/** "42m", "3h 5m", "2d 4h": how long until the window resets. */
359function until(ms: number) {
360 const minutes = Math.max(1, Math.round(ms / 60000));
361 if (minutes < 60) return `${minutes}m`;
362 const hours = Math.floor(minutes / 60);
363 if (hours < 24) return `${hours}h ${minutes % 60}m`;
364 return `${Math.floor(hours / 24)}d ${hours % 24}h`;
365}
366
367// When the reading this session shows was taken; the shared store carries the newest one of any session.
368let seenAt = 0;
369
370/** Keeps the latest reading for the meter, and for the next session until a response reports again. */
371let lastLimits = "";
372
373async function remember($: EngineInterface, found: Limit[]) {
374 if (found.length === 0) return;
375 const key = JSON.stringify(found);
376 if (key === lastLimits) return;
377 lastLimits = key;
378 const [now] = await Promise.all([
379 $.clock.now(),
380 update($, limits, () => found),
381 ]);
382 seenAt = now;
383 windowCount = found.length;
384 // Not waited on: nothing drawn depends on it. `at` after the limits, so a reader that sees the new time finds the new limits.
385 void $.store
386 .set("limits", found)
387 .then(() => $.store.set("at", now))
388 .catch(() => {});
389}
390
391/** Adopts the reading another session left in the shared store, when it is newer than ours. */
392async function syncFromStore($: EngineInterface) {
393 const at = Number(await $.store.get("at")) || 0;
394 if (at <= seenAt) return;
395 const saved = await $.store.get("limits");
396 if (!Array.isArray(saved)) return;
397 seenAt = at;
398 windowCount = saved.length;
399 await update($, limits, () => saved as Limit[]);
400}
401
402/** The windows to draw: all of them, the one the rotation is on, or the one chosen. With fewer than two readings, whatever there is. */
403function visible(all: Limit[], display: string, index: number): Limit[] {
404 if (display === "both" || all.length < 2) return all;
405 if (display === "rotate") return all.slice(index % all.length).slice(0, 1);
406 return all.filter((limit) => limit.kind === WINDOW_OF[display]);
407}
408
409/** Which pieces the options turn on, and how the meter displays. */
410type Show = {
411 model: boolean;
412 effort: boolean;
413 git: boolean;
414 context: boolean;
415 usage: boolean;
416 display: string;
417};
418
419/**
420 * What the whole row shows, read by both drawings: the left part and the meter. Reading it subscribes
421 * the drawing to every value, so a change on either side redraws both and they agree on the step.
422 */
423async function rowOf(
424 $: EngineInterface,
425 show: Show,
426): Promise<{ left: Left; meter: Meter[] }> {
427 // `view` in the same round: read after, it cost a second round trip per drawing.
428 const [shownName, shownLevel, shownFill, shownGit, all, , now, shownView] =
429 await Promise.all([
430 read($, model),
431 read($, effort),
432 read($, context),
433 read($, git),
434 read($, limits),
435 read($, tick),
436 $.clock.now(),
437 show.usage && show.display === "rotate" ? read($, view) : 0,
438 ]);
439 const repo = show.git ? shownGit : null;
440 const fill = show.context ? shownFill : null;
441 const left: Left = {
442 name: show.model ? shownName : "",
443 level: show.effort ? shownLevel : "",
444 folder: repo === null ? "" : cut(repo.folder, NAME_MAX),
445 branch: repo?.branch
446 ? cut(repo.branch, NAME_MAX) + (repo.isDirty ? "*" : "")
447 : "",
448 percent:
449 fill === null
450 ? ""
451 : `${fill.isEstimate ? "~" : ""}${Math.round(fill.value)}%`,
452 filled: fill === null ? 0 : cellsFor(fill.value),
453 value: fill === null ? 0 : fill.value,
454 };
455 if (!show.usage || all.length === 0) return { left, meter: [] };
456 const index = all.length >= 2 ? shownView : 0;
457 const meter = visible(all, show.display, index).map((limit) => {
458 const resetsIn =
459 limit.resetsAt === undefined
460 ? undefined
461 : Date.parse(limit.resetsAt) - now;
462 const hasReset = resetsIn !== undefined && resetsIn <= 0;
463 const percent = hasReset ? 0 : limit.percentUsed;
464 return {
465 kind: limit.kind,
466 label: LABELS[limit.kind] ?? limit.kind,
467 percent,
468 filled: cellsFor(percent),
469 used: ` ${Math.round(percent)}%`,
470 reset: resetsIn !== undefined && !hasReset ? ` ↻ ${until(resetsIn)}` : "",
471 };
472 });
473 return { left, meter };
474}
475
476let isInteractive = false;
477// The effort the settings gave when last read: shown only when it changes there, so a request's
478// own effort (turn.step, after any downgrade for the model) is not overwritten on every tick.
479let lastSettingsEffort = "";
480
481/** The effort the settings give a model: its own (`/effort` writes it there), else the global one. */
482const effortOf = (perModel: unknown, level: unknown, id: string) =>
483 String(
484 (perModel as Record<string, { effortLevel?: unknown }> | undefined)?.[id]
485 ?.effortLevel ??
486 level ??
487 "",
488 );
489
490/** Model and effort as the session and the settings have them now: after /model or /effort, and on each tick. */
491async function syncChoice($: EngineInterface) {
492 const [id, perModel, level] = await Promise.all([
493 $.session.model(),
494 $.settings.read({ key: "modelSettings" }),
495 $.settings.read({ key: "effortLevel" }),
496 ]);
497 const effort = effortOf(perModel, level, id);
498 if (id === lastSessionModel && effort === lastSettingsEffort) return;
499 lastSessionModel = id;
500 lastSettingsEffort = effort;
501 await showModel($, id, effort);
502}
503
504/** Fills the footer for a session that starts (or a /clear) and starts its timers. */
505async function start($: EngineInterface, show: Show, rotateMs: number) {
506 tickTimer?.cancel();
507 tickTimer = undefined;
508 rotateTimer?.cancel();
509 rotateTimer = undefined;
510 lastGit = "";
511 isReadingGit = false;
512 if (show.git) void syncGit($).catch(() => {}); // first, and not waited on: the session starts at once
513 lastLimits = "";
514 // Every read in one round trip.
515 const [saved, storedAt, usage, sessionModel, perModel, level] =
516 await Promise.all([
517 $.store.get("limits"),
518 $.store.get("at"),
519 $.session.usage(),
520 $.session.model(),
521 $.settings.read({ key: "modelSettings" }),
522 $.settings.read({ key: "effortLevel" }),
523 ]);
524 seenAt = Number(storedAt) || 0;
525 const found = windowsOf(usage.rateLimits);
526 if (found.length === 0 && Array.isArray(saved)) {
527 windowCount = saved.length;
528 await update($, limits, () => saved as Limit[]);
529 }
530
531 // Before the first request: the session's model, and the effort the settings give it.
532 lastModel = lastEffort = "";
533 lastSessionModel = sessionModel;
534 lastSettingsEffort = effortOf(perModel, level, sessionModel);
535 compactFor = "";
536 lastFill = null;
537 isCounting = false;
538 await Promise.all([
539 remember($, found),
540 showModel($, lastSessionModel, lastSettingsEffort),
541 ]);
542 // The window estimate is not waited on either: the fill shows when it is in.
543 void syncContext($, usage.context).catch(() => {});
544
545 if (!isInteractive) return;
546 // Another session's reading shows up within TICK_MS of being stored.
547 tickTimer = $.clock.every(TICK_MS, () => {
548 void refill($, show, rotateMs).catch(() => {});
549 void syncFromStore($).catch(() => {}); // a failed read keeps the reading on screen
550 // A /model or /effort the command hook missed (a picker still open when it ran).
551 void syncChoice($).catch(() => {});
552 // A resume, a rewind, a compaction or a /model switch moves the fill without a measurement.
553 void $.session
554 .usage()
555 .then((usage) => syncContext($, usage.context))
556 .catch(() => {});
557 if (show.git) void syncGit($).catch(() => {});
558 if (windowCount > 0) void update($, tick, (n) => n + 1);
559 });
560 if (show.display === "rotate") {
561 rotateTimer = $.clock.every(rotateMs, () => {
562 if (windowCount >= 2) void update($, view, (n) => n + 1);
563 });
564 }
565}
566
567/**
568 * Fills the footer again when the state was emptied under the module (a /clear), whatever order
569 * the engine ran that in: the model is written at every start, so an empty one means it was lost.
570 */
571async function refill($: EngineInterface, show: Show, rotateMs: number) {
572 if (lastModel === "" || (await read($, model)) !== "") return;
573 await start($, show, rotateMs);
574}
575
576/** The element constructors the trees below are built from, as `$.ui.resolve(e)` hands them. */
577type Ui = Pick<Elements["terminal"], "Box" | "Text">;
578
579/** The left part's pieces for a step, each after a SEPARATOR but the first; empty when none shows. */
580function leftTree({ Box, Text }: Ui, left: Left, step: Step) {
581 const { name, level, folder, branch, percent, filled, value } = left;
582 const hasBranch = step.hasBranch && branch !== "";
583 const hasFolder =
584 folder !== "" && (step.hasFolder || (step.hasBranch && branch === ""));
585 const hasContext = step.hasContext && percent !== "";
586 const segments = [
587 step.hasModel && name ? <Text color="claude">{name}</Text> : null,
588 step.hasEffort && level ? <Text dimColor>{level}</Text> : null,
589 hasFolder || hasBranch ? (
590 <Box flexDirection="row">
591 {hasFolder ? <Text>{folder}</Text> : null}
592 {hasBranch ? (
593 <Text color="merged">{(hasFolder ? " " : "") + "git:("}</Text>
594 ) : null}
595 {hasBranch ? <Text color="suggestion">{branch}</Text> : null}
596 {hasBranch ? <Text color="merged">)</Text> : null}
597 </Box>
598 ) : null,
599 hasContext ? (
600 <Box flexDirection="row">
601 <Text dimColor>ctx </Text>
602 {step.hasContextBar ? (
603 <Text color={contextColor(value)}>{"━".repeat(filled)}</Text>
604 ) : null}
605 {step.hasContextBar ? (
606 <Text dimColor>{"─".repeat(CELLS - filled) + " "}</Text>
607 ) : null}
608 <Text color={step.hasContextBar ? undefined : contextColor(value)}>
609 {percent}
610 </Text>
611 </Box>
612 ) : null,
613 ].filter(Boolean);
614 return segments.map((segment, i) => (
615 <Box flexDirection="row" key={String(i)}>
616 {i > 0 ? <Text dimColor>{SEPARATOR}</Text> : null}
617 {segment}
618 </Box>
619 ));
620}
621
622/** The meter's windows for a step, BETWEEN apart; empty when none shows. */
623function meterTree({ Box, Text }: Ui, meter: Meter[], step: Step) {
624 return windowsFor(meter, step).map((w, i) => (
625 <Box flexDirection="row" key={w.kind}>
626 {i > 0 ? <Text dimColor>{BETWEEN}</Text> : null}
627 <Text dimColor>{w.label}</Text>
628 {step.hasMeterBar ? (
629 <Text color={colorFor(w.percent)}>{" " + "━".repeat(w.filled)}</Text>
630 ) : null}
631 {step.hasMeterBar ? (
632 <Text dimColor>{"─".repeat(CELLS - w.filled)}</Text>
633 ) : null}
634 <Text>{w.used}</Text>
635 {step.hasReset && w.reset ? <Text dimColor>{w.reset}</Text> : null}
636 </Box>
637 ));
638}
639
640/** The whole row on a row of its own, `room` columns wide: the left part, the meter at the right end. */
641function rowTree(ui: Ui, left: Left, meter: Meter[], room: number) {
642 const step = fitRow(left, meter, room);
643 const segments = leftTree(ui, left, step);
644 const windows = meterTree(ui, meter, step);
645 if (segments.length === 0 && windows.length === 0) return null;
646 const { Box } = ui;
647 return (
648 <Box
649 flexDirection="row"
650 justifyContent="space-between"
651 width={Number.isFinite(room) ? room : undefined}
652 >
653 <Box flexDirection="row">{segments}</Box>
654 <Box flexDirection="row">{windows}</Box>
655 </Box>
656 );
657}
658
659export const register: Register = (on, options) => {
660 seenAt = 0;
661 windowCount = 0;
662 lastHint = "";
663 lastTidied = "";
664 const hideCycleHint = options.hideCycleHint !== false;
665 const hideInterruptHint = options.hideInterruptHint !== false;
666 const rotateSeconds = Number(options.rotateSeconds);
667 const rotateMs = (rotateSeconds > 0 ? rotateSeconds : ROTATE_SECONDS) * 1000;
668 const display = DISPLAYS.includes(String(options.display))
669 ? String(options.display)
670 : "rotate";
671 const position = POSITIONS.includes(String(options.position))
672 ? String(options.position)
673 : "footer";
674 const showModelName = options.showModel !== false;
675 const showEffort = options.showEffort !== false;
676 const showContext = options.showContext !== false;
677 const showUsage = options.showUsage !== false;
678 const showGit = options.showGit !== false;
679 const show: Show = {
680 model: showModelName,
681 effort: showEffort,
682 git: showGit,
683 context: showContext,
684 usage: showUsage,
685 display,
686 };
687 const hidden = [
688 ...(hideCycleHint ? [CYCLE_HINT] : []),
689 ...(hideInterruptHint ? [INTERRUPT_HINT] : []),
690 ];
691
692 /** The hint line without the parts hidden, and without the empty parts they leave. */
693 function tidy(hint: string) {
694 if (!hidden.some((part) => part.test(hint))) return hint;
695 return hidden
696 .reduce((line, part) => line.replace(part, " · "), hint)
697 .split("·")
698 .map((part) => part.trim())
699 .filter(Boolean)
700 .join(" · ");
701 }
702
703 on("session.start", async ($, e, next) => {
704 isInteractive = e.isInteractive;
705 await start($, show, rotateMs);
706 return next(e);
707 });
708
709 // A /clear ends the session and goes on under a new one with no session.start and its state
710 // emptied once this has run: the timers keep going and refill the footer (refill), a moment
711 // after the clear and again on each tick and request, whatever this misses.
712 on("session.end", ($, e, next) => {
713 if (e.reason === "clear") {
714 // shortcut: a fixed wait, as no event marks the new session (classic.SessionStart may be passed over by another plugin).
715 $.clock.after(1000, () => void refill($, show, rotateMs).catch(() => {}));
716 return next(e);
717 }
718 tickTimer?.cancel();
719 tickTimer = undefined;
720 rotateTimer?.cancel();
721 rotateTimer = undefined;
722 return next(e);
723 });
724
725 // Each request of the main loop names the model and effort it is really sent with.
726 on("turn.step", async function* ($, e, next) {
727 if (e.agentId === undefined) await refill($, show, rotateMs);
728 if (e.agentId === undefined)
729 await showModel(
730 $,
731 e.model,
732 e.effort === undefined ? "" : String(e.effort),
733 );
734 return yield* next(e);
735 });
736
737 // /effort and /model write the settings and the session's model: shown as soon as they ran.
738 on("command.run", { command: ["effort", "model"] }, async ($, e, next) => {
739 const result = await next(e);
740 await syncChoice($).catch(() => {});
741 return result;
742 });
743
744 // A turn that committed, switched branch or moved the session shows at once.
745 on("turn.complete", async ($, e, next) => {
746 if (showGit && e.agentId === undefined) void syncGit($).catch(() => {});
747 return next(e);
748 });
749
750 on("session.measure", async ($, e, next) => {
751 await remember($, windowsOf(e.rateLimits));
752 if (e.changed.includes("context")) await syncContext($, e.context);
753 return next(e);
754 });
755
756 // Bottom right of the prompt footer: the meter, then what is beneath it (the engine's mode labels,
757 // another plugin's switch), so a plugin that draws itself last stays last whichever wraps the other.
758 // Where the row is short the meter is the last thing it gives up (fitRow).
759 on("ui.render", { component: "SessionMode" }, async ($, e, next) => {
760 if (!showUsage || position !== "footer") return next(e);
761 const [{ left, meter }, beneath] = await Promise.all([
762 rowOf($, show),
763 next(e),
764 ]);
765 if (meter.length === 0) return beneath;
766 const hasBeneath = e.props.modes.length > 0 || !isBlank(beneath);
767 if (e.surface === "terminal") {
768 const width = hasBeneath ? widthOfNode(beneath, e.props.modes) : 0;
769 if (width !== beneathWidth) {
770 beneathWidth = width;
771 $.ui.invalidate("ui.render");
772 }
773 }
774 const step = fitRow(left, meter, roomOf(e), hintWidth, beneathWidth);
775 const ui = $.ui.resolve(e);
776 const windows = meterTree(ui, meter, step);
777 if (windows.length === 0) return beneath;
778
779 const { Box, Text } = ui;
780
781 return (
782 <Box flexDirection="row">
783 {windows}
784 {hasBeneath ? <Text dimColor>{SEPARATOR}</Text> : null}
785 {beneath}
786 </Box>
787 );
788 });
789
790 // Left of the footer, right after the mode label the engine draws: the model, its effort, the folder
791 // and git branch and the context fill, then the hint line ("(shift+tab to cycle)", "esc to interrupt"
792 // while Claude works). The hint's width tells the meter how much room is left.
793 on("ui.render", { component: "PromptHint" }, async ($, e, next) => {
794 if (e.props.hint !== lastHint) {
795 lastHint = e.props.hint;
796 lastTidied = tidy(e.props.hint);
797 }
798 const hint = lastTidied;
799 const hasHint = hint.trim() !== "";
800 const passHint = () =>
801 hint === e.props.hint
802 ? next(e)
803 : next({ ...e, props: { ...e.props, hint } });
804 if (position === "above") return passHint();
805 const ui = $.ui.resolve(e);
806 const { Box, Text } = ui;
807 if (e.surface === "terminal") {
808 const width = hasHint ? widthOf(hint) : 0;
809 if (width !== hintWidth) {
810 hintWidth = width;
811 $.ui.invalidate("ui.render");
812 }
813 }
814 const [{ left, meter }, beneath] = await Promise.all([
815 rowOf($, show),
816 passHint(),
817 ]);
818 const step = fitRow(left, meter, roomOf(e), hintWidth, beneathWidth);
819 const segments = leftTree(ui, left, step);
820 if (segments.length === 0) return beneath;
821
822 return (
823 <Box flexDirection="row">
824 {segments}
825 {hasHint ? <Text dimColor>{SEPARATOR}</Text> : null}
826 {hasHint ? beneath : null}
827 </Box>
828 );
829 });
830
831 // With `position` "above": the whole row in the band over the prompt, the meter at its right end.
832 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
833 if (position !== "above" || e.props.hasSurvey) return next(e);
834 const { left, meter } = await rowOf($, show);
835 return rowTree($.ui.resolve(e), left, meter, e.props.bodyColumns) ?? next(e);
836 });
837};
838types/index.d.ts 23 lines1/** One rate-limit window of the subscription, as the last API response reported it. */
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string };
3
4/** How full the context is against the autocompact window; an estimate until a response reports tokens. */
5export type Fill = { value: number; isEstimate: boolean } | null;
6
7/** The session's folder, its git branch (empty outside a repository) and whether it has uncommitted changes. */
8export type Git = { folder: string; branch: string; isDirty: boolean } | null;
9
10declare module "claude-code" {
11 interface PluginState {
12 "mini-hud": {
13 limits: Limit[];
14 view: number;
15 tick: number;
16 model: string;
17 effort: string;
18 context: Fill;
19 git: Git;
20 };
21 }
22}
23