SLOPSHOPPER

mini-hud

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…

newbandspinnerprocesstimer
v0.4.1MITupdated 2026-10-23Jaime9745/mini-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mini-hud
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › Opus 5.5 · [object Object] · app · ctx ━━━━━───── 49% · ⟨Claude Code's own drawing⟩ 5h ━━━─────── 31% ↻ NaNd NaNh · ⟨Claude Code's own drawing⟩

Draws

Prompt hint
Opus 5.5 · [object Object] · app · ctx ━━━━━───── 49% · ⟨Claude Code's own drawing⟩
README

mini-hud

A minimal HUD for Claude Code, drawn in the prompt footer.

  • Left, right after the mode (auto, plan, bypass…): the model, its reasoning effort, the folder you work in with its git branch (* 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.
  • Bottom right: your subscription usage (5-hour and weekly windows) with a bar, a percentage and the time until it resets.
⏵⏵ 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.

Configure

Run /mini-hud:configure: it asks what to show and writes the options for you. The options:

OptionDefault
positionfooterfooter or above
showModeltruethe model
showEfforttruethe effort level
showGittruefolder + git branch
showContexttruecontext bar + %
showUsagetruesubscription usage at the right
displayrotatesee below
rotateSeconds8seconds per window when rotating
hideCycleHinttruehide "(shift+tab to cycle)"
hideInterruptHinttruehide "esc to interrupt"

Display modes

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 side
  • session: only the 5-hour window
  • week: only the weekly window

When 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.

Install

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.

Credits

Based on sub-meter by midudev (MIT).

Source 2 files
hooks/register.tsx 838 lines
1// 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};
838
types/index.d.ts 23 lines
1/** 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