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…

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.

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
y to Add marketplace? (github:Krs7N/context-meter).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.
| Setting | Default | What it does |
|---|---|---|
sleepAfterMinutes ("Clawd naps after (minutes)") | 5 | Minutes without activity before Clawd falls asleep. 0 keeps him awake. |
showClawd ("Show Clawd") | on | Off: no mascot, and the stats take his 15 cells. |
reduceMotion ("Reduce motion") | off | On: 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") | auto | auto: the full band where it fits, else one line. one-line: always the one-line meter. |
showCost ("Show the session's cost") | on | The $ figure in row 3. |
showRateLimits ("Show rate limits") | on | The 5h, 7d and spend windows in row 3, with their resets and pace. |
showCompactButton ("Show the compact button") | on | The compact button once the bar turns amber or red. |
warnAt ("Bar turns amber at …") | 75 | How 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 …") | 95 | The same for red, Clawd fuming. Below warnAt, both keep their defaults. |
cacheLifetime ("Prompt-cache lifetime") | auto | auto: learned from your requests (Cold cache). 5m or 1h: always that. |
models ("Models in the model picker") | haiku, sonnet, opus, fable | The 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.
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 :.
| Row | Content | Source |
|---|---|---|
| 1 | Context + fill bar + percent + tokens / window; model badge and effort badge on the right | $.session.usage().context (tokens, window, percent); model and effort: see below |
| 2 | in 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 |
| 3 | session 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 · compact | startedAt 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 them | breakdown.categories (kind used) — toggled by the details button |
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).▏▎▍▌▋▊▉█), so it moves smoothly. It is up to 40 cells wide.┊ mark on the empty part shows where auto-compaction runs.success) for about 10 s. The percent then shows a green ▲, or a red ▼ (error) when it drops.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./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.12→42% green when the fill rose over the trend, red when it fell) and the compaction note (compacted 182.0k → 31.0k, red).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.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.tokens, then Context, then the window, then the token count, and the bar shrinks last;┊ 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;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./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.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.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.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).┊ 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.
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).
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.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.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.$ estimate: token prices are not in the plugin API, and subscription plans do not pay per token.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>.
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.
The badge grows louder with the effort level:
| Effort | Drawn 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 budget | effort 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./config row named effort…, when this build has one: a starting value, read only until a request or a pick names the level.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.
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).
CLAWD.shutEye), so he never looks eyeless.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).outfitFor, clawdRows):| Effort | Outfit |
|---|---|
| low | none |
| medium | a blue cap, facing front |
| high | the cap plus a bottle in a holder strapped to each side, straws running into his face |
| xhigh | cap, bottles and straws, plus glasses with round white rims around his eyes |
| max, ultracode | a 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.
fillColor, read by moodFor), so the two always change together, count-up included:| Bar | Mood | Clawd |
|---|---|---|
| orange | calm | as above |
warning | annoyed | ember 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 |
error | fuming | red 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).
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).CLAWD constant in hooks/register.tsx; the timing in TICK_MS (tick length) and BLINK_EVERY (ticks between blinks).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.
| Event | What happens |
|---|---|
session.start | reads 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.detach | Claude Code Desktop joining starts the band's work with a fresh reading; the last drawing client leaving stops it |
turn.complete | the end of a main-thread turn: a reading with breakdown, the turn's growth and a trend point |
session.measure | a plain reading, after each turn and whenever a rate-limit window moves (mid-turn too) |
turn.step | each main-thread request: its effort level, then a live reading from the response's own usage, and the cache's share and lifetime |
session.compact | a 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.submit | wakes 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.
| File | Purpose |
|---|---|
.claude-plugin/plugin.json | manifest (name, version, description, types) |
hooks/hooks.json | points to the hooks module |
hooks/register.tsx | all logic: refresh, animation, rendering, formatting helpers |
types/index.d.ts | state contract under context-meter: snap, trend, frame, effort, model, the pickers' open flags, pace, cache, cacheTtl, compactStep, … |
tests/meter.test.ts | formatting, band contents and redraw, layouts, model switches, live readings, /clear, /resume, /compact, blink timing |
README.md | this document |
fillColor and the color="claude" props in the ui.render hook. Any theme key works (success, warning, error, subtle, permission, …).const width = Math.max(10, Math.min(40, …)) in the render hook.isCompact in the render hook (planMeter decides what the meter row keeps, fitGroups what rows 2 and 3 keep).TREND_LEN (shown), TREND_KEEP (stored).Snapshot in types/index.d.ts, fill it in toSnapshot, print it in line2 / line3.hooks/register.tsx 2307 lines1import { 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 lines1export 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