SLOPSHOPPER

context-meter

See your context window fill up as you work, no /context needed. A live band above the prompt shows an animated fill bar, token count, auto-compact headroom…

newbandtoastprompttimer
★ 2v0.4.3MITupdated 2026-10-16Krs7N/context-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-meter
› fix the failing auth test and add an audit log call ● context-meter: context-meter: session.start on terminal, engine 2.1.289; drawn: true; mono: false ⏺ 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 ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────── │ ▐▛███▜▌ Context ████████▍░░░░░░░░ 49% 97.4k / 200.0k tokens │ ▝▜█████▛▘ │ ▀ ▀▀▀▀▀ ▀ session 30m · $0.42 · 5h 31% · details │ ▀ ▀ ▀ ▀ ▀ ▀ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭─────────────────────────────────────────────────────────────────────────────────────────────────── │ ▐▛███▜▌ Context ████████▍░░░░░░░░ 49% 97.4k / 200.0k tokens │ ▝▜█████▛▘ │ ▀ ▀▀▀▀▀ ▀ session 30m · $0.42 · 5h 31% · details │ ▀ ▀ ▀ ▀ ▀ ▀ ╰───────────────────────────────────────────────────────────────────────────────────────────────────
README

context-meter — a Claude Code mod

See your context window fill up as you work, without typing /context. A live band above the prompt shows an animated fill bar, the token count, headroom before auto-compaction and about how many turns it lasts, the usage trend, the session's length and cost, how much the prompt cache served and when it has gone cold, and the rate limits with when each resets, or when the recent pace runs one out first. It also has one-click model and effort pickers, a compact button once the context runs high, and Clawd, who types while Claude works, dresses up for the effort level, gets cross as the context fills up and naps when you're idle.

context-meter above the Claude Code prompt: Clawd in his xhigh glasses beside the fill bar at 51 %, the auto-compact headroom, this turn's growth and the trend, then the session's length, cost and rate limits

Install

Requires Claude Code 2.1.295 or later. Claude Code Desktop runs its own bundled Claude Code (2.1.295 as of 2026-10-15), whatever version the terminal has. Type this at the prompt of a terminal session:

/plugin install context-meter --marketplace Krs7N/context-meter
  1. Answer y to Add marketplace? (github:Krs7N/context-meter).
  2. Press Enter to keep the user scope, so the mod loads in every session.
  3. Claude Code prints Installed context-meter. Plugin is now active. The band appears above the prompt right away.

Update to a newer version with /plugin update context-meter, then /reload-plugins (or restart Claude Code): a session that is already open keeps drawing the old version until then. Remove the mod with /plugin uninstall context-meter.

Settings

SettingDefaultWhat it does
sleepAfterMinutes ("Clawd naps after (minutes)")5Minutes without activity before Clawd falls asleep. 0 keeps him awake.
showClawd ("Show Clawd")onOff: no mascot, and the stats take his 15 cells.
reduceMotion ("Reduce motion")offOn: numbers land at once instead of counting up, and Clawd neither types, blinks nor shakes (his zZ stops growing). His face and colour still follow the bar. The fast timer never runs.
layout ("Layout")autoauto: the full band where it fits, else one line. one-line: always the one-line meter.
showCost ("Show the session's cost")onThe $ figure in row 3.
showRateLimits ("Show rate limits")onThe 5h, 7d and spend windows in row 3, with their resets and pace.
showCompactButton ("Show the compact button")onThe compact button once the bar turns amber or red.
warnAt ("Bar turns amber at …")75How far toward auto-compact, in percent, the bar turns amber and Clawd gets annoyed. With no auto-compact point known, the way to 80 % of the window.
alertAt ("Bar turns red at …")95The same for red, Clawd fuming. Below warnAt, both keep their defaults.
cacheLifetime ("Prompt-cache lifetime")autoauto: learned from your requests (Cold cache). 5m or 1h: always that.
models ("Models in the model picker")haiku, sonnet, opus, fableThe picker's entries as /model takes them, separated by commas or spaces, smallest first: a family, opus[1m] (1M window), best, opusplan or a model id.

Change them in /config, where each is a row of its own, or from a shell:

echo '{"sleepAfterMinutes":10,"showCost":false}' | claude plugin configure context-meter@context-meter --values-stdin

A change reloads the mod with the new values. A value out of range keeps its default.

Run it from a clone instead

git clone https://github.com/Krs7N/context-meter
claude --plugin-dir ./context-meter

To load it in every session without a marketplace, add the clone's path to the env block in ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "C:/path/to/context-meter" } }

On Windows, separate several paths with ;. On macOS and Linux, use :.

What it shows

RowContentSource
1Context + fill bar + percent + tokens / window; model badge and effort badge on the right$.session.usage().context (tokens, window, percent); model and effort: see below
2in this order, each only when it applies: ! 150.0k over the 200.0k window: next send compacts (or : /compact before sending with auto-compaction off), in red; cache cold: next send re-writes 162.0k in amber (see Cold cache); window 1.00M → 200.0k for about 10 s after a model switch; ┊ auto-compact at 167.0k (83.0k left, ~6 turns) (the same ┊ as on the bar), or with auto-compaction off window full in ~6 turns; the turn: this turn +12.0k while Claude works, compacted 182.0k → 31.0k after a compaction (shrank 182.0k → 31.0k when the fill fell with no compaction seen), else last turn +3.2k; fill trend trend ▁▂▃▅▇ 12→42%context.breakdown; each main-thread request's usage; the turn ends; trend and cache state
3session duration, session cost, how much of the last request the cache served, rate-limit windows (5h, 7d, spend), each with a countdown to its reset, then the compact button once the bar warns: session 1h 3m · $4.20 · cache 97% · 5h 72% full in ~48m, resets 2h 14m · 7d 41% resets 3d 4h · compactstartedAt and the clock, cost.usd, the main request's usage, rateLimits (percentUsed, resetsAt) and the pace state
4 (optional)the 5 largest used categories, as /context splits thembreakdown.categories (kind used) — toggled by the details button
  • Bar color: Claude orange (claude theme key), then warning and error as the fill nears the auto-compact point (from three quarters of the way, and from 95 %; the warnAt and alertAt settings). With no auto-compact point known, the way to 80 % of the window: warning from 60 %, error from 76 % (fillColor). Clawd changes with it (see The mascot).
  • The bar fills in eighth-block steps (▏▎▍▌▋▊▉█), so it moves smoothly. It is up to 40 cells wide.
  • A ┊ mark on the empty part shows where auto-compaction runs.
  • The meter moves with each model request of a running turn, not only when the turn ends.
  • When a reading raises the fill, the newly added part is drawn bold green (success) for about 10 s. The percent then shows a green ▲, or a red ▼ (error) when it drops.
  • Change highlight: every number that changed since the last reading is drawn bold for about 10 s (HIGHLIGHT_TICKS × 0.7 s): green where it went up, red where it went down (moveOf). This covers tokens, tokens left before compaction, percent, cost, each rate-limit percent and the window … → … note. A turn's growth (this turn, last turn) goes by its sign: + green, − red. Unchanged numbers stay dim. A new window (a model switch) is no growth: no ▲, no count-up, just the window … note.
  • Colour-blind readers: pick Dark mode (colorblind-friendly) or Light mode (colorblind-friendly) in /theme. Claude Code draws success in blue there and keeps error red, and the band's up and down colours are those two theme keys, so every rise turns blue. Clawd's moods change his face as well as his colour.
  • Arrows take the same colours, not bold: the trend's ends (12→42% green when the fill rose over the trend, red when it fell) and the compaction note (compacted 182.0k → 31.0k, red).
  • Count-up: a changed number does not jump to its new value. It counts there from the old one over 6 s with an ease-out (TWEEN_STEPS × TWEEN_MS), or over 1 s for a reading in the middle of a turn (LIVE_STRIDE), and the bar grows with it. A newer reading restarts the count.
  • Trend: the tokens at the last 8 turn ends, scaled to the values shown, then first→last as percents of the window shown now, so a switch to a smaller window draws no spike.
  • Over the window: the engine caps the percent at 100, so a context bigger than the window (after a switch to a smaller model) shows as red tokens and the warning at the start of row 2.
  • Below 72 columns, when the badges leave too little room beside Clawd for the bar, the percent and the tokens, or when the full band (an open picker included) is taller than the room above the prompt (maxRows, a short fullscreen terminal), only row 1 and the two badges are drawn, without frame and mascot. Where even an open picker column is taller than that room, its choices are drawn in a row instead of the meter until it closes.
  • Nothing is cut mid-figure. Where a row lacks room it leaves out whole pieces:
  • the meter row drops the word tokens, then Context, then the window, then the token count, and the bar shrinks last;
  • row 2 shortens ┊ auto-compact at 167.0k (83.0k left, ~6 turns) to ┊ 83.0k left, ~6 turns and the cold-cache warning to cache cold, then leaves out the trend, then the turn's growth;
  • row 3 first drops each window's reset countdown (the 7d one first) and keeps its percent and any full in warning, then leaves out the cache's share, then the session's length, then the rate limits, the cost last. The compact and details buttons always keep their room.
  • The session clock and the reset countdowns run between readings. A window whose reset time is unknown or already past shows its percent alone.
  • Before the first reply of a session (or right after /clear) the token count shows "waiting for the first reply". Right after a compaction it shows what the compaction left, ~31.0k / 200.0k tokens, until the next reply.
  • /clear and /resume start the meter over: no growth or trend is measured against the conversation before.
  • A fill that falls without a compaction seen: when a reading is more than a fifth below the one before (SHRINK) and no compaction was recorded, row 2 shows shrank 182.0k → 31.0k instead of a negative turn, and the next turn counts from there. This covers organisation-managed installs, where Claude Code does not run a plugin's session.compact hooks, and rewinds.
  • In Claude Code Desktop Clawd is drawn as one picture (clawdSvg), so he looks as he does in the terminal; the badges and picker options are Desktop's own buttons. The band's text is set in the monospaced face where Desktop's engine supports it (2.1.299 or later, read with $.session.version()). An older engine refuses the whole band over that setting, so there, or while the version is unknown, the text uses Desktop's page font.
  • Until the first reading, the band is one line: Context waiting for the first reply, or, where reading the usage fails, reading failed on desktop: <why> in warning. The reason also goes to the debug log (claude --debug).

Looking ahead

Turns to compact

┊ auto-compact at 167.0k (83.0k left, ~6 turns): the tokens left before auto-compaction over the average growth of the last 5 turns that grew (a compaction's drop is not a turn's growth), rounded up (turnsLeft). With auto-compaction off it counts toward a full window instead: window full in ~6 turns. Nothing shows before two turns have grown, or from 100 turns on.

Rate-limit pace

Each rate-limit window keeps its readings of the last half hour, about one a minute (withReadings, the pace state); a new window (another reset time) or a drop starts them over. Once they span 10 minutes and rose a point, and while the newest is under 10 minutes old, a window that the recent pace would use up before it resets says when: 5h 72% full in ~48m, resets 2h 14m, the warning in red (fullIn).

Cold cache

  • cache 97% in row 3: how much of the last main-thread request the prompt cache served (cache_read over all its input). The first request of a conversation reads nearly nothing.
  • Idle past the cache's lifetime, row 2 opens with cache cold: next send re-writes 162.0k in amber: the next send writes the whole context to the cache again, at the cache-write rate. Not while Claude works.
  • The plugin API does not give the cache's lifetime, so the requests show it (ttlFrom). A request sent more than 5 minutes after the last reply, on the same model and effort, that still read most of its input from the cache means it lives an hour. One that had to write most of it again means 5 minutes. A compaction, another model or effort, or /clear starts a new cache, which tells nothing. What was learned is kept in the mod's store (cacheTtl) for later sessions. Until a request has shown it, the warning assumes an hour, so it never says "cold" too early.
  • No $ estimate: token prices are not in the plugin API, and subscription plans do not pay per token.
  • If Claude Code renews the cache in the background, the cache can stay warm longer than the warning assumes.

Compact button

Once the bar turns amber or red (or the context is over the window), and while Claude is not working, row 3 ends · compact · details. A press asks compact now? yes · no; yes compacts the conversation as /compact does without instructions ($.session.compact()), shows compacting…, then compacted 182.0k → 31.0k in row 2. If Claude Code skips the compaction, a toast says why. A prompt moves on from an unanswered confirm. For a compaction with instructions, type /compact <what to keep>.

Model badge

Left of the effort badge: ◇ Opus 5.5 ▾, the main loop's model as /model shows it ($.session.model(), read at start, after every /model and every 1.4 s, so a change made anywhere shows up). A 1M-context variant reads ◇ Opus 5.5 · 1M.

When the model changes, the meter reads the new window, auto-compact point and saved effort at once, without waiting for the next turn.

Click it to open a picker column around the badge, in the order haiku sonnet opus fable (MODEL_CHOICES): smaller models stack above the badge, larger ones below. A pick runs /model <alias> exactly as if you typed it, so Claude Code's own model changes, and whether it is saved as your default follows /model's own rules. Only one picker is open at a time: opening one closes the other.

When Claude Code keeps the model (/model answers "Kept model as …"), the mod shows a toast: Claude Code kept Opus 5.5: /model sonnet did not switch. Typing the command answers the same, so the toast suggests nothing. Plugins may not submit slash commands themselves.

Effort badge

The badge grows louder with the effort level:

EffortDrawn as
low· low, dimmed
medium○ medium
high● high, bold orange
xhigh◆ xhigh, bold warning
max ▲ MAX , filled error badge
ultracode ✦ ULTRACODE · <LEVEL> , filled orange badge (beats every level)
token budgeteffort 32.0k budget
none (a model that takes no effort)– no effort, dimmed

Click it to open a picker column around the badge, in the order low medium high xhigh max ultracode: lower levels stack above the badge, higher ones below it, and the ultracode switch comes last. Click the badge again to close it. The details/hide toggle sits at the end of row 3, away from the picker. A pick runs /effort <choice> exactly as if you typed it ($.command.run), so Claude Code's own setting changes. Whether it is saved as the default follows /effort's own rules. If /effort refuses, caps or overrides the pick (ultracode unavailable, an organization's cap, CLAUDE_CODE_EFFORT_LEVEL), the badge shows what is in force and a toast quotes why. If it prints nothing the mod can read, the badge shows what was picked. Ultracode rides on top of a level: picking a level while it is on changes the level and keeps ultracode on, as /effort itself does (badge ✦ ULTRACODE · XHIGH). Only the last option, ✦ ultracode off (/effort ultracode off), turns it off.

The level comes from these sources:

  • command.run on /effort (picked from the badge, or typed where the hook sees it): the mod reads the output: "Set effort level to …", "Ultracode on/off", "Effort stays …".
  • prompt.submit of a typed /effort <level>, /effort ultracode [on|off]: read from the arguments. max and ultracode are session-only and saved nowhere else the mod could read.
  • turn.step on the main thread (no agentId): the effort every request actually sends, after any downgrade for the model. A request with none means the model takes no effort (– no effort). Subagents' requests are ignored.
  • A /config row named effort…, when this build has one: a starting value, read only until a request or a pick names the level.
  • Saved settings, polled every 1.4 s and after a model switch: modelSettings[<model>].effortLevel (the key matched by id or by the name /model shows), else effortLevel. This catches Claude Code's own /effort panel, which no hook sees. It applies only when the saved value changes.

Ultracode starts off in every session (it is per session). A prompt that only mentions the word "ultracode" does not turn the badge on.

The mascot (Clawd)

Clawd as the Claude Code welcome banner draws him, in the frame's left corner, sitting behind a keyboard: two rows of keycaps on a dark gray case (a key, the spacebar and a key on top; six keys below; CLAWD.keyboard).

  • Idle: blinks for one tick (0.7 s) about every 4.2 s (every 6th tick). Shut eyes are thin dark lines (CLAWD.shutEye), so he never looks eyeless.
  • While Claude works (isWorking): he types. One arm reaches down at a time (CLAWD.typing) and a key under that hand lights up blue, a different one each frame (litKey, from CLAWD.keysLeft / CLAWD.keysRight).
  • He dresses for the effort level (outfitFor, clawdRows):
EffortOutfit
lownone
mediuma blue cap, facing front
highthe cap plus a bottle in a holder strapped to each side, straws running into his face
xhighcap, bottles and straws, plus glasses with round white rims around his eyes
max, ultracodea tall purple magician's hat with a gold band, and nothing else

He is 13 cells wide in every outfit, so the stats beside him never move.

  • He feels the context fill up. His mood follows the bar's colour (fillColor, read by moodFor), so the two always change together, count-up included:
BarMoodClawd
orangecalmas above
warningannoyedember body (#e5603f) and flat eyes (▂, a -_- look; behind the glasses a line through the middle of each lens, ━, where the open eye sits); he still blinks and types as usual
errorfumingred body (#e5383b) and eyes squeezed shut (> <), so he does not blink; awake, he shakes a cell left and right each frame (his hat, cap and bottles with him, the keyboard stays), and the key he hits flashes amber (warning)

The body colours are raw, so he looks the same in every theme (MOOD_BODY, MOOD_EYES). His outfit, width and height never change with the mood. In Claude Code Desktop the picture's alt text names it (Clawd, fuming, typing).

  • Asleep: after 5 minutes without activity (the sleepAfterMinutes setting; 0 keeps him awake), he leaves the keyboard (his legs show again in its place), his eyes close (inside the glasses too) and "zZzZ" grows out from the top-left of his head, in an extra row above him and any hat. He keeps his outfit and his mood's colour while asleep, but rests: no squeezed eyes, no shaking. A prompt, a model request or a new reading wakes him (markActive).
  • Frames live in the CLAWD constant in hooks/register.tsx; the timing in TICK_MS (tick length) and BLINK_EVERY (ticks between blinks).

The timer

All animation runs on two $.clock.every intervals. The ticker (startTicker) ticks every 0.7 s (TICK_MS): Clawd's frames, the highlight's fade and, every second tick, the polls. The counter (startCounter) ticks every 100 ms (TWEEN_MS) only while a changed number counts up, and stops once it lands, so an idle band makes about 86 dispatches a minute. Work that a hook starts with sleep loops dies when that hook's dispatch ends, because each hook has a time budget and sleeps count against it. An engine interval runs until it is cancelled or the module reloads. session.start starts the ticker and lands any count-up a reload interrupted. session.measure, prompt.submit and /effort restart it if it is missing, because a /clear fires no new session.start. session.end stops it, except on /clear and /resume, which keep it and start the meter over 0.3 s later. It runs only while a terminal or Claude Code Desktop draws the band.

Every /effort writes a line to the debug log (claude --debug): context-meter: /effort <args> → "<output>" (applied: …). If /effort was given a level and the mod could not read its output, the line also goes to the transcript.

When it refreshes

EventWhat happens
session.startreads the engine's version (whether Text takes mono); first reading; starts the mascot's animation loop where a terminal draws the band (the REPL); -p and SDK runs, which draw nothing, run none of the band's work
session.attach / session.detachClaude Code Desktop joining starts the band's work with a fresh reading; the last drawing client leaving stops it
turn.completethe end of a main-thread turn: a reading with breakdown, the turn's growth and a trend point
session.measurea plain reading, after each turn and whenever a rate-limit window moves (mid-turn too)
turn.stepeach main-thread request: its effort level, then a live reading from the response's own usage, and the cache's share and lifetime
session.compacta main-conversation compaction: records what it freed and redraws (the compact button records its own)
session.end/clear and /resume: start the meter over; anything else: stops the animation loop
prompt.submitwakes Clawd; reads a typed /effort …
command.run (effort)reads the level and ultracode state back from /effort's output
command.run (model)reads the model back ($.session.model()) once /model ran; a new model gets a full reading
ui.render (AbovePrompt)draws the band from the stored state; yields to surveys (hasSurvey)

Turn ends, model switches and compactions call $.session.usage({ breakdown: 'summary' }). A summary breakdown is estimated locally — no token-count API calls, no cost. ('full' would count with the API, as /context does.) Live readings and cost-only measures read the plain figures, which cost nothing.

Files

FilePurpose
.claude-plugin/plugin.jsonmanifest (name, version, description, types)
hooks/hooks.jsonpoints to the hooks module
hooks/register.tsxall logic: refresh, animation, rendering, formatting helpers
types/index.d.tsstate contract under context-meter: snap, trend, frame, effort, model, the pickers' open flags, pace, cache, cacheTtl, compactStep, …
tests/meter.test.tsformatting, band contents and redraw, layouts, model switches, live readings, /clear, /resume, /compact, blink timing
README.mdthis document

Common edits

  • Change the colors — the thresholds in fillColor and the color="claude" props in the ui.render hook. Any theme key works (success, warning, error, subtle, permission, …).
  • Bar width — const width = Math.max(10, Math.min(40, …)) in the render hook.
  • One-line cut-off — isCompact in the render hook (planMeter decides what the meter row keeps, fitGroups what rows 2 and 3 keep).
  • Trend length — TREND_LEN (shown), TREND_KEEP (stored).
  • Add a metric — add a field to Snapshot in types/index.d.ts, fill it in toSnapshot, print it in line2 / line3.
  • **More cate
Source 2 files
hooks/register.tsx 2307 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionUsage, TurnStepInput, TurnUsage } from 'claude-code'
3
4import type { CacheReading, CacheTtl, Effort, Limit, LimitPace, Snapshot } from '../types'
5
6const snap = atom({ plugin: 'context-meter', key: 'snap' } as const, null)
7const effort = atom({ plugin: 'context-meter', key: 'effort' } as const, { isUltra: false } as Effort)
8const isPickerOpen = atom({ plugin: 'context-meter', key: 'isPickerOpen' } as const, false)
9const model = atom({ plugin: 'context-meter', key: 'model' } as const, null as string | null)
10const isModelPickerOpen = atom({ plugin: 'context-meter', key: 'isModelPickerOpen' } as const, false)
11const tweenStep = atom({ plugin: 'context-meter', key: 'tweenStep' } as const, 0)
12const trend = atom({ plugin: 'context-meter', key: 'trend' } as const, [])
13const frame = atom({ plugin: 'context-meter', key: 'frame' } as const, 0)
14const awakeTick = atom({ plugin: 'context-meter', key: 'awakeTick' } as const, 0)
15const showDetails = atom({ plugin: 'context-meter', key: 'showDetails' } as const, false)
16const readError = atom({ plugin: 'context-meter', key: 'readError' } as const, null as string | null)
17const hasMono = atom({ plugin: 'context-meter', key: 'hasMono' } as const, false)
18const pace = atom({ plugin: 'context-meter', key: 'pace' } as const, {} as Record<string, LimitPace>)
19const cache = atom({ plugin: 'context-meter', key: 'cache' } as const, {} as CacheReading)
20const cacheTtl = atom({ plugin: 'context-meter', key: 'cacheTtl' } as const, null as CacheTtl | null)
21const compactStep = atom({ plugin: 'context-meter', key: 'compactStep' } as const, 'none' as 'none' | 'ask' | 'running')
22
23const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err))
24
25/**
26 * Clawd, the Claude Code mascot, as the welcome banner draws him.
27 *
28 * His rows are 9 cells wide. He sits behind an 11-cell keyboard that hides his
29 * legs; his arms are the corner nubs of the body row.
30 */
31const CLAWD = {
32  eyesOpen: ' ▐▛███▜▌ ',
33  /** Shut eyes: the eye cells as a thin dark line on orange, so he still has eyes. */
34  shutEye: '▁',
35  body: '▝▜█████▛▘',
36  /** Typing: left arm down, then right arm down. */
37  typing: ['▗▜█████▛▘', '▝▜█████▛▖'],
38  /** Asleep, away from the keyboard: his four legs, in columns 2, 3, 5 and 6. */
39  legs: '  ▘▘ ▝▝  ',
40  /**
41   * Two rows of keycaps on the case, 11 cells: a key, the spacebar and a key
42   * on top; six keys below. Each cap is the upper half of its cell, so the
43   * case shows between rows and between keys.
44   */
45  keyboard: [' ▀ ▀▀▀▀▀ ▀ ', '▀ ▀ ▀ ▀ ▀ ▀'],
46  /** The keys under each hand, as [row, first cell, end cell]; the spacebar is under both. */
47  keysLeft: [
48    [1, 0, 1],
49    [1, 2, 3],
50    [1, 4, 5],
51    [0, 1, 2],
52    [0, 3, 8],
53  ] as const,
54  keysRight: [
55    [1, 6, 7],
56    [1, 8, 9],
57    [1, 10, 11],
58    [0, 9, 10],
59    [0, 3, 8],
60  ] as const,
61  /** Asleep: a row above his head where "zZzZ" grows out from its top-left. */
62  zzz: ['   z     ', '  Zz     ', ' zZz     ', 'ZzZz     ', '         '],
63}
64
65/** What Clawd wears for the effort: each level adds to the last, max and ultracode swap it all for a top hat. */
66export type Outfit = 'none' | 'cap' | 'cap-bottles' | 'cap-bottles-glasses' | 'top-hat'
67
68export function outfitFor(e: Effort): Outfit {
69  if (e.isUltra) return 'top-hat'
70  switch (e.level) {
71    case 'medium':
72      return 'cap'
73    case 'high':
74      return 'cap-bottles'
75    case 'xhigh':
76      return 'cap-bottles-glasses'
77    case 'max':
78      return 'top-hat'
79    default:
80      return 'none'
81  }
82}
83
84/** How Clawd takes the context filling up: calm while the bar is orange, annoyed once it warns, fuming once it is red. */
85export type Mood = 'calm' | 'annoyed' | 'fuming'
86
87/** Clawd's mood from the bar's colour (`fillColor`), so the two always change together. */
88function moodFor(fill: string): Mood {
89  return fill === 'error' ? 'fuming' : fill === 'warning' ? 'annoyed' : 'calm'
90}
91
92export type ClawdPiece = { text: string; color?: string; bg?: string; bold?: boolean }
93
94const PAD = '  '
95const CAP_COLOR = 'suggestion'
96const BOTTLE_COLOR = 'success'
97const STRAW_COLOR = 'text'
98const HAT_COLOR = 'merged' // purple in the built-in themes
99const BAND_COLOR = 'warning'
100const GLASSES_COLOR = 'text' // white rims on a dark theme, so they stand out on his orange face
101const KEYBOARD_COLOR = 'subtle' // the case, showing between the keycaps
102const KEY_COLOR = 'inactive'
103const KEY_LIT_COLOR = 'suggestion'
104/** Fuming, the key he hits flashes amber: a red one would vanish under his red arm. */
105const KEY_FUMING_COLOR = 'warning'
106/** His body by mood: Claude orange, ember, red. Raw colours, so he looks the same in every theme. */
107const MOOD_BODY: Record<Mood, string> = { calm: 'claude', annoyed: '#e5603f', fuming: '#e5383b' }
108/** His eyes, left and right, when he is not calm: flat (annoyed), squeezed shut (fuming). */
109const MOOD_EYES: Record<Exclude<Mood, 'calm'>, readonly [string, string]> = { annoyed: ['▂', '▂'], fuming: ['>', '<'] }
110/** Inside the glasses, flat eyes are a line through the middle of each lens, where the open `●` sits. */
111const GLASSES_EYES: Record<Exclude<Mood, 'calm'>, readonly [string, string]> = { annoyed: ['━', '━'], fuming: ['>', '<'] }
112
113/**
114 * Clawd's rows as colored pieces, 13 cells wide: his 9 plus 2 on each side,
115 * where the high-effort bottles hang. The width never changes with the outfit,
116 * so the stats beside him stay put. `shake` moves him a cell left (-1) or
117 * right (1), keyboard aside.
118 */
119export function clawdRows(o: {
120  outfit: Outfit
121  isShut: boolean
122  typing?: number
123  zzz?: string
124  mood?: Mood
125  shake?: number
126}): ClawdPiece[][] {
127  const mood = o.mood ?? 'calm'
128  const C = MOOD_BODY[mood]
129  const side = (text: string, color?: string): ClawdPiece => ({ text, color })
130  const hasBottles = o.outfit === 'cap-bottles' || o.outfit === 'cap-bottles-glasses'
131  const hasCap = o.outfit === 'cap' || hasBottles
132  const rows: ClawdPiece[][] = []
133  if (o.zzz !== undefined) rows.push([side(PAD), { text: o.zzz, color: 'subtle', bold: true }, side(PAD)])
134
135  if (o.outfit === 'top-hat') {
136    rows.push([side(`${PAD}  ▄▄▄▄▄  ${PAD}`, HAT_COLOR)])
137    rows.push([side(`${PAD}  █████  ${PAD}`, HAT_COLOR)])
138    rows.push([side(`${PAD}  `), { text: '█████', color: BAND_COLOR }, side(`  ${PAD}`)])
139    rows.push([side(`${PAD}▄▄█████▄▄${PAD}`, HAT_COLOR)])
140  }
141  // A cap seen from the front: a small crown on top, then the dome flaring into the brim,
142  // as wide as his head. With bottles, each one sits in a holder strapped to the cap's side.
143  if (hasCap) {
144    rows.push([
145      hasBottles ? side(' ▄', BOTTLE_COLOR) : side(PAD),
146      { text: '   ▄▄▄   ', color: CAP_COLOR },
147      hasBottles ? side('▄ ', BOTTLE_COLOR) : side(PAD),
148    ])
149    rows.push(
150      hasBottles
151        ? [
152            side(' █', BOTTLE_COLOR),
153            { text: '━▟█████▙━', color: CAP_COLOR },
154            side('█ ', BOTTLE_COLOR),
155          ]
156        : [side(PAD), { text: ' ▟█████▙ ', color: CAP_COLOR }, side(PAD)],
157    )
158  }
159
160  // Straws run from each bottle's bottom down and into his face.
161  const left = hasBottles ? side(' ╰', STRAW_COLOR) : side(PAD)
162  const right = hasBottles ? side('╯ ', STRAW_COLOR) : side(PAD)
163  const inner = (text: string): ClawdPiece => (hasBottles ? side('─', STRAW_COLOR) : side(text))
164  // Eyes drawn on his face (shut, flat or squeezed) take the colour of what ▛ and ▜ leave
165  // empty: dark on a dark theme, light on a light one. Squeezed ones are bold.
166  const isSqueezed = mood === 'fuming' && !o.isShut
167  const ink = (text: string): ClawdPiece =>
168    isSqueezed ? { text, color: 'inverseText', bg: C, bold: true } : { text, color: 'inverseText', bg: C }
169  if (o.outfit === 'cap-bottles-glasses') {
170    // Round white rims around each eye, a bridge between them; the eyes shut inside the rims.
171    const rim = (text: string): ClawdPiece => ({ text, color: GLASSES_COLOR, bg: C, bold: true })
172    const [l, r] = o.isShut ? ['─', '─'] : mood === 'calm' ? ['●', '●'] : GLASSES_EYES[mood]
173    rows.push([left, inner(' '), rim('('), ink(l), rim(')─('), ink(r), rim(')'), inner(' '), right])
174  } else if (o.isShut || mood !== 'calm') {
175    const [l, r] = o.isShut || mood === 'calm' ? [CLAWD.shutEye, CLAWD.shutEye] : MOOD_EYES[mood]
176    rows.push([
177      left,
178      inner(' '),
179      { text: '▐', color: C },
180      ink(l),
181      { text: '███', color: C },
182      ink(r),
183      { text: '▌', color: C },
184      inner(' '),
185      right,
186    ])
187  } else {
188    rows.push([left, inner(' '), { text: CLAWD.eyesOpen.trim(), color: C }, inner(' '), right])
189  }
190  // He sits behind a keyboard. Typing, one arm reaches down at a time and a key under it lights up.
191  const t = o.typing
192  const body = (t === undefined ? undefined : CLAWD.typing[t % CLAWD.typing.length]) ?? CLAWD.body
193  rows.push([side(PAD), { text: body, color: C }, side(PAD)])
194  // Asleep he has left the keyboard: his legs show again where it stood.
195  if (o.zzz !== undefined) rows.push([side(PAD), { text: CLAWD.legs, color: C }, side(PAD)])
196  // Shaking, all of him moves; the keyboard stays put.
197  const by = o.shake ?? 0
198  const placed = by === 0 ? rows : rows.map(row => shifted(row, by))
199  if (o.zzz !== undefined) return placed
200  const lit = t === undefined ? undefined : litKey(t)
201  CLAWD.keyboard.forEach((line, r) => {
202    const cells = [...line]
203    const cap = (text: string, isLit = false): ClawdPiece => ({
204      text,
205      color: isLit ? (mood === 'fuming' ? KEY_FUMING_COLOR : KEY_LIT_COLOR) : KEY_COLOR,
206      bg: KEYBOARD_COLOR,
207      bold: isLit,
208    })
209    const pieces =
210      lit && lit[0] === r
211        ? [
212            cap(cells.slice(0, lit[1]).join('')),
213            cap(cells.slice(lit[1], lit[2]).join(''), true),
214            cap(cells.slice(lit[2]).join('')),
215          ].filter(p => p.text !== '')
216        : [cap(line)]
217    placed.push([side(' '), ...pieces, side(' ')])
218  })
219  return placed
220}
221
222/**
223 * A row of Clawd moved a cell left (`by` < 0) or right (`by` > 0): a blank cell leaves one
224 * edge and joins the other, so the row keeps its width. A row with no blank on that edge stays.
225 */
226function shifted(row: readonly ClawdPiece[], by: number): ClawdPiece[] {
227  const first = row[0]
228  const last = row[row.length - 1]
229  if (by < 0 && first?.bg === undefined && first?.text.startsWith(' ')) {
230    const rest = first.text.slice(1)
231    return [...(rest ? [{ ...first, text: rest }] : []), ...row.slice(1), { text: ' ' }]
232  }
233  if (by > 0 && last?.bg === undefined && last?.text.endsWith(' ')) {
234    const rest = last.text.slice(0, -1)
235    return [{ text: ' ' }, ...row.slice(0, -1), ...(rest ? [{ ...last, text: rest }] : [])]
236  }
237  return [...row]
238}
239
240/**
241 * Theme colours as Claude Code's dark and light themes draw them, for the Svg Clawd: an Svg
242 * is drawn isolated from the page, so theme keys mean nothing inside it. The page's light or
243 * dark picks the set, so he looks as the terminal draws him in that theme. Raw colours pass through.
244 */
245const SVG_DARK: Record<string, string> = {
246  claude: '#d77757',
247  suggestion: '#b1b9f9',
248  success: '#4eba65',
249  warning: '#ffc107',
250  error: '#ff6b80',
251  merged: '#af87ff',
252  text: '#ffffff',
253  inverseText: '#000000',
254  inactive: '#999999',
255  subtle: '#505050',
256}
257const SVG_LIGHT: Record<string, string> = {
258  claude: '#d77757',
259  suggestion: '#5769f7',
260  success: '#2c7a39',
261  warning: '#966c1e',
262  error: '#ab2b3f',
263  merged: '#8700ff',
264  text: '#000000',
265  inverseText: '#ffffff',
266  inactive: '#666666',
267  subtle: '#afafaf',
268}
269/** One terminal cell in the Svg, in CSS pixels: twice as tall as wide, as a terminal draws it. */
270const CELL_W = 8
271const CELL_H = 16
272
273/** The quarters of a cell each block glyph fills: left-top, right-top, left-bottom, right-bottom. */
274const QUADS: Record<string, readonly [number, number, number, number]> = {
275  '█': [1, 1, 1, 1],
276  '▀': [1, 1, 0, 0],
277  '▄': [0, 0, 1, 1],
278  '▌': [1, 0, 1, 0],
279  '▐': [0, 1, 0, 1],
280  '▘': [1, 0, 0, 0],
281  '▝': [0, 1, 0, 0],
282  '▖': [0, 0, 1, 0],
283  '▗': [0, 0, 0, 1],
284  '▛': [1, 1, 1, 0],
285  '▜': [1, 1, 0, 1],
286  '▙': [1, 0, 1, 1],
287  '▟': [0, 1, 1, 1],
288  '▚': [1, 0, 0, 1],
289  '▞': [0, 1, 1, 0],
290}
291
292/** A colour in the Svg: the dark theme's as the attribute, and for a theme key the class that follows the page. */
293type SvgPaint = { color: string; key?: string }
294
295const svgPaint = (c: string): SvgPaint => {
296  const dark = SVG_DARK[c]
297  return dark === undefined ? { color: c } : { color: dark, key: c }
298}
299const fillOf = (p: SvgPaint) => `fill="${p.color}"${p.key ? ` class="f-${p.key}"` : ''}`
300const strokeOf = (p: SvgPaint) => `stroke="${p.color}"${p.key ? ` class="s-${p.key}"` : ''}`
301const escapeXml = (s: string) => s.replace(/[<>&"']/g, ch => `&#${ch.charCodeAt(0)};`)
302
303/** The theme classes, dark first and light on a light page (a CSS rule outranks the attribute). */
304const SVG_STYLE = (() => {
305  const rules = (set: Record<string, string>) =>
306    Object.entries(set)
307      .map(([k, v]) => `.f-${k}{fill:${v}}.s-${k}{stroke:${v}}`)
308      .join('')
309  return `<style>${rules(SVG_DARK)}@media (prefers-color-scheme: light){${rules(SVG_LIGHT)}}</style>`
310})()
311
312/** One glyph of a cell at (x, y) as Svg markup: blocks as rects, the line-drawing ones as strokes. */
313function glyphSvg(ch: string, x: number, y: number, paint: SvgPaint, isBold: boolean): string {
314  const w = CELL_W
315  const h = CELL_H
316  const rect = (rx: number, ry: number, rw: number, rh: number) =>
317    `<rect x="${rx}" y="${ry}" width="${rw}" height="${rh}" ${fillOf(paint)}/>`
318  const stroke = (d: string, width: number) =>
319    `<path d="${d}" fill="none" ${strokeOf(paint)} stroke-width="${width}" stroke-linecap="round"/>`
320  const q = QUADS[ch]
321  if (q) {
322    const [lt, rt, lb, rb] = q
323    if (lt && rt && lb && rb) return rect(x, y, w, h)
324    const half = (left: number, right: number, top: number) =>
325      left && right ? rect(x, top, w, h / 2) : (left ? rect(x, top, w / 2, h / 2) : '') + (right ? rect(x + w / 2, top, w / 2, h / 2) : '')
326    return half(lt, rt, y) + half(lb, rb, y + h / 2)
327  }
328  switch (ch) {
329    case ' ':
330      return ''
331    case '▁':
332      return rect(x, y + h - h / 8, w, h / 8)
333    case '▂':
334      return rect(x, y + h - h / 4, w, h / 4)
335    // squeezed eyes, tip and ends centred on the cell's middle, where an open pupil (●) sits
336    case '>':
337      return stroke(`M${x + 2} ${y + 4} L${x + w - 2} ${y + h / 2} L${x + 2} ${y + h - 4}`, isBold ? 2 : 1.5)
338    case '<':
339      return stroke(`M${x + w - 2} ${y + 4} L${x + 2} ${y + h / 2} L${x + w - 2} ${y + h - 4}`, isBold ? 2 : 1.5)
340    case '─':
341      return rect(x, y + h / 2 - (isBold ? 1 : 0.5), w, isBold ? 2 : 1)
342    case '━':
343      return rect(x, y + h / 2 - 1.5, w, 3)
344    case '╰':
345      return stroke(`M${x + w / 2} ${y} V${y + h / 2 - 2} Q${x + w / 2} ${y + h / 2} ${x + w / 2 + 2} ${y + h / 2} H${x + w}`, 1.5)
346    case '╯':
347      return stroke(`M${x + w / 2} ${y} V${y + h / 2 - 2} Q${x + w / 2} ${y + h / 2} ${x + w / 2 - 2} ${y + h / 2} H${x}`, 1.5)
348    case '(':
349      return stroke(`M${x + w - 2} ${y + 3} Q${x + 1} ${y + h / 2} ${x + w - 2} ${y + h - 3}`, isBold ? 2 : 1.5)
350    case ')':
351      return stroke(`M${x + 2} ${y + 3} Q${x + w - 1} ${y + h / 2} ${x + 2} ${y + h - 3}`, isBold ? 2 : 1.5)
352    case '●':
353      return `<circle cx="${x + w / 2}" cy="${y + h / 2}" r="${w * 0.35}" ${fillOf(paint)}/>`
354    default:
355      return `<text x="${x + w / 2}" y="${y + h * 0.75}" text-anchor="middle" font-family="ui-monospace, Menlo, Consolas, monospace" font-size="${h * 0.8}"${isBold ? ' font-weight="bold"' : ''} ${fillOf(paint)}>${escapeXml(ch)}</text>`
356  }
357}
358
359/**
360 * Clawd's rows (`clawdRows`) as one Svg, for surfaces that draw Text in a page font: there
361 * the block glyphs leave gaps between lines and the cells lose their width. Every cell is
362 * drawn as the terminal would fill it, so he looks the same: what a glyph leaves empty (his
363 * eyes, as in the welcome banner) shows what is behind him.
364 */
365export function clawdSvg(rows: readonly (readonly ClawdPiece[])[]): { source: string; width: number; height: number } {
366  const parts: string[] = []
367  rows.forEach((row, r) => {
368    let col = 0
369    for (const p of row) {
370      const paint = svgPaint(p.color ?? 'text')
371      const bg = p.bg === undefined ? undefined : svgPaint(p.bg)
372      for (const ch of p.text) {
373        const x = col * CELL_W
374        const y = r * CELL_H
375        if (bg) parts.push(`<rect x="${x}" y="${y}" width="${CELL_W}" height="${CELL_H}" ${fillOf(bg)}/>`)
376        parts.push(glyphSvg(ch, x, y, paint, p.bold === true))
377        col++
378      }
379    }
380  })
381  const width = 13 * CELL_W
382  const height = rows.length * CELL_H
383  const source =
384    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" shape-rendering="crispEdges">` +
385    SVG_STYLE +
386    parts.join('') +
387    '</svg>'
388  return { source, width, height }
389}
390
391/**
392 * The key that lights up on typing frame `t`: one under the arm that is down
393 * (left on even frames, right on odd), picked pseudo-randomly from the frame.
394 */
395export function litKey(t: number): readonly [number, number, number] {
396  const keys = t % 2 === 0 ? CLAWD.keysLeft : CLAWD.keysRight
397  const pick = Math.floor(t / 2) * 7 + 3 // walks the keys in a jumbled order
398  return keys[pick % keys.length] ?? keys[0]
399}
400
401const TICK_MS = 700
402const BLINK_EVERY = 6 // ticks: a 0.7 s blink about every 4 s
403const TREND_KEEP = 16
404const HIGHLIGHT_TICKS = 14 // a changed number stays highlighted about 10 s
405const HL_COLOR = 'suggestion'
406const TWEEN_STEPS = 60 // a changed number counts up in 60 steps…
407const TWEEN_MS = 100 // …of 0.1 s: 6 s in all
408
409/** Ease-out: fast at first, settling onto the new value. */
410export function tween(from: number, to: number, step: number): number {
411  if (step >= TWEEN_STEPS) return to
412  const t = Math.max(0, step) / TWEEN_STEPS
413  return from + (to - from) * (1 - (1 - t) ** 3)
414}
415const SLEEP_AFTER_MINUTES = 5
416
417/**
418 * Frames without activity before Clawd falls asleep, from the `sleepAfterMinutes` setting:
419 * 0 keeps him awake; anything but a number of at least 0 keeps the default 5 minutes.
420 */
421export function sleepTicks(minutes: unknown): number {
422  const m = typeof minutes === 'number' && Number.isFinite(minutes) && minutes >= 0 ? minutes : SLEEP_AFTER_MINUTES
423  return m === 0 ? Infinity : Math.round((m * 60_000) / TICK_MS)
424}
425
426/** Set from the plugin's options each time the module loads (a settings change reloads it). */
427let sleepAfterTicks = sleepTicks(SLEEP_AFTER_MINUTES)
428
429/**
430 * Bar color by how far the fill is toward the auto-compact point (as a percent of the
431 * window; with none known, 80 % of it): orange, `warning` from `warnAt` % of the way (three
432 * quarters by default), `error` from `alertAt` % (95).
433 */
434export function fillColor(percent: number, compactPercent?: number, warnAt = 75, alertAt = 95): string {
435  const toward = compactPercent !== undefined && compactPercent > 0 ? compactPercent : 80
436  const share = (percent * 100) / toward
437  return share >= alertAt ? 'error' : share >= warnAt ? 'warning' : 'claude'
438}
439
440export type MeterRun = { text: string; kind: 'old' | 'new' | 'empty' | 'mark' }
441
442/**
443 * The bar as styled runs: the fill that was there before (`old`), the part
444 * the last reading added (`new`), the empty rest, and a `┊` mark where
445 * auto-compaction runs.
446 */
447export function meterRuns(args: {
448  percent: number
449  prevPercent?: number
450  compactFraction?: number
451  width: number
452}): MeterRun[] {
453  const { percent, prevPercent, compactFraction, width } = args
454  const { filled, empty } = bar(percent, width)
455  const chars = [...filled]
456  const oldLen =
457    prevPercent !== undefined && prevPercent < percent
458      ? Math.min(chars.length, Math.floor((Math.max(0, prevPercent) / 100) * width))
459      : chars.length
460  const rest = [...empty]
461  let mark = -1
462  if (compactFraction !== undefined) {
463    mark = Math.min(width - 1, Math.floor(compactFraction * width)) - chars.length
464    if (mark >= 0 && mark < rest.length) rest[mark] = '┊'
465    else mark = -1
466  }
467  const runs: MeterRun[] = [
468    { text: chars.slice(0, oldLen).join(''), kind: 'old' },
469    { text: chars.slice(oldLen).join(''), kind: 'new' },
470  ]
471  if (mark < 0) {
472    runs.push({ text: rest.join(''), kind: 'empty' })
473  } else {
474    runs.push(
475      { text: rest.slice(0, mark).join(''), kind: 'empty' },
476      { text: '┊', kind: 'mark' },
477      { text: rest.slice(mark + 1).join(''), kind: 'empty' },
478    )
479  }
480  return runs.filter(r => r.text.length > 0)
481}
482
483/**
484 * A piece of a stats row: plain (dim), highlighted (`hl`, a fresh change, in the colour of
485 * its move), or in a colour of its own (a warning, a move that is not fresh, `bold: false`).
486 */
487type Seg = { text: string; hl?: boolean; color?: string; bold?: boolean }
488
489const isPlain = (seg: Seg) => !seg.hl && seg.color === undefined
490
491/** Cells a text takes: one per character (the band draws no wide ones). */
492const cellsOf = (text: string) => [...text].length
493
494/**
495 * A group of a stats row, how much it matters (the lowest rank goes first) and, for some, a
496 * shorter form that keeps its main figure.
497 */
498export type Group = { segs: Seg[]; rank: number; short?: Seg[] }
499
500/**
501 * The groups that fit `room` cells joined by ` · `, in their order. While they do not fit,
502 * the short forms are taken first, then the least important group is left out (the lowest
503 * rank; of equal ranks, the later one), so a figure is shown whole or not at all, never cut
504 * at the row's edge.
505 */
506export function fitGroups(groups: readonly Group[], room: number): Seg[][] {
507  const kept = groups.map(g => ({ ...g }))
508  const width = () =>
509    kept.reduce((n, g, i) => n + (i > 0 ? 3 : 0) + g.segs.reduce((m, seg) => m + cellsOf(seg.text), 0), 0)
510  // the least important of `among`: the lowest rank, of equal ranks the later one
511  const leastOf = (among: readonly typeof kept[number][]) =>
512    among.reduce<typeof kept[number] | undefined>((least, g) => (least === undefined || g.rank <= least.rank ? g : least), undefined)
513  while (width() > room) {
514    const g = leastOf(kept.filter(k => k.short !== undefined))
515    if (g === undefined || g.short === undefined) break
516    g.segs = g.short
517    g.short = undefined
518  }
519  while (kept.length > 0 && width() > room) {
520    const g = leastOf(kept)
521    kept.splice(g === undefined ? 0 : kept.indexOf(g), 1)
522  }
523  return kept.map(g => g.segs)
524}
525
526const WAITING = 'waiting for the first reply'
527const METER_LABEL = 'Context '
528const PERCENT_CELLS = 7
529const MIN_BAR = 10
530const MAX_BAR = 40
531
532/** The forms of the meter row's tokens piece, richest first: `84.0k / 200.0k tokens`, `84.0k / 200.0k`, `84.0k`. */
533export type TokensForm = 'full' | 'short' | 'bare' | 'none'
534export type MeterPlan = { hasLabel: boolean; tokens: TokensForm; bar: number }
535
536/**
537 * How the meter row fits `room` cells, given the cells each tokens form takes: the richest
538 * row with a bar of at least MIN_BAR, leaving out whole pieces in turn (the word "tokens",
539 * the "Context" label, the window, the tokens). Failing all, the bar and the percent alone.
540 */
541export function planMeter(room: number, tokensCells: (form: TokensForm) => number): MeterPlan {
542  const forms = [
543    [true, 'full'],
544    [true, 'short'],
545    [false, 'short'],
546    [false, 'bare'],
547    [false, 'none'],
548  ] as const
549  for (const [hasLabel, tokens] of forms) {
550    const rest = room - (hasLabel ? cellsOf(METER_LABEL) : 0) - PERCENT_CELLS - tokensCells(tokens)
551    if (rest >= MIN_BAR) return { hasLabel, tokens, bar: Math.min(MAX_BAR, rest) }
552  }
553  return { hasLabel: false, tokens: 'none', bar: Math.max(0, Math.min(MAX_BAR, room - PERCENT_CELLS)) }
554}
555
556/** Groups joined by ` · `, adjacent plain pieces merged so the row stays few Texts. */
557function joinSegs(groups: Seg[][]): Seg[] {
558  const out: Seg[] = []
559  groups.forEach((g, i) => {
560    for (const seg of i > 0 ? [{ text: ' · ' }, ...g] : g) {
561      const last = out[out.length - 1]
562      if (last && isPlain(last) && isPlain(seg)) last.text += seg.text
563      else out.push({ ...seg })
564    }
565  })
566  return out
567}
568const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
569const SPARK = '▁▂▃▄▅▆▇█'
570
571export function fmtTokens(n: number | undefined): string {
572  if (n === undefined) return '–'
573  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`
574  if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
575  return String(Math.round(n))
576}
577
578/** A bar of `width` cells, filled to `percent` with eighth-block precision. */
579export function bar(percent: number, width: number): { filled: string; empty: string } {
580  const cells = (Math.max(0, Math.min(100, percent)) / 100) * width
581  const whole = Math.floor(cells)
582  const part = EIGHTHS[Math.round((cells - whole) * 8)] ?? ''
583  const filled = '█'.repeat(whole) + (whole < width ? part : '')
584  return { filled, empty: '░'.repeat(Math.max(0, width - [...filled].length)) }
585}
586
587/** Bars scaled between the lowest and highest value shown, so small moves stay visible. */
588export function sparkline(values: readonly number[]): string {
589  const lo = Math.min(...values)
590  const span = Math.max(...values) - lo
591  return values
592    .map(v => (span === 0 ? SPARK[3] : SPARK[Math.min(7, Math.max(0, Math.floor(((v - lo) / span) * 8)))]))
593    .join('')
594}
595
596const TREND_LEN = 8
597
598/**
599 * "trend ▁▂▃▅▇ 12→42%": the last TREND_LEN turn ends' tokens as a shape, then where the
600 * fill started and is now, as percents of `window` (the one shown now, so a model switch
601 * draws no spike). With no window known, the tokens themselves.
602 */
603export function trendText(tokens: readonly number[], window: number): string {
604  const shown = tokens.slice(-TREND_LEN)
605  const first = shown[0] ?? 0
606  const last = shown[shown.length - 1] ?? 0
607  const ends =
608    window > 0
609      ? `${Math.round((first / window) * 100)}→${Math.round((last / window) * 100)}%`
610      : `${fmtTokens(first)}→${fmtTokens(last)}`
611  return `trend ${sparkline(shown)} ${ends}`
612}
613
614/** Turn growths a forecast averages: the last few that grew (a compaction's drop is none). */
615const FORECAST_TURNS = 5
616
617/**
618 * About how many turns fit in `room` tokens: the room over the average growth of the last
619 * FORECAST_TURNS turns that grew, taken from the trend (tokens at each turn end), rounded up.
620 * Nothing with fewer than two growths, no room, or 100 turns or more.
621 */
622export function turnsLeft(trendTokens: readonly number[], room: number): number | undefined {
623  const growths = trendTokens
624    .slice(1)
625    .map((t, i) => t - (trendTokens[i] ?? t))
626    .filter(g => g > 0)
627    .slice(-FORECAST_TURNS)
628  if (growths.length < 2 || room <= 0) return undefined
629  const turns = Math.max(1, Math.ceil(room / (growths.reduce((a, b) => a + b, 0) / growths.length)))
630  return turns < 100 ? turns : undefined
631}
632
633/** "~6 turns", "~1 turn". */
634const turnsText = (n: number) => `~${n} turn${n === 1 ? '' : 's'}`
635
636/** The prompt cache lives 5 minutes or an hour after the last reply; until a request shows which, an hour. */
637const TTL_MS: Record<CacheTtl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
638
639/**
640 * What a main request sent `gapMs` after the last reply, on the same model and effort, tells
641 * of the cache's lifetime: still read from the cache past 5 minutes, an hour; written again
642 * before an hour, 5 minutes. Nothing too soon (with a 30 s margin) or too near the hour to
643 * tell, for a small request, or when neither side has most of it.
644 */
645export function ttlFrom(
646  gapMs: number,
647  u: { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number },
648): CacheTtl | undefined {
649  const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
650  if (total < 1_000 || gapMs <= TTL_MS['5m'] + 30_000 || gapMs >= TTL_MS['1h'] - 5 * 60_000) return undefined
651  if (u.cache_read_input_tokens >= total / 2) return '1h'
652  return u.cache_creation_input_tokens >= total / 2 ? '5m' : undefined
653}
654
655/** A window's pace is read over its readings of the last half hour, about one a minute... */
656const PACE_KEEP_MS = 30 * 60_000
657const PACE_EVERY_MS = 60_000
658/** ...once they span 10 minutes, and while the newest is at most 10 minutes old (you are at it). */
659const PACE_SPAN_MS = 10 * 60_000
660const PACE_IDLE_MS = 10 * 60_000
661
662/**
663 * Each rate-limit window's readings with this one added at `now`. A new window (another
664 * `resetsAt`) or a drop starts them over; they keep the last half hour, about one a minute
665 * (a newer reading within the minute takes the last one's place). Windows no longer
666 * reported are dropped.
667 */
668export function withReadings(
669  paces: Readonly<Record<string, LimitPace>>,
670  limits: readonly Limit[],
671  now: number,
672): Record<string, LimitPace> {
673  const next: Record<string, LimitPace> = {}
674  for (const l of limits) {
675    const old = paces[l.kind]
676    const last = old?.samples[old.samples.length - 1]
677    const isSame = old !== undefined && last !== undefined && old.resetsAt === l.resetsAt && l.percentUsed >= last.percent
678    const kept = isSame ? old.samples.filter(r => r.at >= now - PACE_KEEP_MS) : []
679    const beforeLast = kept[kept.length - 2]
680    if (beforeLast !== undefined && now - beforeLast.at < PACE_EVERY_MS) kept.pop()
681    next[l.kind] = { resetsAt: l.resetsAt, samples: [...kept, { at: now, percent: l.percentUsed }] }
682  }
683  return next
684}
685
686/**
687 * How long until a window is used up at its recent pace, in ms: from the rise over its kept
688 * readings. Nothing before they span PACE_SPAN_MS or rose a point, once they are older than
689 * PACE_IDLE_MS, or when the window resets first.
690 */
691export function fullIn(p: LimitPace | undefined, now: number): number | undefined {
692  const first = p?.samples[0]
693  const last = p?.samples[p.samples.length - 1]
694  if (p === undefined || first === undefined || last === undefined || last.percent >= 100) return undefined
695  const span = last.at - first.at
696  const rise = last.percent - first.percent
697  if (span < PACE_SPAN_MS || rise < 1 || now - last.at > PACE_IDLE_MS) return undefined
698  const fullAt = last.at + ((100 - last.percent) / rise) * span
699  const resetsAt = p.resetsAt === undefined ? NaN : Date.parse(p.resetsAt)
700  return fullAt > now && !(fullAt >= resetsAt) ? fullAt - now : undefined
701}
702
703/** "42m", "3h 5m", and from a day on "1d 2h". */
704export function duration(ms: number): string {
705  const minutes = Math.max(0, Math.floor(ms / 60_000))
706  const h = Math.floor(minutes / 60)
707  if (h >= 24) return `${Math.floor(h / 24)}d ${h % 24}h`
708  return h > 0 ? `${h}h ${minutes % 60}m` : `${minutes}m`
709}
710
711/**
712 * "resets 2h 14m": how long until a rate-limit window resets (`resetsAt`, ISO 8601), under a
713 * minute "resets <1m". Nothing when the time is unknown, unreadable or already past.
714 */
715export function resetText(resetsAt: string | undefined, now: number): string | undefined {
716  const ms = resetsAt === undefined ? NaN : Date.parse(resetsAt) - now
717  if (!(ms > 0)) return undefined
718  return `resets ${ms < 60_000 ? '<1m' : duration(ms)}`
719}
720
721/** The first engine whose Text takes `mono`. Older ones refuse a tree naming it and draw no band. */
722const MONO_SINCE = [2, 1, 299]
723
724/**
725 * Whether an engine of release `base` (`2.1.300`, `2.1.300-dev`) takes Text's `mono` prop.
726 * Claude Code Desktop bundles its own engine (2.1.295 on 2026-10-15), so this varies by
727 * surface. An unknown version counts as too old.
728 */
729export function takesMono(base: string | undefined): boolean {
730  const m = /^(\d+)\.(\d+)\.(\d+)/.exec(base ?? '')
731  if (m === null) return false
732  for (let i = 0; i < 3; i++) {
733    const have = Number(m[i + 1])
734    const need = MONO_SINCE[i] ?? 0
735    if (have !== need) return have > need
736  }
737  return true
738}
739
740const LIMIT_NAMES: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
741
742type EffortBadge = {
743  text: string
744  color?: string
745  backgroundColor?: string
746  bold?: boolean
747  dimColor?: boolean
748}
749
750/** How loudly the effort is drawn: quiet for low, a filled badge for max and ultracode. */
751export function effortBadge(e: Effort): EffortBadge | undefined {
752  const level = typeof e.level === 'number' ? `${fmtTokens(e.level)} budget` : e.level
753  if (e.isUltra) {
754    return {
755      text: ` ✦ ULTRACODE${level ? ` · ${String(level).toUpperCase()}` : ''} `,
756      color: 'inverseText',
757      backgroundColor: 'claude',
758      bold: true,
759    }
760  }
761  if (e.isUnsupported) return { text: '– no effort', dimColor: true }
762  switch (level) {
763    case undefined:
764      return undefined
765    case 'max':
766      return { text: ' ▲ MAX ', color: 'inverseText', backgroundColor: 'error', bold: true }
767    case 'xhigh':
768      return { text: '◆ xhigh', color: 'warning', bold: true }
769    case 'high':
770      return { text: '● high', color: 'claude', bold: true }
771    case 'medium':
772      return { text: '○ medium', color: 'text' }
773    case 'low':
774      return { text: '· low', dimColor: true }
775    default:
776      return { text: `effort ${level}`, color: 'text' }
777  }
778}
779
780/** The choices the effort picker offers, as `/effort <arg>` takes them. */
781export const EFFORT_CHOICES = ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode'] as const
782
783/**
784 * What `/effort` printed, read back as what is in force: "Set effort level to max (…)",
785 * "Ultracode on (this session only): … Effort stays medium.", "… · Ultracode off"; and where
786 * that is not what was asked, the level that holds: an environment variable's
787 * ("CLAUDE_CODE_EFFORT_LEVEL=high overrides this session …"), a cap's ("… set to 'high'
788 * instead"), the one kept ("Kept effort level as high"). A refused ultracode is off.
789 */
790export function parseEffortOutput(text: string): Partial<Effort> {
791  const found: Partial<Effort> = {}
792  const level =
793    /CLAUDE_CODE_EFFORT_LEVEL=(\w+) (?:overrides|still controls)/i.exec(text) ??
794    /set to '(\w+)' instead/i.exec(text) ??
795    /exceeds the cap for .*?; using '(\w+)'/i.exec(text) ??
796    /effort level to (\w+)/i.exec(text) ??
797    /effort stays (\w+)/i.exec(text) ??
798    /kept effort level as (\w+)/i.exec(text)
799  const word = level?.[1]?.toLowerCase()
800  if (word && word !== 'auto') found.level = word
801  if (/ultracode on\b/i.test(text)) found.isUltra = true
802  else if (/ultracode off\b/i.test(text) || /^ultracode (?:needs|isn't available)/i.test(text)) found.isUltra = false
803  return found
804}
805
806/**
807 * The output of a `/effort` that did not take effect as asked, as Claude Code words it:
808 * refused (ultracode unavailable, a failure, a session-only level a remote process cannot
809 * take), kept, capped, or overridden by `CLAUDE_CODE_EFFORT_LEVEL`. Undefined for a plain success.
810 */
811export function effortRefusal(text: string | undefined): string | undefined {
812  const t = text?.trim() ?? ''
813  const isRefused =
814    /^ultracode (?:needs|isn't available)/i.test(t) ||
815    /^failed to set effort level/i.test(t) ||
816    /^not applied:/i.test(t) ||
817    /^kept effort level as/i.test(t) ||
818    /won't reach the remote process/i.test(t) ||
819    /exceeds the cap for/i.test(t) ||
820    /CLAUDE_CODE_EFFORT_LEVEL=\w+ (?:overrides|still controls)/i.test(t)
821  return isRefused ? t : undefined
822}
823
824/**
825 * The `/config` row for effort, when this build has one: a starting value before the
826 * first request. Once a request or a pick named the level, `/config` is not read again.
827 */
828async function refreshEffort($: EngineInterface): Promise<void> {
829  try {
830    const known = await read($, effort)
831    if (known.level !== undefined || known.isUnsupported) return
832    const row = (await $.config.list()).find(r => /^effort/i.test(r.key) || /^effort/i.test(r.label))
833    const level = row?.value
834    if (typeof level !== 'string' && typeof level !== 'number') return
835    await update($, effort, old => (old.level !== undefined ? old : { ...old, level }))
836  } catch {
837    // no such row: the first request's effort fills it in
838  }
839}
840
841/**
842 * A model as settings tell it apart, for comparing names: the 1M variant is the same model
843 * ("claude-opus-5-5[1m]", "Opus 5.5 (1M context)" → "Opus 5.5"), and a version without a
844 * minor is its .0 ("claude-opus-4-20250514" → "Opus 4.0", as "claude-opus-4-0").
845 */
846const settingsModel = (name: string) =>
847  modelLabel(name.replace(/\[1m\]$/i, ''))
848    .replace(/ · 1M$/, '')
849    .replace(/^(\S+ \d+)$/, '$1.0')
850
851/**
852 * The saved effort for a model: `modelSettings[model].effortLevel`, else the first
853 * `modelSettings` entry naming the same model another way (settings key by id without the
854 * 1M suffix, the session may say "Opus 5.5" or name the 1M variant), else the top-level
855 * `effortLevel`.
856 */
857export function savedEffort(settings: Readonly<Record<string, unknown>>, model?: string): string | undefined {
858  const perModel = settings.modelSettings as Record<string, { effortLevel?: unknown } | undefined> | undefined
859  let level = model ? (perModel?.[model]?.effortLevel ?? perModel?.[model.replace(/\[1m\]$/i, '')]?.effortLevel) : undefined
860  if (level === undefined && model && perModel) {
861    const name = settingsModel(model)
862    level = Object.entries(perModel).find(([key, v]) => v?.effortLevel !== undefined && settingsModel(key) === name)?.[1]
863      ?.effortLevel
864  }
865  level ??= settings.effortLevel
866  return typeof level === 'string' ? level : undefined
867}
868
869let lastSaved: string | undefined
870
871/**
872 * Picks up an effort changed where no hook sees it (Claude Code's own /effort panel).
873 * Applies only when the saved value itself moved, so a session-only /effort or a
874 * request's effort is not overwritten by a stale saved one.
875 */
876async function pollSavedEffort($: EngineInterface): Promise<void> {
877  try {
878    // the polled model: the breakdown's name is stale after a switch until the next full reading
879    const [s, polled] = await Promise.all([read($, snap), read($, model)])
880    const level = savedEffort(await $.settings.read(), polled ?? s?.model)
881    if (level === undefined || level === lastSaved) return
882    const isFirst = lastSaved === undefined
883    lastSaved = level
884    // a newly saved level changes the level only; ultracode stays as it was
885    await update($, effort, old =>
886      isFirst && old.level !== undefined ? old : { ...old, level, isUnsupported: false },
887    )
888  } catch {
889    // settings unreadable: hooks and requests still fill it in
890  }
891}
892
893/**
894 * The effort a main-thread request carried, after any downgrade for the model. None
895 * means the model takes no effort, so the old level must not linger on the badge.
896 * Ultracode is left as it is: requests do not carry it.
897 */
898async function noteEffort($: EngineInterface, level: TurnStepInput['effort']): Promise<void> {
899  try {
900    await update($, effort, old =>
901      level === undefined
902        ? old.isUnsupported && old.level === undefined
903          ? old
904          : { ...old, level: undefined, isUnsupported: true }
905        : old.level === level && !old.isUnsupported
906          ? old
907          : { ...old, level, isUnsupported: false },
908    )
909  } catch {
910    // drawing state is best effort; never hold up the request
911  }
912}
913
914async function applyEffortOutput($: EngineInterface, text: string | undefined): Promise<boolean> {
915  const found = parseEffortOutput(text ?? '')
916  if (Object.keys(found).length === 0) return false
917  await update($, effort, old => ({ ...old, ...found }))
918  return true
919}
920
921/**
922 * What a typed `/effort <args>` asks for, read from the arguments alone.
923 * A level leaves ultracode as it is (Claude Code keeps it on across level changes);
924 * `ultracode` or `ultracode on` turns it on, `ultracode off` off. Bare `/effort`, `auto` and
925 * anything else give nothing.
926 */
927export function parseEffortArgs(args: string): Partial<Effort> | undefined {
928  const [word, flag, ...rest] = args.trim().toLowerCase().split(/\s+/)
929  if (rest.length > 0) return undefined
930  if (word === 'ultracode') {
931    if (flag === undefined || flag === 'on') return { isUltra: true }
932    return flag === 'off' ? { isUltra: false } : undefined
933  }
934  if (flag !== undefined) return undefined
935  return ['low', 'medium', 'high', 'xhigh', 'max'].includes(word ?? '') ? { level: word } : undefined
936}
937
938/** A prompt that is a typed `/effort …` command: what it asks for. */
939export function parseEffortCommand(text: string): Partial<Effort> | undefined {
940  const m = /^\s*\/effort(?:\s+(.*))?$/i.exec(text)
941  return m ? parseEffortArgs(m[1] ?? '') : undefined
942}
943
944/**
945 * Runs `/effort <args>` exactly as if typed. What it printed wins when the mod
946 * can read it; otherwise the badge shows what was asked for, since a session-only
947 * choice (max, ultracode) is saved nowhere the mod could read it back from.
948 */
949async function pickEffort($: EngineInterface, args: string): Promise<void> {
950  await update($, isPickerOpen, () => false)
951  try {
952    const result = await $.command.run({ command: 'effort', args })
953    // what is in force, where the output says; a pick that did not take as asked also says why
954    const isApplied = await applyEffortOutput($, result.text)
955    const refusal = effortRefusal(result.text)
956    if (refusal !== undefined) {
957      await $.ui.toast(refusal)
958    } else if (!isApplied) {
959      const asked = parseEffortArgs(args)
960      if (asked) await update($, effort, old => ({ ...old, ...asked }))
961      $.ui.log(`context-meter: /effort ${args} printed nothing readable; showing what was asked`, { to: 'debug' })
962    }
963  } catch (err) {
964    await $.ui.toast(`/effort ${args} failed: ${err instanceof Error ? err.message : String(err)}`)
965  }
966}
967
968/** The model families, smallest first: the picker's default entries, as `/model <alias>` takes them. */
969export const MODEL_CHOICES = ['haiku', 'sonnet', 'opus', 'fable'] as const
970type ModelChoice = (typeof MODEL_CHOICES)[number]
971
972/** A picker entry that names a family, with or without the 1M window: `opus`, `opus[1m]`. */
973const FAMILY_ENTRY = /^(haiku|sonnet|opus|fable)(\[1m\])?$/i
974const isLong = (name: string) => /\[1m\]|1m context/i.test(name)
975
976/** Whether a picker entry names the model in use: a family entry by family and window, a model id by its label. */
977function isCurrentModel(entry: string, current: string | null | undefined): boolean {
978  if (!current) return false
979  const m = FAMILY_ENTRY.exec(entry)
980  if (m) return modelFamily(current) === m[1]?.toLowerCase() && isLong(entry) === isLong(current)
981  return /^claude-/i.test(entry) && modelLabel(entry) === modelLabel(current)
982}
983
984/** A picker entry as it reads: a family or model id as the badge names it (`Opus · 1M`), any other alias as given (`best`). */
985const entryLabel = (entry: string) => (FAMILY_ENTRY.test(entry) || /^claude-/i.test(entry) ? modelLabel(entry) : entry)
986
987/** Which family a model name or id belongs to ("claude-opus-5-5", "Opus 5.5" → "opus"). */
988export function modelFamily(name: string | null | undefined): ModelChoice | undefined {
989  const lower = name?.toLowerCase() ?? ''
990  return MODEL_CHOICES.find(f => lower.includes(f))
991}
992
993/**
994 * A model as the badge shows it: "claude-opus-5-5" → "Opus 5.5", "claude-opus-4-20250514"
995 * → "Opus 4", "claude-3-5-haiku-20241022" → "Haiku 3.5", "claude-opus-5-5[1m]" →
996 * "Opus 5.5 · 1M"; an unknown name as given.
997 */
998export function modelLabel(name: string | null | undefined): string {
999  if (!name) return 'model ?'
1000  const family = modelFamily(name)
1001  if (!family) return name.replace(/`/g, '')
1002  // the version right after the family; a date is no minor version
1003  const after = new RegExp(`${family}[-_ ]?(\\d{1,2})(?:[-.](\\d{1,2}))?(?!\\d)`, 'i').exec(name)
1004  // older ids put it before the family: claude-3-5-haiku
1005  const before = after
1006    ? undefined
1007    : /(?<!\d)(\d{1,2})[-.](\d{1,2})[-.]/.exec(name.slice(0, name.toLowerCase().indexOf(family)))
1008  const [, major, minor] = after ?? before ?? []
1009  const title = family[0]?.toUpperCase() + family.slice(1)
1010  const version = major === undefined ? '' : minor === undefined ? ` ${major}` : ` ${major}.${minor}`
1011  const isLong = /\[1m\]|1m context/i.test(name)
1012  return `${title}${version}${isLong ? ' · 1M' : ''}`
1013}
1014
1015/** What `/model <alias>` printed: "Set model to Opus 5.5 …", "Kept model as `Opus 5.5`". */
1016export function parseModelOutput(text: string | undefined): string | undefined {
1017  const m = /(?:set model to|kept model as)\s+`?([^`\n(]+?)`?(?:\s+and saved\b|\s*\(|\s*$|\n)/i.exec(text ?? '')
1018  return m?.[1]?.trim() || undefined
1019}
1020
1021/**
1022 * The main loop's model, straight from the engine. A switch to another model (not
1023 * the same one named another way) changes the window, the auto-compact point and the
1024 * saved effort, and no measure follows a /model: read them all now.
1025 */
1026async function refreshModel($: EngineInterface): Promise<void> {
1027  try {
1028    const old = await read($, model)
1029    const name = await $.session.model()
1030    await update($, model, o => (o === name ? o : name))
1031    if (old !== null && modelLabel(old) !== modelLabel(name)) {
1032      // whether the new model takes effort is known at its first request, not from the old one's
1033      await update($, effort, e => (e.isUnsupported ? { ...e, isUnsupported: false } : e))
1034      await refresh($)
1035      await pollSavedEffort($)
1036    }
1037  } catch {
1038    // no model yet: the badge shows "model ?"
1039  }
1040}
1041
1042/**
1043 * Runs `/model <alias>`, then reads the model back from the engine. When Claude Code
1044 * keeps the old model (it answers "Kept model as …"), the mod says so; typing the
1045 * command answers the same, so it suggests nothing.
1046 */
1047async function pickModel($: EngineInterface, alias: string): Promise<void> {
1048  await update($, isModelPickerOpen, () => false)
1049  try {
1050    const result = await $.command.run({ command: 'model', args: alias })
1051    const name = parseModelOutput(result.text)
1052    if (/kept model as/i.test(result.text ?? '') && !isCurrentModel(alias, name)) {
1053      await $.ui.toast(`Claude Code kept ${modelLabel(name)}: /model ${alias} did not switch`)
1054    }
1055    // the engine's answer, not the command's text: writing that here would hide the switch from refreshModel
1056    await refreshModel($)
1057  } catch (err) {
1058    await $.ui.toast(`/model ${alias} failed: ${err instanceof Error ? err.message : String(err)}`)
1059  }
1060}
1061
1062/**
1063 * The keys of the numbers that differ between two readings ('limit:<kind>' per
1064 * rate-limit window). A new window is its own change: the percent then moved because
1065 * the window did, so it is not counted as fill. A turn's growth (`delta`) is added by
1066 * the turn end alone.
1067 */
1068export function changedKeys(old: Snapshot | null | undefined, next: Snapshot): string[] {
1069  if (!old) return []
1070  const keys: string[] = []
1071  if (next.tokens !== undefined && next.tokens !== old.tokens) keys.push('tokens', 'headroom')
1072  if (next.window !== old.window) keys.push('window')
1073  else if (next.percent !== undefined && next.percent !== old.percent) keys.push('percent')
1074  if (next.usd !== undefined && Math.round((next.usd ?? 0) * 100) !== Math.round((old.usd ?? 0) * 100)) keys.push('usd')
1075  for (const l of next.limits) {
1076    const before = old.limits.find(o => o.kind === l.kind)
1077    if (before && before.percentUsed !== l.percentUsed) keys.push(`limit:${l.kind}`)
1078  }
1079  return keys
1080}
1081
1082/** A fall of the fill by more than this share, with no compaction seen, is a shrink. */
1083const SHRINK = 0.2
1084
1085/**
1086 * A reading as the meter keeps it. With no `previous` it is fresh: nothing is
1087 * highlighted and "this turn" counts from its tokens. A reading without a breakdown,
1088 * in an unchanged window, keeps the last breakdown's figures. A turn end
1089 * (`isTurnEnd`) measures the turn's growth from `turnBase` and starts the next turn there.
1090 */
1091export function toSnapshot(
1092  u: SessionUsage,
1093  now: number,
1094  previous?: Snapshot | null,
1095  tick = 0,
1096  isTurnEnd = false,
1097): Snapshot {
1098  const b = u.context.breakdown
1099  const isNewWindow = previous != null && previous.window !== u.context.window
1100  const carried = !b && previous != null && !isNewWindow ? previous : undefined
1101  const categories = b
1102    ? b.categories
1103        .filter(c => c.kind === 'used' && c.tokens > 0)
1104        .sort((x, y) => y.tokens - x.tokens)
1105        .slice(0, 5)
1106        .map(c => ({ name: c.name, tokens: c.tokens }))
1107    : (carried?.categories ?? [])
1108  const tokens = u.context.tokens
1109  let turnBase = previous ? previous.turnBase : tokens
1110  let lastDelta = previous?.lastDelta
1111  let compaction = previous?.compaction
1112  // The fill fell by more than SHRINK with no compaction recorded (an organisation-managed
1113  // install runs no session.compact hook of the mod's; a rewind drops it too): a shrink, not
1114  // a turn's growth. The next turn counts from what is left.
1115  if (previous?.tokens !== undefined && tokens !== undefined && !isNewWindow && tokens < previous.tokens * (1 - SHRINK)) {
1116    compaction = { before: previous.tokens, after: tokens, isShrink: true }
1117    turnBase = undefined
1118  }
1119  let isTurnMeasured = false
1120  if (isTurnEnd && tokens !== undefined) {
1121    if (turnBase !== undefined) {
1122      lastDelta = tokens - turnBase
1123      compaction = undefined
1124      isTurnMeasured = true
1125    } else {
1126      lastDelta = undefined
1127    }
1128    turnBase = tokens
1129  }
1130  const next: Snapshot = {
1131    tokens,
1132    window: u.context.window,
1133    percent: u.context.percent,
1134    compactAt: b ? (b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined) : carried?.compactAt,
1135    isAutoCompact: b ? b.isAutoCompactEnabled : carried?.isAutoCompact,
1136    model: b ? b.model : carried?.model,
1137    categories,
1138    usd: u.cost?.usd,
1139    limits: u.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
1140    startedAt: u.startedAt,
1141    lastDelta,
1142    turnBase,
1143    windowFrom: isNewWindow ? previous.window : previous?.windowFrom,
1144    compaction,
1145    at: now,
1146    changed: previous?.changed ?? [],
1147    changedTick: previous?.changedTick ?? tick,
1148    prevPercent: previous?.prevPercent,
1149    from: previous?.from ?? {},
1150  }
1151  const keys = changedKeys(previous, next)
1152  if (isTurnMeasured && lastDelta !== 0) keys.push('delta')
1153  if (keys.length > 0 && previous) {
1154    next.changed = keys
1155    next.changedTick = tick
1156    if (keys.includes('percent')) next.prevPercent = previous.percent
1157    next.from = fromValues(previous, keys)
1158  }
1159  return next
1160}
1161
1162/** A fresh change's colour by the way it went: green where the number rose, red where it fell. */
1163const MOVE_COLOR = { up: 'success', down: 'error' } as const
1164
1165/**
1166 * Which way a changed number went since the last reading: `up`, `down`, or undefined where
1167 * it did not move or its old value is unknown. A turn's growth (`delta`) goes by its sign,
1168 * a new window from the one before.
1169 */
1170export function moveOf(s: Snapshot, key: string): 'up' | 'down' | undefined {
1171  let from = s.from[key]
1172  let to: number | undefined
1173  if (key === 'delta') {
1174    from = 0
1175    to = s.lastDelta
1176  } else if (key === 'window') {
1177    from = s.windowFrom
1178    to = s.window
1179  } else if (key === 'percent') {
1180    from = s.prevPercent ?? from
1181    to = s.percent
1182  } else if (key === 'tokens') to = s.tokens
1183  else if (key === 'headroom') to = s.compactAt !== undefined && s.tokens !== undefined ? Math.max(0, s.compactAt - s.tokens) : undefined
1184  else if (key === 'usd') to = s.usd
1185  else if (key.startsWith('limit:')) to = s.limits.find(l => `limit:${l.kind}` === key)?.percentUsed
1186  if (from === undefined || to === undefined || from === to) return undefined
1187  return to > from ? 'up' : 'down'
1188}
1189
1190/**
1191 * Each changed number's old value, the start of its count-up. A finished turn's growth
1192 * counts on from the "this turn" figure the live readings showed, else from 0.
1193 */
1194function fromValues(old: Snapshot, keys: readonly string[]): Record<string, number> {
1195  const from: Record<string, number> = {}
1196  for (const key of keys) {
1197    if (key === 'tokens' && old.tokens !== undefined) from.tokens = old.tokens
1198    else if (key === 'headroom' && old.compactAt !== undefined && old.tokens !== undefined) {
1199      from.headroom = Math.max(0, old.compactAt - old.tokens)
1200    } else if (key === 'delta') {
types/index.d.ts 108 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Category = { name: string; tokens: number }
4
5/** How long the prompt cache lives after a reply, as the requests showed it (`ttlFrom`); kept in the store between sessions. */
6export type CacheTtl = '5m' | '1h'
7
8/**
9 * The main thread's last reply, for the cache: when it came, the share of its request the
10 * cache served (0 to 1), and the model and effort it was sent with (a change of either
11 * starts a new cache, so the next request tells nothing of its lifetime).
12 */
13export type CacheReading = { repliedAt?: number; hit?: number; model?: string; effort?: string | number }
14
15/** A rate-limit window's recent readings, oldest first, for its pace: kept while the window (its `resetsAt`) stays the same. */
16export type LimitPace = { resetsAt?: string; samples: { at: number; percent: number }[] }
17
18export type Snapshot = {
19  /** Input tokens of the last response (absent before the first one, and right after a compaction). */
20  tokens?: number
21  /** The model's context window, in tokens. */
22  window: number
23  /** tokens / window, whole percent, capped at 100 by the engine. */
24  percent?: number
25  /** Token count at which auto-compaction runs, when it is on. */
26  compactAt?: number
27  /** Auto-compaction is on, as the last breakdown said; absent until one was read. */
28  isAutoCompact?: boolean
29  /** Model the breakdown was computed for. */
30  model?: string
31  /** Largest used categories of the window, biggest first. */
32  categories: Category[]
33  /** Session cost so far, USD. */
34  usd?: number
35  /** Rate-limit windows (five_hour, seven_day, ...). */
36  limits: Limit[]
37  /** When the session began, in $.clock.now() milliseconds. */
38  startedAt: number
39  /** Token growth of the last completed turn: its end's tokens less `turnBase`. */
40  lastDelta?: number
41  /**
42   * Tokens at the last turn end, or at a fresh reading: "this turn" counts from here.
43   * Absent after a compaction until the next turn end sets it.
44   */
45  turnBase?: number
46  /** The window before the last window change (a model switch), for the `window 1.00M → 200.0k` chip. */
47  windowFrom?: number
48  /**
49   * The last main-conversation compaction, kept until a turn end measures a real `lastDelta`.
50   * `isShrink`: no compaction was seen, the fill just fell (`shrank 182.0k → 31.0k`).
51   */
52  compaction?: { before?: number; after?: number; isShrink?: boolean }
53  /** When this reading was taken, in $.clock.now() milliseconds. */
54  at: number
55  /** Which numbers changed at the last change: tokens, headroom, delta, percent, window, usd, limit:<kind>. */
56  changed: string[]
57  /** The mascot frame tick at which `changed` was recorded (drives the highlight's fade). */
58  changedTick: number
59  /** Percent before the last change, so the bar can draw the newly filled part. */
60  prevPercent?: number
61  /** Where each changed number counts up from, by the keys of `changed`. */
62  from: Record<string, number>
63}
64
65export type Effort = {
66  /** Effort the main thread's last request carried: a level, or a token budget. */
67  level?: string | number
68  /** Ultracode is on for this session, as the last `/effort` output said. */
69  isUltra: boolean
70  /** The model takes no effort: the main thread's last request carried none. */
71  isUnsupported?: boolean
72}
73
74declare module 'claude-code' {
75  interface PluginState {
76    'context-meter': {
77      snap: Snapshot | null
78      effort: Effort
79      /** Count-up step since the last change, 0..TWEEN_STEPS (done). */
80      tweenStep: number
81      /** The effort picker column is open. */
82      isPickerOpen: boolean
83      /** The main loop's model, as `/model` shows it (`$.session.model()`); null until read. */
84      model: string | null
85      /** The model picker column is open. */
86      isModelPickerOpen: boolean
87      /** Tokens at each turn end (and at a fresh reading), the last 16; percents are taken against the window shown. */
88      trend: number[]
89      frame: number
90      /** Frame tick of the last activity; Clawd sleeps once idle long enough. */
91      awakeTick: number
92      showDetails: boolean
93      /** Why the last reading failed, shown by the placeholder band; null once one succeeds. */
94      readError: string | null
95      /** The engine's Text takes `mono` (2.1.299 on); false while the version is unknown. */
96      hasMono: boolean
97      /** Each rate-limit window's readings of the last half hour, by kind, for its pace. */
98      pace: Record<string, LimitPace>
99      /** The main thread's last reply, for the cache's warmth and hit rate; empty in a new conversation. */
100      cache: CacheReading
101      /** How long the cache lives, once a request showed it; null until then. */
102      cacheTtl: CacheTtl | null
103      /** The compact button: `ask` while it asks to confirm, `running` while the compaction it started runs. */
104      compactStep: 'none' | 'ask' | 'running'
105    }
106  }
107}
108