Token-saver mod: quiet bash, trim output, cache band, /tokens, cold-cache compaction veto, keep-warm pings

Fewer tokens per turn, and a Context pane that tells the truth.
A Claude Code mod (an in-process plugin of function hooks) that quiets noisy commands before they run, trims huge output before Claude reads it, refuses a /compact that would re-send a cold cache, keeps the prompt cache warm while you think, prices your skills per turn, and shows a receipt it can stand behind. MIT. Works in the terminal, the desktop app, and on a phone.
git clone https://github.com/dbrownjave/claude-token-saver && cd claude-token-saver && ./install.sh
Then open a new session and type /tokens.
True numbers. The pane's Saved figure counts only what the plugin itself did: trimmed output, compactions it vetoed, and keep-warm pings net of their cost. Claude's own prompt-cache reads are shown beside it and never added in; a session with 150k cache reads and nothing trimmed says Saved 0 tok, and a test holds it to that. Estimates carry a ≈. Dollars appear only on API billing with a rate derived from your bill, never on a subscription.
Docs: docs/claude-mods.md is a full reference for writing Claude Code mods, written from the engine's own type definitions. The repo carries two skills so Claude knows how to edit this mod (mod-authoring) and ship it (release). Site: site/index.html. Contributing: CONTRIBUTING.md.
Each engineer installs this on their own machine and turns on only the modules they want. Nothing is forced. A bare install copies files and enables zero modules.
unzip claude-token-saver.zip
cd claude-token-saver
./install.sh
That is the whole install. No questions. It:
~/.claude/token-saver/.claude plugin install when your Claude Code accepts it. The marketplace is registered from a staged copy under ~/.claude/token-saver/pack, so you can delete the download afterwards. If the build refuses the mod, the Python hooks cover the same ground and it says so.settings.json. Hooks you already have stay. For each one that overlaps, it prints one line saying how the two coexist, or that it left ours off (for example when you already have a status line). Nothing of yours is edited or reordered.While it works, an ASCII gold coin spins above the step list, a progress bar with a tick per step and a pill naming the current step fills underneath it, and each step ticks as it lands. It is a lit, textured disc in the spirit of David Haubner's ASCII Bitcoin animation, without the B; --size sets its height and --emblem T puts a raised letter on the face. On a pipe, in CI, or with --plain (or TOKEN_SAVER_PLAIN=1) you get plain lines instead. NO_COLOR is honored. python3 scripts/coin.py --demo shows it without installing anything.
Run it again after updating the pack; it restages the mod and Claude picks it up on the next session or /reload-plugins. ./install.sh --dry-run shows the plan without writing. ./install.sh --pick is the module picker if you want to choose. ./install.sh --uninstall removes the mod, our hooks, rules and skills, and leaves everything else.
Requires python3 (stdlib only). On Windows there is no bash; run the same one-shot directly:
python manage.py setup
Build the zip you hand to others with scripts/dist.sh. It runs the self-test, validates the mod, and writes dist/claude-token-saver-<version>.zip from a whitelist.
Other entry points:
./install.sh --list
./install.sh --enable cache-guard
./install.sh --disable trim-output
./install.sh --project --enable rules,quiet-bash # one repo only
python3 manage.py status
Mods are in-process plugins. They can rewrite a tool call and draw UI. Settings hooks cannot. ./install.sh installs it for you; these are the manual equivalents:
claude plugin marketplace add .
claude plugin install token-saver@token-saver
# session already open:
# /reload-plugins
Or one session, no marketplace:
claude --plugin-dir ./plugins/token-saver
Toggles live on the plugin, not in a settings splice: quietBash, trimOutput, cacheBand, compactHint, compactGuard, longCacheTtl, keepWarm, keepWarmPings. They show up as rows in /config. Disable the whole thing with /plugin disable token-saver@token-saver.
The mod registers a progress tool. When Claude runs a job with stages, it calls the tool as each stage starts, waits, fails or ends, and a bar appears above the prompt.
The bar is a port of the Effort slider's own shader from the Claude app: a 14 px squared track of tiny rounded cells in three rows, lit by bursts that race out from the head every 0.3–0.75 s as Manhattan-distance wavefronts, a five-step white→ink ramp with breathing top cells, and a charged bed that deepens toward the head and fades over the first quarter. Tuned brighter than the slider. The stage and count ride in a gradient pill in the bar's own colour, trailing the head; there is no status icon, the colour and the pill's ✕ / ? / ✓ carry state. Purple running, amber waiting, red failed, green done. A finished row keeps sparking faintly for a minute, then holds still. Frames are a pure function of the band's 70 ms tick, so they are deterministic and testable.
In the terminal the same cells draw as a one-row Raster of half-block squares with a coloured text pill; where Raster is missing, small-square glyphs. × removes a row; remove: true from the tool does the same. /loadout actions use the same rows: one stage per skill, green when done, gone eight seconds later. The status line reads "Progress" while any bar is up.
A settings hook can only add a note before /compact. The mod sits on the compaction itself:
| Trigger | What happens |
|---|---|
/compact, cache warm | Runs. The keep-list (task, files touched, decisions, failing tests, next steps, exact errors) is appended to the summarizer's instructions. |
/compact, cache cold | Refused. One line names the idle time and the tokens a compaction would re-send, and points at /handoff then /clear. |
/compact force | Runs anyway. Keep-list still appended. |
| auto-compaction | Never refused. Keep-list appended. Blocking it would let the window overflow. |
| subagent transcripts | Left alone. |
"Cold" means idle longer than the prompt-cache TTL since the last response. Default TTL is 5 minutes; turn on longCacheTtl if your sessions get the 1-hour cache. /tokens shows which state you are in.
Keep-warm (keepWarm, on by default) applies only to API billing with the 5-minute TTL. While you are idle, it re-reads the session's own cached prefix with one tiny request ($.model.fork) about 75 seconds before the cache lapses. A read costs about 0.1× the input price and restarts the TTL. Your next message then hits the cache instead of paying a 1.25× re-write.
keepWarmPings pings per idle stretch (default 5, about 20 minutes, the break-even if you come back half the time)./tokens and the Context pane book what the pings cost and the re-writes they avoided, so the net can be negative if you stay away.Every term the Context pane uses (context, the prompt cache, re-cache, keep-warm, marks, what was saved) has one definition, kept in plugins/token-saver/hooks/glossary.js: what it is, its live value in your session, and what to do. Press ? in the pane to number its parts and explain each one with your numbers; More in the glossary → opens the full glossary at that term. /tokens learn prints the whole glossary as text, and /tokens learn <term> (an id or a name, such as cache or keep-warm) prints just that term. Money appears only on API billing with a known rate, never on a subscription.
Dev loop: claude plugin validate ./plugins/token-saver then claude plugin test ./plugins/token-saver.
| What | Where it works |
|---|---|
| Quiet bash + trim | CLI, Desktop, VS Code panel, claude -p |
| Cache band above the prompt | CLI and Desktop only. The Context pane closed into one row: a short fire bar, the percent in your threshold colour, the cache cells and what the cache means for your next message, what was saved, ⌄ to open the pane and × to hide the band (/tokens show brings it back). Past a mark, the cache-aware hint sits on a second line. On a narrow terminal it drops the "Reuses" figure first, then the bar. VS Code does not draw mod UI; use its cache clock. |
/tokens | Opens the Context pane. Closed: Context 96k / 200k tok (48%) with the percentage in your threshold colour, one full-width bar drawn with the pack's fire shader (ticks at your marks, the auto-compact buffer dim at the end), the cache (five tiny cells and what it means for your next message) and what was saved, then Clear / Compact / Later with the recommended one highlighted; Compact is drawn unavailable while the cache is cold. ⌄ opens: Saved (tokens, the share of input cut, dollars on the API only, a Receipt line by line), three cards (your next mark with messages to go at the recent growth per message, keep-warm on the API or the five-hour window on a subscription, the cache), Claude's working memory by kind like /context, and groups (MCP tools, memory files with duplicates flagged, custom agents, skills) whose buttons run /mcp, /memory, /agents, /loadout. Worth is shown in the currency you pay in; never a dollar on a subscription. cardBars in /config calms the card bars to an ember. /tokens list prints the text form; hide and show toggle the band. |
| Threshold toast | Once per threshold crossed (40, 60, 75 percent by default), with the cache-aware hint. |
| First-run welcome | One toast the first time the mod loads on a machine, saying what is now on. |
| Compaction guard | Everywhere the mod loads. |
/loadout | Opens a pane: every skill with cost per turn and last use, a Park button on each active one, Link here / Unpark on each parked one, Park N unused in the header. Esc closes. /loadout list is the text form. CLI and Desktop; the VS Code panel gets the text. |
Same ~/.claude/token-saver/quiet.json as the Python hook. Do not run both quiet-bash implementations at once or commands get rewritten twice.
The mod does not touch the system prompt every turn. That would bust the cache. The band hint does not type a command into the box. Your marks live in ~/.claude/token-saver/thresholds.json, copied once and never overwritten. Defaults: 40% note, 60% /handoff then /clear, 75% /compact if warm and a new chat if cold. /context in VS Code stays the category grid. The panel will not draw this band. /tokens prints the same hint. Enabling the Python quiet-bash hook while the mod is on is refused. dash shows cache read vs cache write per session. That is request accounting, not a bill. The named miss cause stays on /usage.
Python dash / bench stay. Mods do not read history.
./install.sh --enable statusline,quiet-map
| Module | What it does |
|---|---|
statusline | CLI status bar: ● cache 98% 1h 12m ctx 41% or ▲ COLD +461k if you type. Does not overwrite an existing statusLine. VS Code’s chat panel already has a cache clock — this is for the terminal. |
quiet-map | Copies ~/.claude/token-saver/quiet.json if you do not already have one. Edit it to add pnpm, kubectl, company wrappers. Never overwritten on reinstall. |
dash now ranks the noisiest Bash commands from your last week of transcripts (count, est. tokens, whether a quiet.json rule already covers them).
Add your own rewrite:
{
"id": "kubectl-get",
"pattern": "^kubectl\\s+get\\b",
"unless": ["-o name"],
"append": " -o name",
"enabled": true
}
Every skill under ~/.claude/skills costs tokens on every turn: its name and description sit in the system prompt's skill listing. The body only loads when you invoke it. Thirty skills is a few hundred tokens a turn, forever.
./install.sh skills # active skills, tok/turn, last use, · = unused 30+ days
./install.sh skills pick # interactive: choose what to park
./install.sh skills park gsap-* # by name, glob, @group, or 'unused'
In a session with the mod: /loadout opens the pane; /loadout gsap-* parks; /loadout back gsap-react brings one back everywhere; /loadout link gsap-react for this repo only. Instant, no model turn. The picker needs a TTY so it stays in the terminal.
| Action | What happens | Undo |
|---|---|---|
park | Moves the skill (or its symlink) to ~/.claude/skills-parked/ and records where it came from in .parked.json. | unpark |
unpark | Moves it back to its original place. | park |
link | Symlinks a parked skill into this repo's .claude/skills/. Loads only when you work here. | unlink |
unlink | Removes that symlink only. The skill stays parked. | link |
Nothing is ever deleted. Changes take effect in the next session (/clear or restart). "Last use" comes from a ripgrep over your local transcripts for Skill calls, run through the claude binary (BSD grep takes 30s on a big transcript dir) and cached for an hour (--rescan to refresh). It is a hint, not a bill. Parked skills that still get used are called out so you can link them where they belong. Links are machine-local: add .claude/skills/<name> to .gitignore if teammates do not have the parked skill.
The /token-saver skill routes skill bloat here and is told never to move skill folders by hand.
You should not have to run an installer per session. Three things close the loop:
| Where | What | Cost |
|---|---|---|
| Session start (mod) | Reads the repo's manifests, names parked skills that fit, shows one toast and a log line. | 0 tokens |
Session start (Python session-audit hook) | Same one line, appended to the budget line so Claude knows too. | ~30 tokens, only when there is a match |
/repo-skills skill | When you ask "do we have a skill for X" or start GSAP/SwiftUI/Higgsfield work, Claude runs skills.py suggest --for "X", tells you what fits, and links it on a yes. | loads on demand |
./install.sh skills suggest # parked skills that fit this repo
./install.sh skills suggest --for "scroll animation"
Signals: package.json deps, tsconfig.json, Package.swift, *.xcodeproj, *.swift, pyproject.toml, requirements.txt, Cargo.toml, go.mod, Gemfile, Podfile, Tailwind configs, and libraries referenced from an index.html. Dependency names and file types are strong signals. Aliases (next→react, three→3d, gsap→animation) and broad words (javascript, frontend) are weak hints: they can nudge a ranking but never put a skill in the link command. Matching is on skill names and descriptions, not bodies. Already-active matches are listed first so Claude uses what is loaded.
The session-start toast shows once a day per repo; the log line shows every time. Needs python3 or python on PATH.
Self-test (temp dir, never touches ~/.claude):
python3 scripts/test_skills.py
./install.sh dash # free — tokens from your local Claude Code sessions
./install.sh dash --watch # refresh every 2s
./install.sh bench # free — hook compression: used / saved / result preview
./install.sh bench --live # A/B against Claude (asks first; spends tokens)
Same via Python:
python3 scripts/tui.py dash
python3 scripts/tui.py bench
python3 scripts/tui.py bench --live --prompt "git status then name the 3 newest files"
| Command | Calls Claude? | What “saved” means |
|---|---|---|
dash | no | Real tokens your past sessions used. Does not invent a savings %. |
bench | no | Tokens the hooks strip from command output (used vs after). Shows the trimmed result. |
bench --live | yes, twice | OFF arm vs ON arm. Shows tokens, $, and both result texts so you can judge quality. |
Live A/B is capped at 4 turns and $0.50 per arm. Tools are read-only plus Bash. Confirm before it spends.
| Path | Scope | |
|---|---|---|
| Files | ~/.claude/token-saver/ | You, all repos |
| Hooks spliced into | ~/.claude/settings.json | You, all repos |
| Rules (if enabled) | ~/.claude/rules/token-budget.md | You, all repos |
| Skill (if enabled) | ~/.claude/skills/token-saver/ | You, all repos |
| State | ~/.claude/token-saver/enabled.json | What you turned on |
Your existing hooks stay. We only add or remove entries whose command path contains token-saver/. A timestamped backup of settings.json is written before each change.
Claude Code on the web / cloud VMs does not see ~/.claude. This pack is for the local CLI/desktop app.
./install.sh --project --enable rules,quiet-bash
Writes hooks into .claude/settings.local.json (gitignored) and files into .claude/token-saver/.
Recommended (on if you press Enter):
| id | What it does |
|---|---|
rules | Standing policy: /clear on topic change, @file, Haiku subagents |
skill | /token-saver — loads only when you ask about tokens |
session-audit | Short budget line at session start |
quiet-bash | Rewrites git status / git log / tests to short output |
trim-output | Caps huge successful bash stdout (never trims failures) |
Opt-in (off until you enable them):
| id | What it does |
|---|---|
compact-keep | Keep-list injected before /compact |
cache-guard | Asks before a mid-session model switch when the cache is warm |
slim-startup | Caps bash at 8k chars; sets CLAUDE_CODE_DISABLE_ARTIFACT=1 (reversible) |
deny-secrets | Blocks Read on .env, secrets/, node_modules |
thinking-cap | MAX_THINKING_TOKENS=8000 |
no-connectors | Disables claude.ai connectors (only if you do not use Slack/Jira/etc) |
no-remote | Disables remote-control tool defs |
slim-startup does not write enableArtifact: false. That key can turn Artifacts off in a way you cannot turn back on from another file. The env var is reversible: disable the module and it is removed.
IT can put the zip on an internal drive and say:
Install it yourself. Recommended:
./install.sh --enable rules,skill,quiet-bash,session-audit
Each engineer still chooses. Nobody’s repo settings change unless they pass --project.
Open a new Claude Code session and run /context. That is the before/after measurement.
/token-saver if you enabled the skill
/mcp disable unused servers (biggest startup win after Artifacts)
/clear new task
/compact only while the cache is warm
trim-output depends on updatedToolOutput, which has been flaky on some 2.1.x builds. quiet-bash is the reliable layer.settings.json stays.hooks/register.js 2439 lines1/**
2 * Token-saver mod. In-process plugin of function hooks.
3 *
4 * Sandbox rules: a hooks module may import only its own files and
5 * "claude-code". No node:* imports, no process/env globals. Files go through
6 * $.fs, environment through $.env, time through $.clock.
7 *
8 * VS Code's chat panel runs these hooks but does not draw UI.
9 */
10
11import { GLOSSARY } from './glossary.js'
12
13const LIMIT = 8000
14const HEAD = 2500
15const TAIL = 1500
16const MINUTE = 60 * 1000
17const TTL_SHORT = 5 * MINUTE
18const TTL_LONG = 60 * MINUTE
19const BAND_TICK = 30 * 1000
20const KEEP_WARM_TICK = 15 * 1000
21const KEEP_WARM_LEAD = 75 * 1000 // the TTL runs from a request's start and a fork takes seconds: ping well before it lapses
22const KEEP_WARM_PROMPT = 'Keep-alive ping from the token-saver plugin. Reply with a single period and nothing else.'
23
24const DEFAULT_RULES = [
25 { id: 'git-status', pattern: '^git\\s+status\\b', unless: ['-sb', '-s'], replace: 'git status -sb' },
26 { id: 'git-log', pattern: '^git\\s+log\\b', unless: ['--oneline'], replace: 'git log --oneline -20' },
27 { id: 'git-diff', pattern: '^git\\s+diff\\s*$', replace: 'git diff --stat' },
28 { id: 'pytest', pattern: '^pytest\\b', unless: ['-q'], append: ' -q --tb=short' },
29 { id: 'npm-test', pattern: '^npm\\s+test\\b', unless: ['--reporter', '-- '], append: ' -- --reporter=dot' },
30 { id: 'pnpm-test', pattern: '^pnpm\\s+test\\b', unless: ['--reporter', '-- '], append: ' -- --reporter=dot' },
31 { id: 'cargo-test', pattern: '^cargo\\s+test\\b', unless: ['--quiet', '-q'], append: ' --quiet' },
32]
33
34const DEFAULT_LEVELS = [
35 { id: 'watch', pct: 40, hint: 'note' },
36 { id: 'handoff', pct: 60, hint: 'new-conversation' },
37 { id: 'compact', pct: 75, hint: 'compact-if-warm' },
38]
39
40const KEEP =
41 'When compacting, keep: current task, files touched, decisions, failing tests, ' +
42 'next steps, exact error messages. Drop: raw tool output, resolved errors, ' +
43 'exploratory file reads, repeated logs.'
44
45// Module state. A reload starts these over; nothing here needs to survive one.
46let lastUsage = null
47let lastStepAt = 0
48let savedChars = 0
49let rewrites = 0
50let vetoes = 0
51let rules = DEFAULT_RULES
52let levels = DEFAULT_LEVELS
53let pythonQuietOn = false
54let bandHidden = false
55let bandSurface = 'terminal' // where the band last drew: the desktop's native buttons drop a press that spans a redraw
56const totals = { turns: 0, input: 0, cacheRead: 0, cacheWrite: 0, output: 0, keptOut: 0, vetoTokens: 0 } // this session, from turn.step usage
57let limitBase = null // { pct, tokens } when the five-hour window was first seen: tokens per % of window
58let monthSaved = { key: '', tok: 0, usd: 0, sessionTok: 0, sessionUsd: 0 } // carried in $.store across sessions
59let announcedPct = 0 // highest threshold already toasted this session
60/**
61 * Keep-warm: while you are idle, re-read the session's own cached prefix ($.model.fork) shortly before the 5-minute TTL
62 * lapses, so your next message hits the cache instead of re-writing it. A read costs 0.1× fresh input and refreshes the
63 * TTL; a lapse costs a 1.25× re-write, so a ping pays while pings × 0.1 < chance-you-return × 1.15. The cap (default 5,
64 * about 20 minutes) assumes about even odds. Costs and savings are booked in fresh-input-token equivalents.
65 */
66const keepWarm = { on: false, streak: 0, pings: 0, misses: 0, costTok: 0, savedTok: 0, busy: false, refreshedAt: 0, stopped: '' }
67let inTurn = false
68// Progress rows above the prompt, driven by the `progress` tool the model calls.
69const PROGRESS_TOOL = 'mcp__token-saver__progress'
70const progressRows = new Map() // id -> { id, label, stage, step, total, state, target, shown, note }
71let progressTicker = null
72const STATE_COLOR = { running: '#8b7cf6', waiting: '#d9a441', failed: '#e5484d', done: '#30a46c' }
73const STATE_GLYPH = { running: '●', waiting: '?', failed: '!', done: '✓' }
74const SKILLS_PANE = 'loadout'
75let skillsData = null // last `skills.py status --json`
76let skillsBusy = false
77let skillsMsg = '' // last action's one-line result, shown in the pane footer
78
79async function readJson($, path) {
80 try {
81 return JSON.parse(await $.fs.read(path))
82 } catch {
83 return null
84 }
85}
86
87/** Home config first, project config overrides it. Missing files are fine. */
88async function homeDir($) {
89 return (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
90}
91
92/** python3, then python (Windows installs often ship only `python`). */
93async function runPython($, args, init) {
94 let lastErr
95 for (const exe of ['python3', 'python']) {
96 try {
97 return await $.process.run([exe, ...args], init)
98 } catch (err) {
99 lastErr = err
100 }
101 }
102 throw lastErr
103}
104
105/**
106 * One child process, streamed. Tries python3 then python. Yields { stream, text } pieces
107 * and finally { code }. Throws before the first piece when neither interpreter starts.
108 */
109async function* spawnPython($, args, init) {
110 let lastErr
111 for (const exe of ['python3', 'python']) {
112 let yielded = false
113 try {
114 for await (const piece of $.process.spawn({ argv: [exe, ...args], ...init })) {
115 yielded = true
116 yield piece
117 }
118 return
119 } catch (err) {
120 lastErr = err
121 if (yielded) throw err
122 }
123 }
124 throw lastErr
125}
126
127async function loadConfig($, cwd) {
128 const home = await homeDir($)
129 const dirs = []
130 if (home) dirs.push(`${home}/.claude/token-saver`)
131 if (cwd) dirs.push(`${cwd}/.claude/token-saver`)
132 rules = DEFAULT_RULES
133 levels = DEFAULT_LEVELS
134 for (const dir of dirs) {
135 const q = await readJson($, `${dir}/quiet.json`)
136 if (q && Array.isArray(q.rewrites) && q.rewrites.length) rules = q.rewrites
137 const t = await readJson($, `${dir}/thresholds.json`)
138 if (t && Array.isArray(t.levels) && t.levels.length) levels = t.levels
139 }
140 pythonQuietOn = false
141 if (home) {
142 const settings = await readJson($, `${home}/.claude/settings.json`)
143 pythonQuietOn = !!settings && JSON.stringify(settings.hooks || {}).includes('quiet_bash.py')
144 }
145}
146
147function quiet(cmd) {
148 if (!cmd || /&&|\|\||\||;/.test(cmd)) return null
149 if (/\brm\b|\bsudo\b|\bdd\b|\bmkfs\b|>\s|\/dev\//.test(cmd)) return null
150 for (const rule of rules) {
151 if (rule.enabled === false || !rule.pattern) continue
152 let re
153 try {
154 re = new RegExp(rule.pattern)
155 } catch {
156 continue
157 }
158 if (!re.test(cmd)) continue
159 if ((rule.unless || []).some((u) => cmd.includes(u))) continue
160 const next = rule.replace || (rule.append ? cmd + rule.append : null)
161 if (!next || next === cmd) return null
162 return { id: rule.id || 'custom', next }
163 }
164 return null
165}
166
167function trim(text) {
168 if (typeof text !== 'string' || text.length <= LIMIT) return text
169 const dropped = text.length - HEAD - TAIL
170 return text.slice(0, HEAD) + `\n…[token-saver trimmed ${dropped} chars]…\n` + text.slice(-TAIL)
171}
172
173function fmt(n) {
174 if (!n) return '0'
175 if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
176 if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
177 return String(n)
178}
179
180function mins(ms) {
181 return Math.max(1, Math.round(ms / MINUTE))
182}
183
184function sentOf(u) {
185 if (!u) return 0
186 return (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
187}
188
189function hitPct(u) {
190 const sent = sentOf(u)
191 return sent ? Math.round((100 * ((u && u.cache_read_input_tokens) || 0)) / sent) : 0
192}
193
194/** Live context fill from the engine; falls back to the last step's usage. */
195async function contextOf($) {
196 try {
197 const usage = await $.session.usage()
198 const c = usage && usage.context
199 if (c && typeof c.window === 'number' && c.window > 0) {
200 const tokens = typeof c.tokens === 'number' ? c.tokens : sentOf(lastUsage)
201 const percent =
202 typeof c.percent === 'number' ? c.percent : Math.min(100, Math.round((100 * tokens) / c.window))
203 return { tokens, window: c.window, percent }
204 }
205 } catch {
206 /* no session bound (tests) or older engine */
207 }
208 const tokens = sentOf(lastUsage)
209 return { tokens, window: 0, percent: 0 }
210}
211
212function cacheClock(now, ttl) {
213 if (!lastStepAt) return { known: false, warm: false, idleMs: 0, leftMs: 0 }
214 const idleMs = Math.max(0, now - Math.max(lastStepAt, keepWarm.refreshedAt)) // a keep-warm ping refreshes the TTL too
215 return { known: true, warm: idleMs < ttl, idleMs, leftMs: Math.max(0, ttl - idleMs) }
216}
217
218/** Whether a keep-warm ping is due now, else why not. Pure: every input is passed in. */
219function keepWarmDue(w, now, lastStep, ttl, maxPings, turnRunning) {
220 if (!w.on) return 'off'
221 if (turnRunning) return 'turn running'
222 if (w.busy) return 'busy'
223 if (!lastStep) return 'no response yet'
224 const idle = now - Math.max(lastStep, w.refreshedAt)
225 if (idle >= ttl) return 'lapsed'
226 if (ttl - idle > KEEP_WARM_LEAD) return 'not yet'
227 if (w.streak >= maxPings) return 'cap'
228 return 'ping'
229}
230
231/** One input-token-equivalent figure for what a call cost: write 1.25×, read 0.1×, output 5×. */
232function billedTok(u) {
233 if (!u) return 0
234 return Math.round((u.input_tokens || 0) + 1.25 * (u.cache_creation_input_tokens || 0) + 0.1 * (u.cache_read_input_tokens || 0) + 5 * (u.output_tokens || 0))
235}
236
237async function tickKeepWarm($, ttl, maxPings) {
238 const now = await $.clock.now()
239 if (keepWarmDue(keepWarm, now, lastStepAt, ttl, maxPings, inTurn) !== 'ping') return
240 keepWarm.busy = true
241 try {
242 // API billing only: a subscription already caches the main thread for an hour, and a ping would spend plan quota
243 let u = null
244 try { u = await $.session.usage() } catch { /* no usage API */ }
245 if (!(u && u.cost && Number.isFinite(u.cost.usd))) { keepWarm.stopped = 'not API billing'; keepWarm.on = false; return }
246 const r = await $.model.fork({ prompt: KEEP_WARM_PROMPT })
247 if (r.usage) keepWarm.costTok += billedTok(r.usage)
248 if (!r.isAnswered && r.reason === 'nothing-to-fork') return
249 keepWarm.pings += 1
250 keepWarm.streak += 1
251 const read = (r.usage && r.usage.cache_read_input_tokens) || 0
252 if (r.isAnswered && read >= 0.8 * sentOf(r.usage)) keepWarm.refreshedAt = now
253 else { keepWarm.misses += 1; keepWarm.streak = Infinity } // already lapsed (model switch, eviction) or failed: stop paying this stretch
254 try { $.ui.invalidate('ui.render') } catch { /* no UI */ }
255 } finally {
256 keepWarm.busy = false
257 }
258}
259
260function levelFor(pct) {
261 return levels.filter((l) => Number(l.pct) <= pct).sort((a, b) => Number(b.pct) - Number(a.pct))[0]
262}
263
264function hintFor(pct, clock) {
265 const hit = levelFor(pct)
266 if (!hit) return ''
267 if (hit.hint === 'note') return `past your ${hit.pct}% (${hit.id}) — note decisions`
268 if (hit.hint === 'new-conversation') return `past your ${hit.pct}% (${hit.id}) — /handoff then /clear`
269 if (hit.hint === 'compact-if-warm' && clock.warm) {
270 return `past your ${hit.pct}% (${hit.id}), cache warm ${mins(clock.leftMs)}m — /compact is cheap`
271 }
272 if (hit.hint === 'compact-if-warm') {
273 return `past your ${hit.pct}% (${hit.id}), cache cold — /handoff then /clear, don't /compact`
274 }
275 return `past your ${hit.pct}% (${hit.id})`
276}
277
278async function refreshSkills($, cwd) {
279 skillsBusy = true
280 try { $.ui.invalidate('ui.render') } catch { /* no UI */ }
281 try {
282 const args = [`${$.plugin.root}/scripts/skills.py`, 'status', '--json']
283 if (cwd) args.push('--project', cwd)
284 const r = await runPython($, args, { timeoutMs: 60_000 })
285 if (r.exitCode !== 0) throw new Error((r.stderr || r.stdout || `exit ${r.exitCode}`).trim().split('\n')[0])
286 skillsData = JSON.parse(r.stdout)
287 } catch (err) {
288 skillsData = { error: err && err.message ? err.message : String(err) }
289 }
290 skillsBusy = false
291 try { $.ui.invalidate('ui.render') } catch { /* no UI */ }
292}
293
294/** Expand globs, @groups and 'unused' against the last status, so each skill can be its own stage. */
295function expandSkillNames(verb, names) {
296 if (!skillsData || skillsData.error) return names
297 const active = (skillsData.active || []).map((x) => x.name)
298 const parked = (skillsData.parked || []).map((x) => x.name)
299 const pool = verb === 'park' ? active : verb === 'unlink' ? (skillsData.project_links || []) : parked
300 const out = []
301 for (const raw of names) {
302 let hits
303 if (raw === 'unused') hits = (skillsData.unused || []).filter((n) => pool.includes(n))
304 else if (raw.startsWith('@')) hits = pool.filter((n) => n.split('-')[0] === raw.slice(1))
305 else if (/[*?[]/.test(raw)) {
306 const re = new RegExp('^' + raw.replace(/[.+^${}()|\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$')
307 hits = pool.filter((n) => re.test(n))
308 } else hits = [raw]
309 for (const h of hits) if (!out.includes(h)) out.push(h)
310 }
311 return out
312}
313
314const VERB_LABEL = { park: 'Park', unpark: 'Bring back', link: 'Link here', unlink: 'Unlink' }
315
316async function actSkills($, verb, names, cwd) {
317 if (skillsBusy) return
318 skillsBusy = true
319 const list = expandSkillNames(verb, names)
320 const total = list.length
321 const rowId = 'loadout'
322 const label = `${VERB_LABEL[verb] || verb} ${total} skill${total === 1 ? '' : 's'}`
323 applyProgress({ id: rowId, label, stage: list[0] || verb, step: 1, total, state: 'running', percent: 0 })
324 skillsMsg = `${label}…`
325 try { $.ui.status('Progress'); $.ui.invalidate('ui.render') } catch { /* no UI */ }
326 let ok = 0
327 let skipped = 0
328 let summary = ''
329 const onLine = (line) => {
330 // skills.py --progress prints `>> i/n name ok|skip` as each skill lands; one process, many stages.
331 const m = /^>> (\d+)\/(\d+) (\S+) (ok|skip)$/.exec(line.trim())
332 if (m) {
333 const i = Number(m[1]), n = Number(m[2])
334 if (m[4] === 'ok') ok += 1
335 else skipped += 1
336 applyProgress({ id: rowId, stage: m[3], step: Math.min(i + 1, n), total: n, state: 'running', percent: (i / n) * 100 })
337 try { $.ui.invalidate('ui.render') } catch { /* no UI */ }
338 } else if (line.trim() && !line.startsWith(' skip')) {
339 summary = line.trim().replace(/\s+→\s+\S+$/, '')
340 }
341 }
342 try {
343 const args = [`${$.plugin.root}/scripts/skills.py`, verb, ...list, '--no-usage', '--progress']
344 if (cwd) args.push('--project', cwd)
345 let streamed = false
346 try {
347 let buf = ''
348 for await (const piece of spawnPython($, args, { cwd: cwd || undefined })) {
349 streamed = true
350 if (typeof piece.text === 'string') {
351 buf += piece.text
352 let nl
353 while ((nl = buf.indexOf('\n')) >= 0) {
354 onLine(buf.slice(0, nl))
355 buf = buf.slice(nl + 1)
356 }
357 }
358 }
359 if (buf.trim()) onLine(buf)
360 } catch (err) {
361 if (streamed) throw err
362 // No streaming on this build: one batch run, start and finish only.
363 const r = await runPython($, args, { timeoutMs: 90_000 })
364 for (const line of (r.stdout || r.stderr || '').split('\n')) onLine(line)
365 }
366 const failed = skipped > 0 && ok === 0
367 const row = applyProgress({ id: rowId, stage: skipped ? `${skipped} skipped` : 'Done', step: total, total, state: failed ? 'failed' : 'done', percent: 100 })
368 if (row) row.expireAt = Date.now() + (skipped ? 15000 : 8000)
369 skillsMsg = summary || `${label}: ${ok} done${skipped ? `, ${skipped} skipped` : ''}`
370 try { $.ui.toast(`${skillsMsg} · loads after /clear`, { timeoutMs: 8000 }) } catch { /* no UI */ }
371 } catch (err) {
372 skillsMsg = `could not run skills.py: ${err && err.message ? err.message : String(err)}`
373 const row = applyProgress({ id: rowId, stage: 'error', state: 'failed', percent: 100 })
374 if (row) row.expireAt = Date.now() + 15000
375 }
376 skillsBusy = false
377 try { $.ui.invalidate('ui.render') } catch { /* no UI */ }
378 await refreshSkills($, cwd)
379}
380
381function clamp01(x) {
382 return Math.max(0, Math.min(1, x))
383}
384
385/** Apply one `progress` call to the rows. Returns the row, or null when removed. */
386function applyProgress(e) {
387 const id = String(e.id || 'job')
388 if (e.remove) {
389 progressRows.delete(id)
390 return null
391 }
392 const row = progressRows.get(id) || { id, label: id, stage: '', step: 0, total: 0, state: 'running', target: 0, shown: 0, note: '' }
393 if (typeof e.label === 'string' && e.label) row.label = e.label
394 if (typeof e.stage === 'string') row.stage = e.stage
395 if (Number.isFinite(e.total)) row.total = Math.max(0, Math.floor(e.total))
396 if (Number.isFinite(e.step)) row.step = Math.max(0, Math.floor(e.step))
397 if (typeof e.state === 'string' && STATE_COLOR[e.state]) row.state = e.state
398 if (typeof e.note === 'string') row.note = e.note
399 if (Number.isFinite(e.percent)) row.target = clamp01(e.percent / 100)
400 else if (row.total > 0) row.target = clamp01((Math.max(1, row.step) - 1) / row.total)
401 if (row.state === 'done') {
402 row.target = 1
403 if (row.total > 0) row.step = row.total
404 if (!row.stage) row.stage = 'Done'
405 }
406 const liveNow = row.state === 'running' || row.state === 'waiting'
407 if (!liveNow && row.settledAt === undefined) row.settledAt = progressTick * TICK_S
408 if (liveNow) row.settledAt = undefined
409 progressRows.set(id, row)
410 return row
411}
412
413function pillText(row) {
414 if (row.plain) return row.stage || '' // a gauge: just the figure, no state prefix
415 const lead = row.state === 'done' ? '✓ ' : row.state === 'failed' ? '✕ ' : row.state === 'waiting' ? '? ' : ''
416 const count = row.total > 0 ? ` ${Math.max(1, Math.min(row.step || 1, row.total))}/${row.total}` : ''
417 return `${lead}${row.stage || row.label}${count}`
418}
419
420/** Shared by every surface: the dot-matrix palette. Deterministic, so a frame never flickers between redraws. */
421function hexRgb(hex) {
422 const n = parseInt(hex.slice(1), 16)
423 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
424}
425function rgbHex(r, g, b) {
426 return '#' + [r, g, b].map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('')
427}
428/** `color` scaled toward black by `f` (0..1), mixed a touch toward the track so the dim end still reads as the state. */
429function shade(hex, f) {
430 const [r, g, b] = hexRgb(hex)
431 const t = Math.max(0, Math.min(1, f))
432 return rgbHex(0x2b + (r - 0x2b) * t, 0x2b + (g - 0x2b) * t, 0x30 + (b - 0x30) * t)
433}
434/** A small integer hash of a grid position; ~15% of filled squares sit one step dimmer. */
435function dotHash(col, row, salt) {
436 let x = (col * 374761393 + row * 668265263 + salt * 2246822519) | 0
437 x = Math.imul(x ^ (x >>> 13), 1274126177)
438 return (x ^ (x >>> 16)) >>> 0
439}
440const DOT_UNFILLED = '#3a3a40'
441const DOT_TRACK = '#242428'
442const THUMB_TEXT = '#1d1d21'
443let progressTick = 0 // advanced by the band ticker, 70 ms apiece; every frame is a pure function of it
444const TICK_S = 0.07
445
446/**
447 * The Effort slider's own recipe (its fragment shader, ported): a grid of small rounded cells; bursts
448 * race out from the head as Manhattan-distance wavefronts with per-cell jitter; energy quantizes to a
449 * five-step ramp from white to the state's hot ink; top-step cells breathe; a charged bed deepens
450 * toward the head and stays once reached; the left quarter fades. Tuned brighter than the slider
451 * (ink floor .25, ceiling 1, gain 1.4, lifted bed) at the person's request.
452 */
453const SH = {
454 SPEED_LO: 20, SPEED_HI: 36, RANGE_LO: 10, RANGE_HI: 55, DENSITY_LO: 0.30, DENSITY_HI: 0.85, JITTER: 0.10,
455 HEAD_GAIN: 1.25, HEAD_DECAY: 5, BODY_GAIN: 0.65, BODY_LO: 3.3, BODY_HI: 0.77, AMP_LO: 0.55, MAX_AGE: 5,
456 RAMP_POW: 1.4, BREATH_DIP: 0.28, BREATH_BASE: 2.4, BREATH_VARY: 1.4,
457 inkFloor: 0.25, inkCeil: 1, gain: 1.4, chargeMax: 1, mask: 0.25, fadeUp: 0.7, burstRamp: 2, sparkleFor: 60,
458}
459const INKS = {
460 running: { hot: [0.70, 0.65, 1.00], c0: [0.78, 0.66, 1.00], c1: [0.55, 0.42, 0.95] },
461 waiting: { hot: [1.00, 0.82, 0.48], c0: [1.00, 0.80, 0.40], c1: [0.80, 0.55, 0.18] },
462 failed: { hot: [1.00, 0.60, 0.63], c0: [1.00, 0.50, 0.54], c1: [0.85, 0.25, 0.30] },
463 done: { hot: [0.62, 0.94, 0.76], c0: [0.45, 0.90, 0.68], c1: [0.20, 0.65, 0.45] },
464 cold: { hot: [0.70, 0.70, 0.74], c0: [0.52, 0.52, 0.57], c1: [0.33, 0.33, 0.38] },
465 orange: { hot: [1.00, 0.74, 0.50], c0: [1.00, 0.62, 0.36], c1: [0.80, 0.40, 0.20] },
466 blue: { hot: [0.62, 0.76, 1.00], c0: [0.45, 0.62, 0.98], c1: [0.22, 0.40, 0.80] },
467 green: { hot: [0.66, 0.90, 0.66], c0: [0.48, 0.78, 0.48], c1: [0.25, 0.50, 0.28] },
468 yellow: { hot: [0.98, 0.86, 0.45], c0: [0.86, 0.72, 0.28], c1: [0.60, 0.48, 0.14] },
469}
470const TRACK_RGB = [0.165, 0.165, 0.18] // #2a2a2e
471const BAR_H = 14, BAR_ROWS = 3, BAR_RADIUS = 4
472
473function hash21(x, y) {
474 const v = Math.sin(x * 127.1 + y * 311.7) * 43758.5453
475 return v - Math.floor(v)
476}
477const mixN = (a, b, t) => a + (b - a) * t
478const smooth = (a, b, x) => { const t = Math.max(0, Math.min(1, (x - a) / (b - a))); return t * t * (3 - 2 * t) }
479const toHex = (c) => '#' + c.map((v) => Math.max(0, Math.min(255, Math.round(v * 255))).toString(16).padStart(2, '0')).join('')
480
481function rowSeed(row) {
482 return (hash21(row.id.length, row.id.charCodeAt(0) || 7) * 512) | 0
483}
484
485/**
486 * The burst schedule, deterministic: burst i fires at a hashed interval after burst i-1, at the head,
487 * with a strength that ramps up over the first two seconds. A finished row switches to faint sparks at
488 * random points along the fill for `sparkleFor` seconds, then holds still. Returns the live bursts.
489 */
490function burstsAt(row, t, seed) {
491 const live = row.live === true || row.state === 'running' || row.state === 'waiting'
492 const out = []
493 if (live) {
494 let T = 0
495 for (let i = 0; i < 400 && T <= t; i++) {
496 const gap = 0.3 + 0.45 * hash21(seed + i * 1.7, 3.3)
497 if (T >= t - SH.MAX_AGE) {
498 const k = Math.min(T / SH.burstRamp, 1)
499 const e = 0.6 + 0.4 * row.shown
500 const gain = Math.min((0.15 + 0.7 * e) * (0.45 + 0.55 * k), 1) * (0.85 + 0.3 * hash21(seed + i * 2.9, 5.1))
501 out.push({ t: T, x: row.shown + (hash21(seed + i * 4.1, 7.7) - 0.5) * 0.08, y: 0.35 + 0.3 * hash21(seed + i * 6.3, 9.9), k: gain })
502 }
503 T += gap
504 }
505 } else {
506 const t0 = row.settledAt || 0
507 out.push({ t: t0, x: row.shown, y: 0.5, k: 0.9 }) // the one front that charges the bed
508 if (row.state === 'done' && t - t0 < SH.sparkleFor) {
509 let T = t0 + 1.2
510 for (let i = 0; i < 200 && T <= t; i++) {
511 if (T >= t - SH.MAX_AGE) out.push({ t: T, x: row.shown * (0.08 + 0.92 * hash21(seed + i * 3.7, 2.2)), y: 0.2 + 0.6 * hash21(seed + i * 5.3, 4.4), k: 0.3 + 0.15 * hash21(seed + i * 1.3, 6.6) })
512 T += 0.5 + 0.7 * hash21(seed + i * 2.1, 8.8)
513 }
514 }
515 }
516 return out.slice(-8)
517}
518
519/** Colour of one cell as [r,g,b] 0..1 composited over the track, or null where nothing paints. */
520function shadeCell(row, cx, cy, cols, rows, t, bursts, seed, pitchPx, W) {
521 const live = row.live === true || row.state === 'running' || row.state === 'waiting'
522 const pos = Math.max(row.shown, 0.02)
523 const px = (cx + 0.5) * pitchPx
524 if (px > pos * W + pitchPx) return null
525 const L = live ? Math.min(t / SH.fadeUp, 1) : 1
526 const jitter = (hash21(cx + 41.7 + seed, cy + 41.7 + seed) - 0.5) * 2 * SH.JITTER
527 const vary = 0.35 + 0.65 * hash21(cx + 13.7 + seed, cy + 13.7 + seed)
528 let energy = 0, charged = 0
529 for (const b of bursts) {
530 const age = t - b.t
531 if (age < 0 || age > SH.MAX_AGE) continue
532 const ox = Math.floor((b.x * W) / pitchPx), oy = Math.floor(b.y * rows)
533 const manh = Math.abs(cx - ox) + Math.abs(cy - oy)
534 const range = mixN(SH.RANGE_LO, SH.RANGE_HI, b.k)
535 const falloff = Math.exp(-manh / (range * 0.85)), reach = Math.exp(-Math.max(manh - range, 0) * 0.12)
536 const tt = age - manh / mixN(SH.SPEED_LO, SH.SPEED_HI, b.k) - jitter
537 charged = Math.max(charged, smooth(0, 0.15, tt) * reach)
538 if (tt < 0) continue
539 if (hash21(cx + 7.3 + seed + b.t, cy + 7.3 + seed + b.t) > mixN(SH.DENSITY_LO, SH.DENSITY_HI, b.k)) continue
540 const head = SH.HEAD_GAIN * Math.exp(-tt * SH.HEAD_DECAY), body = SH.BODY_GAIN * Math.exp(-tt * mixN(SH.BODY_LO, SH.BODY_HI, b.k))
541 energy += (head + body) * mixN(SH.AMP_LO, 1, b.k) * falloff * reach * vary
542 }
543 if (!live && t - (row.settledAt || 0) > 1.6) charged = 1 // a finished row keeps its bed
544 const lumA = hash21(cx + 8.8 + seed, cy + 8.8 + seed), lumB = hash21(cx + 88.8 + seed, cy + 88.8 + seed)
545 const lum = Math.pow(mixN(lumA, lumB, 0.5 + 0.5 * Math.sin(t * 0.35 + lumA * 6.2832)), 1.9)
546 const stream = 0.9 + 0.16 * Math.sin(t * 1.3 + cx * 0.45 + lumB * 2)
547 const e = Math.min(Math.max(energy, 0), 1) * L * mixN(0.16, 1, lum) * stream * SH.gain
548 let ink = INKS[row.state] || INKS.running
549 if (row.segments) { const f = px / W; const sg = row.segments.find((x) => f <= x.end) || row.segments[row.segments.length - 1]; if (sg) ink = INKS[sg.ink] || ink }
550 const g = Math.max(0, Math.min(1, px / Math.max(pos * W, 1)))
551 const chargeA = SH.chargeMax * Math.pow(g, 1.1) * charged * L
552 const lv = (e > 0.04) + (e > 0.2) + (e > 0.4) + (e > 0.6) + (e > 0.8)
553 let alpha = lv <= 0 ? 0 : mixN(SH.inkFloor, SH.inkCeil, (lv - 1) / 4)
554 if (lv >= 5) {
555 const period = SH.BREATH_BASE + SH.BREATH_VARY * hash21(cx + 3.1 + seed, cy + 3.1 + seed)
556 alpha *= 1 - SH.BREATH_DIP * 0.5 * (1 + Math.sin((t * 6.2832) / period + hash21(cx + 5.5 + seed, cy + 5.5 + seed) * 6.2832))
557 }
558 const a = alpha + chargeA * (1 - alpha)
559 if (a <= 0) return null
560 const q = Math.pow(lv / 5, SH.RAMP_POW)
561 const m = SH.mask ? Math.min(1, px / (SH.mask * W)) : 1
562 return [0, 1, 2].map((i) => {
563 const inkc = mixN(1, ink.hot[i], q)
564 const bed = mixN(ink.c0[i], ink.c1[i], g)
565 const col = inkc * alpha + bed * chargeA * (1 - alpha)
566 return Math.min(1, TRACK_RGB[i] * (1 - a * m) + col * m)
567 })
568}
569
570/** Desktop and the web surfaces: the slider's cells at 14 px, squared corners, a gradient text pill trailing the head. */
571function progressSvg(row, width, marks = []) {
572 const W = Math.max(160, Math.floor(width)), H = BAR_H, rows = BAR_ROWS
573 const pitch = H / rows, cellHalf = (3 / 4) * pitch / 2, cols = Math.ceil(W / pitch)
574 const t = progressTick * TICK_S
575 const seed = rowSeed(row)
576 const bursts = burstsAt(row, t, seed)
577 const squares = []
578 for (let cy = 0; cy < rows; cy++) {
579 for (let cx = 0; cx < cols; cx++) {
580 const c = shadeCell(row, cx, cy, cols, rows, t, bursts, seed, pitch, W)
581 if (!c) continue
582 const x = (cx + 0.5) * pitch - cellHalf, y = (cy + 0.5) * pitch - cellHalf
583 squares.push(`<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${(cellHalf * 2).toFixed(1)}" height="${(cellHalf * 2).toFixed(1)}" rx="0.9" fill="${toHex(c)}"/>`)
584 }
585 }
586 const label = pillText(row)
587 const ink = INKS[row.state] || INKS.running
588 const pw = Math.min(W - 4, Math.round(label.length * 6.1 + 14))
589 const headX = Math.max(row.shown, 0.02) * W
590 const px = headX + pw + 2 <= W ? headX : Math.max(2, W - pw - 2) // trail the head; flip behind it at the right end
591 const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<')
592 const gid = `g${row.id.replace(/[^a-z0-9]/gi, '')}`
593 return (
594 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">` +
595 `<defs><clipPath id="c${gid}"><rect width="${W}" height="${H}" rx="${BAR_RADIUS}"/></clipPath>` +
596 `<linearGradient id="${gid}" x1="0" x2="1"><stop offset="0" stop-color="${toHex(ink.c0)}"/><stop offset="1" stop-color="${toHex(ink.c1)}"/></linearGradient></defs>` +
597 `<rect width="${W}" height="${H}" rx="${BAR_RADIUS}" fill="#2a2a2e"/>` +
598 `<g clip-path="url(#c${gid})">${squares.join('')}${marks.map((m) => `<rect x="${(m.frac * W - 0.5).toFixed(1)}" y="2" width="1" height="${H - 4}" fill="${m.color || '#d4d3cf'}" opacity="0.9"/>`).join('')}</g>` +
599 `<rect x="${px.toFixed(1)}" y="1" width="${pw}" height="${H - 2}" rx="${BAR_RADIUS}" fill="url(#${gid})"/>` +
600 `<text x="${(px + pw / 2).toFixed(1)}" y="${H / 2 + 3.5}" text-anchor="middle" font-family="-apple-system, Inter, Segoe UI, sans-serif" font-size="10" font-weight="600" fill="#ffffff">${esc(label)}</text>` +
601 `</svg>`
602 )
603}
604
605/** Standard base64 of a byte array; the sandbox has no Buffer and toBase64 is not everywhere yet. */
606function base64(bytes) {
607 const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
608 let out = ''
609 for (let i = 0; i < bytes.length; i += 3) {
610 const a = bytes[i], b = bytes[i + 1], c = bytes[i + 2]
611 const n = (a << 16) | ((b || 0) << 8) | (c || 0)
612 out += A[(n >> 18) & 63] + A[(n >> 12) & 63] + (b === undefined ? '=' : A[(n >> 6) & 63]) + (c === undefined ? '=' : A[n & 63])
613 }
614 return out
615}
616/** One-row Raster cells: a half-block square on every even global column, a blank gap between. `colorAt(col)` gives
617 * 0xRRGGBB or null for the bare track. `offset` is the global index of this segment's first column. */
618function rasterCells(columns, colorAt, offset = 0) {
619 const DEFAULT = 0x01000000
620 const words = new Uint32Array(columns * 3)
621 let i = 0
622 for (let c = 0; c < columns; c++) {
623 const g = c + offset
624 const col = colorAt(g)
625 if (col === -2) { words[i++] = 0x2502; words[i++] = 0x9a9aa0; words[i++] = DEFAULT } // │ a mark
626 else if (g % 2 === 1) { words[i++] = 0x20; words[i++] = DEFAULT; words[i++] = DEFAULT } // gap column
627 else { words[i++] = 0x2580; words[i++] = col === null ? 0x33333a : col; words[i++] = DEFAULT } // ▀ square, dim on the bare track
628 }
629 return base64(new Uint8Array(words.buffer))
630}
631
632/** The terminal: the same cells, one row, split around a coloured text pill that trails the head. */
633function progressTrackText(row, cells, h, Text, Raster, marks = []) {
634 const t = progressTick * TICK_S
635 const seed = rowSeed(row)
636 const bursts = burstsAt(row, t, seed)
637 const ink = INKS[row.state] || INKS.running
638 const label = ` ${pillText(row)} `
639 const pillW = Math.min(cells, label.length)
640 const head = Math.round(cells * Math.max(row.shown, 0.02))
641 const pillX = head + pillW <= cells ? head : Math.max(0, cells - pillW)
642 const thumb = h(Text, { color: '#ffffff', backgroundColor: toHex(ink.c1), bold: true }, label.slice(0, pillW))
643 const markCols = new Set(marks.map((m) => Math.max(0, Math.min(cells - 1, Math.round(m.frac * cells)))))
644 const colorAt = (x) => {
645 if (markCols.has(x)) return -2
646 const c = shadeCell(row, x, 0, cells, 1, t, bursts, seed, 1, cells)
647 if (!c) return null
648 return parseInt(toHex(c).slice(1), 16)
649 }
650 if (typeof Raster === 'function') {
651 const parts = []
652 if (pillX > 0) parts.push(h(Raster, { key: `progress-dots:${row.id}:l`, columns: pillX, rows: 1, cells: rasterCells(pillX, colorAt, 0) }))
653 parts.push(thumb)
654 const rest = cells - pillX - pillW
655 if (rest > 0) parts.push(h(Raster, { key: `progress-dots:${row.id}:r`, columns: rest, rows: 1, cells: rasterCells(rest, colorAt, pillX + pillW) }))
656 return parts
657 }
658 const parts = []
659 let run = '', runProps = null, runKey = ''
660 const flush = () => { if (run) { parts.push(h(Text, runProps, run)); run = '' } }
661 for (let x = 0; x < cells; x++) {
662 if (x === pillX) { flush(); parts.push(thumb); x += pillW - 1; continue }
663 const c = colorAt(x)
664 const key = c === null ? 'u' : c === -2 ? 'm' : toHex([(c >> 16) / 255, ((c >> 8) & 255) / 255, (c & 255) / 255])
665 const props = c === null ? { color: '#3a3a40' } : c === -2 ? { color: '#9a9aa0' } : { color: key }
666 if (key !== runKey) { flush(); runKey = key; runProps = props }
667 run += c === null ? '·' : c === -2 ? '│' : '▪'
668 }
669 flush()
670 return parts
671}
672
673function progressSettled() {
674 for (const r of progressRows.values()) if (Math.abs(r.target - r.shown) > 0.004) return false
675 return true
676}
677
678// ---------------------------------------------------------------------------
679// The Context pane (/tokens): the pack's own dashboard, drawn to the M4 design (design/m4.js). Closed: the title with
680// the fill, the context bar (F1 fire, ink in the threshold colour), the cache and saved line, Clear / Compact / Later.
681// Open: what was saved, three cards (next mark, billing, cache), Claude's working memory by kind, drill-down groups.
682const CONTEXT_PANE = 'context'
683const BREAKDOWN_TTL = 10 * 1000 // $.session.usage({ breakdown: 'summary' }) is cached this long; redraws cost nothing
684let ctxCache = { at: 0, usage: null }
685let ctxLoading = null
686let ctxBusy = false
687let ctxMsg = ''
688let ctxPaneOpen = false
689let ctxExpanded = false
690let ctxReceipt = false
691let ctxPopped = false // desktop: the Pane was opened by the band's ⤢ (cleared when it closes)
692const ctxGroups = new Set() // which drill-down groups are open
693let homeCache = null // $HOME, read once, to shorten memory-file paths
694/** Context added per message: tokens at turn.complete less tokens at turn.start, the last five. */
695const growth = []
696let turnStartTokens = null
697const GROWTH_KEEP = 5
698
699/** Threshold colour by marks crossed: calm blue, then amber, Claude orange, red. Theme keys for Text. */
700const ACC = ['permission', 'warning', 'claude', 'error']
701/** The same theme keys as hex, per surface (design/theme/claude-themes.json, dark), for the bars' ink. */
702const THEME_HEX = {
703 terminal: { permission: '#b1b9f9', warning: '#ffc107', claude: '#d77757', error: '#ff6b80', success: '#4eba65', text: '#ffffff', inactive: '#999999', subtle: '#505050' },
704 desktop: { permission: '#74abe2', warning: '#fab219', claude: '#d97757', error: '#dd5353', success: '#65bb30', text: '#faf9f5', inactive: '#9c9a92', subtle: '#4a4945' },
705}
706const CACHE_GRAD = ['#629cde', '#a8a38c', '#da934b']
707const CACHE_TRACK = '#2e2d29'
708const WARM = '#ce9466'
709const COLD = '#629cde'
710const BAR_TRACK = '#2a2a2e'
711const BUFFER_CELL = '#45454b'
712// Desktop: the pane's ground lifted 7% toward the text, and the buffer 30% on toward faint, as design/m4.html draws them
713const DESK_TRACK = '#353533'
714const DESK_BUFFER = '#545350'
715const MARK_TICK = '#c2c0b6'
716const AUTO_TICK = '#7d7b75'
717/** Claude's working memory: one family around Claude's orange, so it never reads as a signal colour. */
718const MEM = { messages: '#e07a52', mcp: '#f2a07b', skills: '#c6613f', system: '#f6c9a8', tools: '#9e9a97', memory: '#b98b72', agents: '#d9a888', other: '#a8968a', buffer: '#64635e', free: '#46453f' }
719const MEM_RANK = ['messages', 'mcp', 'skills', 'system', 'tools', 'memory', 'agents', 'other']
720const BAR_PAD = 8 // desktop: px of air above and below the context bar, as design/m4.html's 10 px gaps
721const INNER_PAD = 5 // desktop: the same air, smaller, for the Saved, card and memory bars inside the open pane
722const PX = 7.8 // desktop px per body column (the band measured 523 pt over 66 columns: 7.9)
723const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
724
725/** 484000 → "484k", 1000000 → "1M", 1500000 → "1.5M". */
726function fmtK(n) {
727 if (!n || n < 0) return '0'
728 if (n >= 999_500) {
729 const m = n / 1_000_000
730 const s = m >= 10 ? String(Math.round(m)) : m.toFixed(1).replace(/\.0$/, '')
731 return s + 'M'
732 }
733 if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k'
734 return String(Math.round(n))
735}
736
737function pct100(x) {
738 return Number.isFinite(x) ? Math.max(0, Math.min(100, Math.round(x))) : 0
739}
740
741const cap1 = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s)
742const rgb01 = (hex) => hexRgb(hex).map((v) => v / 255)
743const mixHex = (a, b, t) => toHex(mix3(rgb01(a), rgb01(b), t))
744const gradAt = (f) => (f < 0.5 ? mixHex(CACHE_GRAD[0], CACHE_GRAD[1], f / 0.5) : mixHex(CACHE_GRAD[1], CACHE_GRAD[2], (f - 0.5) / 0.5))
745const avgGrowth = () => (growth.length ? growth.reduce((a, b) => a + b, 0) / growth.length : 0)
746function crossedAt(p) {
747 return Math.min(3, levels.filter((l) => Number(l.pct) <= p).length)
748}
749function seedOf(s) {
750 let x = 7
751 for (let i = 0; i < s.length; i++) x = (x * 31 + s.charCodeAt(i)) % 997
752 return x
753}
754
755// ---- F1 "Comet", ported from design/fire/F1.js: a white-hot core at the head, a flickering body, wind-blown
756// tongues and embers carried left, cooling white → hot → c0 → c1 into a calm bed. Pure: a frame is a function of t.
757const FC = {
758 CORE: 2.2, NOSE: 1.5, ZONE: 175, BODY_LEN: 17, TONGUE_LEN: 80, WIND: 170, BODY_CELL: 7, TONGUE_CELL: 30,
759 EMBER_GAP: 0.018, EMBER_V_LO: 150, EMBER_V_HI: 310, EMBER_D_LO: 30, EMBER_D_HI: 165, EMBER_SPREAD: 1.6, STREAK: 1.1, TICK: 0.07,
760 BED_LO: 0.55, BED_HI: 0.86,
761}
762const WHITE = [1, 1, 1]
763function mix3(a, b, t) {
764 return [mixN(a[0], b[0], t), mixN(a[1], b[1], t), mixN(a[2], b[2], t)]
765}
766function vnoise(u, row) {
767 const i = Math.floor(u), f = u - i, s = f * f * (3 - 2 * f)
768 return mixN(hash21(i, row), hash21(i + 1, row), s)
769}
770/** The heat ramp: 0 is the bed (or the track ahead), then c1, c0, hot, white, in four even steps. */
771function f1Ramp(h, base, ink) {
772 if (h <= 0) return base
773 if (h >= 1) return WHITE
774 const k = h * 4, i = Math.floor(k), f = k - i
775 const stops = [base, ink.c1, ink.c0, ink.hot, WHITE]
776 return mix3(stops[i], stops[i + 1], f)
777}
778/** A bar for the shader. `ink` from one colour: hot = toward white, c0 = itself, c1 = toward black. */
779function inkOf(hex) {
780 const c = rgb01(hex)
781 return { hot: mix3(c, WHITE, 0.45), c0: c, c1: mix3(c, [0, 0, 0], 0.35) }
782}
783function fireBar(geo, shown, live, seed, ink, trackHex) {
784 const s = Math.max(Math.min(shown, 1), 0.02)
785 return { W: geo.W, H: geo.H, rows: geo.rows, pitch: geo.pitch, cols: geo.cols, shown: s, head: s * geo.W, live, seed, wind: 1, ink, track: rgb01(trackHex) }
786}
787function f1Frame(bar, t) {
788 const { W, pitch, rows, cols, head, live, seed } = bar
789 const U = Math.sqrt((W * pitch) / (826 * 3.5))
790 const wind = Math.max(bar.wind ?? 1, 0.05)
791 const hc = Math.max(0, Math.round(head / pitch - 0.5))
792 const zone = FC.ZONE * U
793 const lo = Math.max(0, Math.floor((head - zone) / pitch)), hi = Math.min(cols - 1, hc + 2)
794 const n = Math.max(0, hi - lo + 1)
795 const heat = new Float32Array(rows * n)
796 const mid = (rows - 1) / 2
797 const pulse = live ? 0.93 + 0.07 * Math.sin(t * 23 + seed) * Math.sin(t * 9.7) : 0.5 + 0.08 * Math.sin(t * 2.1)
798 const drift = live ? FC.WIND * U * wind * t : 0
799 for (let cy = 0; cy < rows; cy++) {
800 const off = Math.abs(cy - mid) / Math.max(mid, 1)
801 const rowK = 1 - 0.35 * off
802 for (let cx = lo; cx <= hi; cx++) {
803 const p = (cx + 0.5) * pitch, d = head - p
804 let h
805 if (d < -0.5 * pitch) {
806 h = d >= -FC.NOSE * pitch ? (live ? 0.8 : 0.5) * pulse * (1 - 0.7 * off) : 0
807 } else if (d <= FC.CORE * pitch) {
808 h = live ? (d < 0.3 * pitch ? 1 - 0.12 * off : 1) * pulse + 0.05 : 0.62 + pulse * 0.2
809 } else {
810 const du = d / U
811 const u = (p + drift) / U
812 const flick = vnoise(u / FC.BODY_CELL, cy * 7.3 + seed)
813 const body = Math.exp(-(du - (FC.CORE * pitch) / U) / (FC.BODY_LEN * rowK)) * (live ? 0.62 + 0.5 * flick : 0.75)
814 let tongue = 0
815 if (live) {
816 const tn = vnoise((u + (1.3 * drift) / U) / FC.TONGUE_CELL, cy * 3.1 + seed * 0.37)
817 const s = clamp01((tn - 0.48) / 0.3)
818 tongue = s * s * Math.exp(-du / (FC.TONGUE_LEN * rowK)) * 1.2
819 }
820 h = Math.max(body, tongue)
821 }
822 heat[cy * n + (cx - lo)] = Math.min(h, 1)
823 }
824 }
825 if (live) {
826 const span = FC.EMBER_D_HI / FC.EMBER_V_LO / wind
827 const iHi = Math.floor(t / FC.EMBER_GAP), iLo = Math.max(0, Math.floor((t - span) / FC.EMBER_GAP) - 1)
828 for (let i = iLo; i <= iHi; i++) {
829 const born = (i + 0.8 * hash21(i, seed + 1.7)) * FC.EMBER_GAP
830 const age = t - born
831 if (age < 0) continue
832 const hv = hash21(i, seed + 5.3), hd = hash21(i, seed + 9.1), hy = hash21(i, seed + 13.9)
833 const v = mixN(FC.EMBER_V_LO, FC.EMBER_V_HI, hv) * U * wind
834 const reach = mixN(FC.EMBER_D_LO, FC.EMBER_D_HI, hd * hd) * U
835 const life = reach / v
836 if (age >= life) continue
837 const k = age / life
838 const x = head - FC.CORE * pitch - v * age
839 if (x < 0) continue
840 const y0 = mid + (hy - 0.5) * rows * 0.7
841 const y = Math.max(0, Math.min(rows - 1, mid + (y0 - mid) * (1 + FC.EMBER_SPREAD * k)))
842 const cy = Math.round(y)
843 const h0 = (0.85 + 0.3 * hash21(i, seed + 21.1)) * Math.pow(1 - k, 0.7)
844 const tail = v * FC.TICK * FC.STREAK
845 const c0 = Math.floor(x / pitch), c1 = Math.floor((x + tail) / pitch)
846 for (let c = c0; c <= c1; c++) {
847 if (c < lo || c > hi) continue
848 const along = c1 > c0 ? (c - c0) / (c1 - c0) : 0
849 const hh = h0 * (1 - 0.65 * along)
850 const j = cy * n + (c - lo)
851 if (hh > heat[j]) heat[j] = hh
852 }
853 }
854 }
855 return { lo, hi, n, heat }
856}
857function f1Shade(cx, cy, bar, s) {
858 const { pitch, head, ink, track } = bar
859 const p = (cx + 0.5) * pitch
860 const h = cx >= s.lo && cx <= s.hi ? s.heat[cy * s.n + (cx - s.lo)] : 0
861 if (p > head + 0.5 * pitch) return h <= 0 ? null : f1Ramp(h, track, ink)
862 const g = Math.min(p / Math.max(head, 1), 1)
863 const a = mixN(FC.BED_LO, FC.BED_HI, g) * (0.94 + 0.06 * hash21(cx, cy + bar.seed))
864 const bed = mix3(track, mix3(ink.c1, ink.c0, 0.3 * g * g), a)
865 return f1Ramp(h, bed, ink)
866}
867/** The head's soft glow: one radial gradient, screen-blended (desktop only). */
868function f1Glow(bar, t) {
869 const U = Math.sqrt((bar.W * bar.pitch) / (826 * 3.5))
870 const live = bar.live
871 const pulse = live ? 0.9 + 0.1 * Math.sin(t * 23 + bar.seed) * Math.sin(t * 9.7) : 0.85 + 0.15 * Math.sin(t * 2.1)
872 return { x: bar.head - (live ? 12 : 4) * U, y: bar.H / 2, rx: (live ? 38 : 14) * U, ry: bar.H * (live ? 0.95 : 0.7), rgb: mix3(WHITE, bar.ink.hot, live ? 0.35 : 0.8), alpha: (live ? 0.34 : 0.14) * pulse }
873}
874
875// ---- Cell bars, per surface: an Svg of rounded squares on the desktop, a half-block Raster on the terminal.
876/** Where a bar's cells sit. Desktop: `size` px wide, pitch = H / rows. Terminal: `size` columns, two half-block rows. */
877function geoOf(term, size, H, rows) {
878 if (term) {
879 const cols = Math.max(1, Math.min(512, Math.floor(size)))
880 return { term: true, cols, rows: 2, pitch: 8, H: 16, W: cols * 8 }
881 }
882 const W = Math.max(20, Math.floor(size)), pitch = H / rows
883 return { term: false, W, H, rows, pitch, cols: Math.ceil(W / pitch) }
884}
885/**
886 * One bar. `colorAt(cx, cy)` → [r,g,b] in 0..1, or null for the bare track. `o.ticks`: [{ frac, color, w }] drawn on
887 * top; `o.glow` (desktop): an f1Glow. Returns null where the surface has neither Svg nor Raster.
888 */
889function cellBar(ui, key, geo, colorAt, o = {}) {
890 const track = o.track || BAR_TRACK
891 if (geo.term) {
892 const trackN = parseInt(track.slice(1), 16)
893 const num = (c) => (c ? parseInt(toHex(c).slice(1), 16) : trackN)
894 const ticks = new Map((o.ticks || []).map((m) => [Math.max(0, Math.min(geo.cols - 1, Math.floor(m.frac * geo.cols))), m.color]))
895 if (typeof ui.Raster === 'function') {
896 const words = new Uint32Array(geo.cols * 3)
897 for (let cx = 0, i = 0; cx < geo.cols; cx++) {
898 const a = colorAt(cx, 0), b = colorAt(cx, 1), tk = ticks.get(cx)
899 if (tk) { words[i++] = 0x2502; words[i++] = parseInt(tk.slice(1), 16); words[i++] = num(b) } // │ a mark
900 else { words[i++] = 0x2580; words[i++] = num(a); words[i++] = num(b) } // ▀ top cell fg, bottom bg
901 }
902 return h(ui.Raster, { key, columns: geo.cols, rows: 1, cells: base64(new Uint8Array(words.buffer)) })
903 }
904 const parts = []
905 let run = '', runKey = '', runProps = null
906 for (let cx = 0; cx < geo.cols; cx++) {
907 const tk = ticks.get(cx)
908 const a = tk || toHex(colorAt(cx, 0) || rgb01(track)), b = toHex(colorAt(cx, 1) || rgb01(track))
909 const k = `${a}${b}${tk ? '|' : ''}`
910 if (k !== runKey) { if (run) parts.push(h(ui.Text, runProps, run)); run = ''; runKey = k; runProps = { color: a, backgroundColor: b } }
911 run += tk ? '│' : '▀'
912 }
913 if (run) parts.push(h(ui.Text, runProps, run))
914 return h(ui.Box, { flexDirection: 'row' }, ...parts)
915 }
916 if (typeof ui.Svg !== 'function') return null
917 const { W, H, pitch, rows, cols } = geo
918 // one cell shape, placed with <use>: a frame is redrawn every other tick, so keep the markup small
919 const half = 0.375 * pitch, size = (half * 2).toFixed(2), rx = Math.min(0.9, half * 0.35).toFixed(2)
920 const cid = `c${key.replace(/[^a-z0-9]/gi, '')}`
921 const cells = []
922 for (let cy = 0; cy < rows; cy++) {
923 const run = []
924 for (let cx = 0; cx < cols; cx++) {
925 const c = colorAt(cx, cy)
926 if (c) run.push(`<use href="#${cid}" x="${((cx + 0.5) * pitch - half).toFixed(1)}" fill="${toHex(c)}"/>`)
927 }
928 if (run.length) cells.push(`<g transform="translate(0 ${((cy + 0.5) * pitch - half).toFixed(2)})">${run.join('')}</g>`)
929 }
930 const ticks = (o.ticks || []).map((m) => `<rect x="${(m.frac * W - (m.w || 1.5) / 2).toFixed(1)}" y="0" width="${m.w || 1.5}" height="${H}" fill="${m.color}"/>`)
931 let defs = `<rect id="${cid}" width="${size}" height="${size}" rx="${rx}"/>`, glow = ''
932 const g = o.glow
933 if (g && g.alpha > 0) {
934 const id = `gl${key.replace(/[^a-z0-9]/gi, '')}`, c = toHex(g.rgb)
935 defs += `<radialGradient id="${id}"><stop offset="0" stop-color="${c}" stop-opacity="${g.alpha.toFixed(3)}"/><stop offset="1" stop-color="${c}" stop-opacity="0"/></radialGradient>`
936 glow = `<ellipse cx="${g.x.toFixed(1)}" cy="${g.y.toFixed(1)}" rx="${g.rx.toFixed(1)}" ry="${g.ry.toFixed(1)}" fill="url(#${id})" style="mix-blend-mode:screen"/>`
937 }
938 // padY: clear space above and below, in px (a desktop Box margin is a whole line; the design wants ~10 px)
939 const pad = o.padY || 0, HH = H + 2 * pad
940 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${HH}" viewBox="0 ${-pad} ${W} ${HH}"><defs>${defs}</defs>` +
941 `<rect width="${W}" height="${H}" rx="${o.radius ?? Math.min(4, H / 2)}" fill="${track}"/>${cells.join('')}${glow}${ticks.join('')}</svg>`
942 return h(ui.Svg, { source, alt: o.alt || key, height: HH, width: W })
943}
944
945/** The context bar: F1 in the threshold colour, ticks at the marks still ahead, the auto-compact buffer drawn still. */
946function contextBar(ui, geo, view, t, live, padY = 0) {
947 const track = geo.term ? BAR_TRACK : DESK_TRACK
948 const info = fireBar(geo, view.pct / 100, live, 31, inkOf(ui.hex(view.accent)), track)
949 const st = f1Frame(info, t)
950 const autoX = view.compactPct === null ? Infinity : (view.compactPct / 100) * geo.W
951 const buf = rgb01(geo.term ? BUFFER_CELL : DESK_BUFFER)
952 const colorAt = (cx, cy) => {
953 const px = (cx + 0.5) * geo.pitch
954 if (px >= autoX && px > info.head + 3 * geo.pitch) return buf
955 return f1Shade(cx, cy, info, st)
956 }
957 const ticks = view.marksAll.filter((m) => m.pct > view.pct && m.pct < 100).map((m) => ({ frac: m.pct / 100, color: MARK_TICK, w: 1.5 }))
958 if (view.compactPct !== null) ticks.push({ frac: view.compactPct / 100, color: AUTO_TICK, w: 1 })
959 return cellBar(ui, 'ctx-bar', geo, colorAt, { track, ticks, padY, glow: geo.term ? null : f1Glow(info, t), alt: `Context ${view.pct}%` })
960}
961
962/** A card's bar: F1 live ('fire') or calm ('ember'); a dim run to where it is heading; a tick (the time in a window). */
963function cardBar(ui, key, geo, bar, t, style) {
964 const grad = bar.ink === 'grad'
965 const inkHex = grad ? CACHE_GRAD[2] : ui.hex(bar.ink || 'inactive')
966 const ink = grad ? { hot: mix3(rgb01(CACHE_GRAD[2]), WHITE, 0.45), c0: rgb01(CACHE_GRAD[2]), c1: rgb01(CACHE_GRAD[0]) } : inkOf(inkHex)
967 const lit = bar.fill > 0
968 const info = fireBar(geo, bar.fill, style === 'fire', seedOf(key), ink, CACHE_TRACK)
969 const st = lit ? f1Frame(info, t) : null
970 const dim = bar.ext > bar.fill ? rgb01(mixHex(inkHex, CACHE_TRACK, 0.55)) : null
971 const colorAt = (cx, cy) => {
972 const c = lit ? f1Shade(cx, cy, info, st) : null
973 if (c) return c
974 const f = (cx + 0.5) / geo.cols
975 return dim && f > bar.fill + 1 / geo.cols && f <= bar.ext ? dim : null
976 }
977 const ticks = bar.tick === null || bar.tick === undefined ? [] : [{ frac: bar.tick, color: ui.hex('text'), w: 2 }]
978 return cellBar(ui, key, geo, colorAt, { track: CACHE_TRACK, ticks, glow: geo.term || !lit ? null : f1Glow(info, t), radius: 3, padY: geo.term ? 0 : INNER_PAD, alt: key })
979}
980
981/** The Saved bar: three greens, one per source; a soft white glint sweeps across the fill every 3.2 s. */
982function savedBar(ui, geo, parts, share, t) {
983 const fillW = share * geo.W
984 let acc = 0
985 const segs = parts.map((p) => ({ end: (acc += p.f) * geo.W, rgb: rgb01(p.color) }))
986 const sweep = ((t % 3.2) / 3.2) * (fillW + 120) - 60
987 const colorAt = (cx, cy) => {
988 const px = (cx + 0.5) * geo.pitch
989 if (px > fillW || !segs.length) return null
990 const base = (segs.find((s) => px <= s.end) || segs[segs.length - 1]).rgb
991 const dx = (px - sweep + cy * 3) / 22
992 const k = 0.55 * Math.exp(-dx * dx)
993 return k > 0.01 ? mix3(base, WHITE, k) : base
994 }
995 return cellBar(ui, 'saved-bar', geo, colorAt, { track: CACHE_TRACK, radius: 3, padY: geo.term ? 0 : INNER_PAD, alt: `Saved, ${Math.round(share * 100)}% of input` })
996}
997
998/** Working memory: a cell per slice of the window by kind; a light wave runs through the used part every 2.8 s. */
999function memoryBar(ui, geo, parts, t) {
1000 let acc = 0, usedEnd = 0, open = true
1001 const segs = parts.map((p) => {
1002 acc += p.f
1003 if (open && p.color) usedEnd = acc
1004 else open = false
1005 return { end: acc, rgb: p.color ? rgb01(p.color) : null }
1006 })
1007 const wave = ((t % 2.8) / 2.8) * (usedEnd * geo.W + 160) - 60
1008 const colorAt = (cx, cy) => {
1009 const f = (cx + 0.5) / geo.cols
1010 const s = segs.find((x) => f < x.end)
1011 if (!s || !s.rgb) return null
1012 if (f > usedEnd) return s.rgb
1013 const dx = ((cx + 0.5) * geo.pitch - wave) / 26
1014 const k = 0.42 * Math.exp(-dx * dx)
1015 return k > 0.01 ? mix3(s.rgb, WHITE, k) : s.rgb
1016 }
1017 return cellBar(ui, 'mem-bar', geo, colorAt, { padY: geo.term ? 0 : INNER_PAD, alt: "Claude's working memory by kind" })
1018}
1019
1020/** Five tiny cells for the closed pane: lit count is the warmth along the cache gradient; cold is all frozen blue. */
1021function cacheMini(ui, cache) {
1022 const FROZEN = mixHex(COLD, CACHE_TRACK, 0.45)
1023 const lit = cache.warm ? Math.max(1, Math.ceil(cache.fill * 5)) : 0
1024 const colors = Array.from({ length: 5 }, (_, i) => (cache.warm ? (i < lit ? gradAt((i + 0.5) / 5) : CACHE_TRACK) : cache.known ? FROZEN : CACHE_TRACK))
1025 if (ui.term || typeof ui.Svg !== 'function') return colors.map((c, i) => h(ui.Text, { key: `mini${i}`, color: c }, '▪'))
1026 const rects = colors.map((c, i) => `<rect x="${i * 7}" y="2.5" width="5" height="5" rx="1" fill="${c}"/>`).join('')
1027 return [h(ui.Svg, { source: `<svg xmlns="http://www.w3.org/2000/svg" width="33" height="10" viewBox="0 0 33 10">${rects}</svg>`, alt: `Cache ${cache.accent}`, width: 33, height: 10 })]
1028}
1029
1030/** Fold one breakdown row to a working-memory kind (colour and order). */
1031function memKey(c) {
1032 if (c.kind === 'free') return 'free'
1033 if (c.kind === 'buffer') return 'buffer'
1034 const n = String(c.name || '').toLowerCase()
1035 if (/mcp/.test(n)) return 'mcp'
1036 if (/skill/.test(n)) return 'skills'
1037 if (/system prompt/.test(n)) return 'system'
1038 if (/system tool|^tools?$/.test(n)) return 'tools'
1039 if (/memory/.test(n)) return 'memory'
1040 if (/agent/.test(n)) return 'agents'
1041 if (/message/.test(n)) return 'messages'
1042 return 'other'
1043}
1044
1045const AGENT_SOURCE = { projectSettings: 'Project', userSettings: 'User', localSettings: 'Local', policySettings: 'Managed', plugin: 'Plugin', flagSettings: 'Flag' }
1046const SKILL_SOURCE = { 'built-in': 'Built-in', bundled: 'Built-in', userSettings: 'User', projectSettings: 'Project', localSettings: 'Local', mcp: 'MCP', syncedSkills: 'Synced', plugin: 'Plugin' }
1047
1048/** Biggest first, at most six, the rest folded into one "N more" row. */
1049function topItems(items, more = 'more') {
1050 const sorted = [...items].sort((a, b) => b.tokens - a.tokens)
1051 if (sorted.length <= 6) return sorted
1052 const rest = sorted.slice(6)
1053 return [...sorted.slice(0, 6), { name: `${rest.length} ${more}`, detail: '', tokens: rest.reduce((n, x) => n + x.tokens, 0) }]
1054}
1055function basename(p) {
1056 return String(p || '').split(/[\\/]/).pop()
1057}
1058
1059/** The drill-down groups, like the › rows in Claude's /context: what is inside each kind, and the command that trims it. */
1060function drillGroups(b, home) {
1061 const out = []
1062 if (!b) return out
1063 const mcp = Array.isArray(b.mcpTools) ? b.mcpTools : []
1064 if (mcp.length) {
1065 const servers = new Map()
1066 for (const tl of mcp) {
1067 const s = servers.get(tl.serverName) || { name: String(tl.serverName || 'mcp'), loaded: 0, deferred: 0, tools: 0, loadedTools: 0 }
1068 s.tools += 1
1069 if (tl.isLoaded === false) s.deferred += Number(tl.tokens) || 0
1070 else { s.loaded += Number(tl.tokens) || 0; s.loadedTools += 1 }
1071 servers.set(tl.serverName, s)
1072 }
1073 const live = [...servers.values()].filter((s) => s.loadedTools > 0)
1074 const asleep = [...servers.values()].filter((s) => s.loadedTools === 0)
1075 const items = topItems(live.map((s) => ({ name: s.name, detail: `${s.tools} tool${s.tools === 1 ? '' : 's'}`, tokens: s.loaded })), 'more servers')
1076 if (asleep.length) {
1077 const tools = asleep.reduce((n, s) => n + s.tools, 0)
1078 items.push({ name: `${asleep.length} deferred server${asleep.length === 1 ? '' : 's'}`, detail: `${tools} tool${tools === 1 ? '' : 's'} · Deferred`, tokens: asleep.reduce((n, s) => n + s.deferred, 0) })
1079 }
1080 const loaded = live.reduce((n, s) => n + s.loaded, 0)
1081 out.push({ id: 'mcp', label: 'MCP tools', tokens: loaded, count: mcp.length, items, cmd: 'mcp', what: "Turn off servers you don't use", save: loaded ? `Up to −${fmtK(loaded)}` : '' })
1082 }
1083 const mem = Array.isArray(b.memoryFiles) ? b.memoryFiles : []
1084 if (mem.length) {
1085 const seen = new Set()
1086 let dupTok = 0, dupName = ''
1087 const items = mem.map((f) => {
1088 const id = `${basename(f.path)}:${f.tokens}`
1089 const twice = seen.has(id)
1090 seen.add(id)
1091 if (twice) { dupTok += Number(f.tokens) || 0; dupName = dupName || basename(f.path) }
1092 const path = home && String(f.path).startsWith(home + '/') ? '~' + String(f.path).slice(home.length) : String(f.path)
1093 return { name: path, detail: twice ? `${f.type || 'File'} · Loaded twice` : String(f.type || ''), tokens: Number(f.tokens) || 0, warn: twice }
1094 })
1095 out.push({ id: 'memory', label: 'Memory files', tokens: items.reduce((n, x) => n + x.tokens, 0), count: mem.length, items: topItems(items),
1096 cmd: 'memory', what: dupTok ? `Drop the duplicate ${dupName}` : "Trim what Claude doesn't need", save: dupTok ? `−${fmtK(dupTok)}` : '' })
1097 }
1098 const agents = Array.isArray(b.agents) ? b.agents : []
1099 if (agents.length) {
1100 const items = agents.map((a) => ({ name: String(a.agentType), detail: AGENT_SOURCE[a.source] || String(a.source || ''), tokens: Number(a.tokens) || 0 }))
1101 const tok = items.reduce((n, x) => n + x.tokens, 0)
1102 out.push({ id: 'agents', label: 'Custom agents', tokens: tok, count: agents.length, items: topItems(items), cmd: 'agents', what: 'Remove agents you never spawn', save: tok ? `Up to −${fmtK(tok)}` : '' })
1103 }
1104 const sk = b.skills && Array.isArray(b.skills.skillFrontmatter) ? b.skills : null
1105 if (sk && sk.skillFrontmatter.length) {
1106 const items = sk.skillFrontmatter.map((s) => ({ name: String(s.name), detail: s.source === 'plugin' && s.pluginName ? String(s.pluginName) : SKILL_SOURCE[s.source] || String(s.source || ''), tokens: Number(s.tokens) || 0, builtin: s.source === 'built-in' || s.source === 'bundled' }))
1107 const parkable = items.filter((x) => !x.builtin).reduce((n, x) => n + x.tokens, 0)
1108 out.push({ id: 'skills', label: 'Skills', tokens: Number(sk.tokens) || items.reduce((n, x) => n + x.tokens, 0), count: Number(sk.totalSkills) || items.length,
1109 items: topItems(items), cmd: 'loadout', what: 'Park skills you never call', save: parkable ? `Up to −${fmtK(parkable)}` : '' })
1110 } else if (skillsData && !skillsData.error && Array.isArray(skillsData.active) && skillsData.active.length) {
1111 // no skill listing in the breakdown: the loadout scan's own figures
1112 const items = skillsData.active.map((s) => ({ name: String(s.name), detail: (skillsData.unused || []).includes(s.name) ? 'Unused 30+ days' : '', tokens: Number(s.per_turn) || 0 }))
1113 const unused = items.filter((x) => x.detail).reduce((n, x) => n + x.tokens, 0)
1114 out.push({ id: 'skills', label: 'Skills', tokens: Number(skillsData.per_turn_tokens) || items.reduce((n, x) => n + x.tokens, 0), count: items.length,
1115 items: topItems(items), cmd: 'loadout', what: 'Park skills you never call', save: unused ? `Up to −${fmtK(unused)}` : '' })
1116 }
1117 return out
1118}
1119
1120/** The pane's view model: context fill and breakdown, our thresholds, cache warmth, and what the pack saved. */
1121function contextView(usage, ttl, usageLast, clock, stats) {
1122 const ctx = (usage && usage.context) || {}
1123 const b = ctx.breakdown || null
1124 const window = Number(ctx.window) || (b && Number(b.rawMaxTokens)) || 0
1125 const used = b && Number.isFinite(b.totalTokens) ? b.totalTokens : Number.isFinite(ctx.tokens) ? ctx.tokens : sentOf(usageLast)
1126 const measure = (b && Number(b.rawMaxTokens)) || window || Math.max(used, 1)
1127 const pct = b && Number.isFinite(b.percentage) ? pct100(b.percentage) : Number.isFinite(ctx.percent) ? pct100(ctx.percent) : pct100((100 * used) / measure)
1128 const compactOn = !!(b && b.isAutoCompactEnabled && Number.isFinite(b.autoCompactThreshold) && b.autoCompactThreshold > 0)
1129 const compactPct = compactOn ? pct100((100 * b.autoCompactThreshold) / measure) : null
1130 const marksAll = levels.map((l) => ({ pct: Number(l.pct), id: String(l.id || ''), hint: l.hint })).filter((m) => Number.isFinite(m.pct)).sort((x, y) => x.pct - y.pct)
1131 const crossed = Math.min(3, marksAll.filter((m) => m.pct <= pct).length)
1132 const nextMark = marksAll.find((m) => m.pct > pct) || null
1133 const prevMark = [...marksAll].reverse().find((m) => m.pct <= pct) || null
1134 const handoffPct = (marksAll.find((m) => m.hint === 'new-conversation') || { pct: 60 }).pct
1135 const compactMarkPct = (marksAll.find((m) => m.hint === 'compact-if-warm') || { pct: 75 }).pct
1136
1137 // the cache: what it means for the next message
1138 const ttlMin = Math.max(1, Math.round(ttl / MINUTE))
1139 const amount = fmtK(used || sentOf(usageLast))
1140 const hit = hitPct(usageLast)
1141 let cache
1142 if (!clock || !clock.known) {
1143 cache = { known: false, warm: false, cooling: false, leftMin: 0, fill: 0, accent: 'Cold', strong: 'No response yet', rest: '', foot: 'The clock starts after the first reply', short: 'Cache after 1st reply',
1144 sentence: [['No response yet: the cache clock starts after the first reply', 'inactive']] }
1145 } else if (clock.warm) {
1146 const leftMin = Math.max(1, Math.round(clock.leftMs / MINUTE))
1147 // "cooling" in the last five minutes, or the last third of a short TTL (a 5-minute TTL would otherwise always cool)
1148 const cooling = clock.leftMs <= Math.min(5 * MINUTE, ttl / 3)
1149 cache = { known: true, warm: true, cooling, leftMin, fill: clock.leftMs / ttl, accent: 'Warm', strong: `Warmth ${pct100((100 * clock.leftMs) / ttl)}%`, rest: ` · Hit ${hit}%`,
1150 foot: `${leftMin}m left of ${ttlMin}m`, short: `Warm ${leftMin}m`,
1151 sentence: cooling
1152 ? [['Cache cooling: ', 'inactive'], [`${leftMin}m left to reuse `, 'inactive'], [amount, 'text'], [' from cache', 'inactive']]
1153 : [['Cache warm: next message reuses ', 'inactive'], [amount, 'text'], [' from cache', 'inactive'], [` · ${leftMin}m left`, 'inactive']] }
1154 } else {
1155 cache = { known: true, warm: false, cooling: false, leftMin: 0, fill: 0, accent: 'Cold', strong: 'Warmth 0%', rest: ` · Hit ${hit}%`,
1156 foot: `Idle ${Math.max(1, Math.round(clock.idleMs / MINUTE))}m, past ${ttlMin}m`, short: 'Cold',
1157 sentence: [['Cache cold: next message re-caches ', 'inactive'], [amount, 'text']] }
1158 }
1159 cache.amount = amount
1160
1161 // what the pack saved, in the currency you pay in
1162 const saved = stats || {}
1163 const T = saved.totals || { turns: 0, input: 0, cacheRead: 0, cacheWrite: 0, output: 0, keptOut: 0, vetoTokens: 0 }
1164 const limits = (usage && usage.rateLimits) || []
1165 const five = limits.find((r) => r.kind === 'five_hour') || null
1166 const hasCost = !!(usage && usage.cost && Number.isFinite(usage.cost.usd))
1167 const mode = hasCost ? 'api' : five ? 'subscription' : 'unknown'
1168 const inputAll = T.input + T.cacheRead + T.cacheWrite
1169 // spend in fresh-input-token equivalents: input 1×, cache write 1.25×, cache read 0.1×, output 5× (the pricing ratios)
1170 const billedUnits = T.input + 1.25 * T.cacheWrite + 0.1 * T.cacheRead + 5 * T.output
1171 const rate = Number(saved.rate) || (hasCost && billedUnits > 50_000 ? (usage.cost.usd / billedUnits) * 1e6 : 0)
1172 const kw = saved.keepWarm || { pings: 0, savedTok: 0, costTok: 0 }
1173 const warmNet = kw.savedTok - kw.costTok // can be negative: pings you never came back for
1174 // Only what the plugin itself did. Claude's own prompt cache is reported beside it, never added in: it would
1175 // read the same with or without the plugin, and claiming it made the headline a lie.
1176 const sources = [
1177 { label: 'Trimmed output', tok: T.keptOut }, // ≈ chars ÷ 4, never re-sent
1178 { label: 'Compactions vetoed', tok: Math.round(T.vetoTokens * 1.25) }, // the 1.25× re-write a cold /compact would have paid
1179 { label: 'Keep-warm', tok: warmNet }, // re-writes avoided, less what the pings cost
1180 ]
1181 const totalTok = Math.max(0, sources[0].tok + sources[1].tok + warmNet)
1182 const cacheInfo = { read: T.cacheRead, line: T.cacheRead > 0 ? `Prompt cache read ${fmtK(T.cacheRead)} tok at a tenth of the price — Claude's own, not counted` : '' }
1183 const share = totalTok > 0 ? totalTok / (totalTok + billedUnits) : 0
1184 const priced = mode === 'api' && rate > 0
1185 const usd = (tok) => (priced ? `$${((tok * rate) / 1e6).toFixed(2)}` : '')
1186 const base = saved.limitBase
1187 const tokPerPct = base && five && five.percentUsed - base.pct >= 1 ? (inputAll - base.tokens) / (five.percentUsed - base.pct) : 0
1188 const spent = hasCost ? usage.cost.usd : 0
1189 const right = mode === 'api'
1190 ? [...(priced ? [[`≈ ${usd(totalTok)}`, 'success'], [` · Spent $${spent.toFixed(2)}`, 'inactive']] : [[`Spent $${spent.toFixed(2)}`, 'inactive']])]
1191 : mode === 'subscription' && tokPerPct > 0 && totalTok > 0 ? [[`≈ ${Math.max(1, Math.round(totalTok / tokPerPct))}% of the 5-hour window`, 'success']] : []
1192 const month = saved.month || null
1193 const monthName = month && /^saved:\d{4}-\d{2}$/.test(month.key) ? MONTHS[Number(month.key.slice(-2)) - 1] : ''
1194 const receipt = [
1195 ...sources.map((s) => ({ label: s.label, tok: fmtK(s.tok), amount: priced ? `≈ ${usd(s.tok)}` : '', role: 'inactive', amountRole: s.tok < 0 ? 'warning' : 'success' })),
1196 ...(T.cacheRead > 0 ? [{ label: "Prompt cache, Claude's own (not counted)", tok: fmtK(T.cacheRead), amount: '', role: 'inactive', amountRole: 'inactive' }] : []),
1197 { rule: true },
1198 { label: 'Total saved', tok: fmtK(totalTok), amount: priced ? `≈ ${usd(totalTok)}` : '', role: 'text', amountRole: 'success' },
1199 ...(priced ? [
1200 { label: 'You paid', tok: '', amount: `$${spent.toFixed(2)}`, role: 'inactive', amountRole: 'text' },hooks/glossary.js 129 lines1/**
2 * The glossary: one source for every term the Context pane uses (ported from design/glossary.js, wording kept).
3 * The pane's Explain and Glossary views and `/tokens learn` read it. Each term: what it is, its live value in this
4 * session (from L, the live numbers), and what to do. A live line built from a missing number is dropped by the
5 * caller (see glossaryFor in register.js), never shown as "undefined".
6 *
7 * Writing rules: no "turn" or "prefix"; every term says why you would care; at most two sentences before the action.
8 */
9export function GLOSSARY(L) {
10 return [
11 { group:'Basics', terms:[
12 { id:'context', name:'Context',
13 what:'Everything Claude reads before it answers: your messages, its replies, tool output, and the instructions and tools that load with every conversation.',
14 live:`${L.used} of ${L.win} tokens (${L.pct}%).`,
15 todo:'Keep one task per conversation, so it stays small.' },
16 { id:'window', name:'Context window',
17 what:'The most Claude can hold at once. Near the end it compacts on its own, and answers get slower and dearer before that.',
18 live:`${L.win} tokens for this model.` },
19 { id:'tokens', name:'Tokens',
20 what:'The unit Claude reads and bills in. A token is roughly three quarters of a word.',
21 live:`This conversation is ${L.used} tokens.` },
22 { id:'requests', name:'Messages and requests',
23 what:'You send a message; while working on it Claude makes a request each time it uses a tool. Every request reads the whole context again.',
24 live:`${L.requests} requests so far this session.` },
25 { id:'estimate', name:'Estimates (≈)',
26 what:'Money figures are worked out from your rate per million tokens. Your bill is the real number.',
27 live:L.api ? `At ${L.rate} per million input tokens.` : 'Subscription: shown as tokens and share of your 5-hour window, never dollars.' },
28 ] },
29 { group:'Working memory', terms:[
30 { id:'messages', name:'Messages',
31 what:'Your messages, Claude’s replies and the results of the tools it used. The part that grows as you work.',
32 live:`${L.messages} (${L.messagesPct}).`,
33 todo:'Start a fresh conversation when the topic changes.' },
34 { id:'mcp', name:'MCP tools',
35 what:'Descriptions of the tools your connected MCP servers offer. They load with every request, used or not.',
36 live:`${L.mcp} (${L.mcpPct}) from ${L.mcpServers} servers.`,
37 todo:'/mcp: turn off the servers you do not use here.' },
38 { id:'deferred', name:'Deferred tools',
39 what:'Tools that load only when Claude first uses one. Until then they cost nothing, so they show a dash instead of a share.',
40 live:`${L.mcpDeferred} MCP and ${L.sysDeferred} system tools waiting.` },
41 { id:'skills', name:'Skills',
42 what:'A one-line description of each skill you have, so Claude knows it can use it. The full skill loads only when used.',
43 live:`${L.skills} from ${L.skillCount} skills.`,
44 todo:'/loadout: park the skills you never call.' },
45 { id:'system', name:'System prompt and system tools',
46 what:'Claude Code’s own instructions and built-in tools. The same in every conversation; you cannot trim them.',
47 live:`${L.system} + ${L.sysTools}.` },
48 { id:'memoryfiles', name:'Memory files',
49 what:'CLAUDE.md, AGENTS.md and rules files. Loaded into every conversation, so every line costs on every request.',
50 live:`${L.memory} from ${L.memoryCount} files${L.memoryTwice ? '; one is loaded twice' : ''}.`,
51 todo:'/memory: drop the duplicate, and keep these files short.' },
52 { id:'buffer', name:'Autocompact buffer',
53 what:'Room kept free so Claude can write its summary before the window runs out.',
54 live:`${L.buffer} reserved.` },
55 { id:'free', name:'Free space',
56 what:'What is left before auto-compact starts.',
57 live:`${L.free} left.` },
58 ] },
59 { group:'Cache', terms:[
60 { id:'cache', name:'Prompt cache',
61 what:'Anthropic keeps a copy of your conversation for a while after each request. Reusing that copy costs a tenth of reading it fresh, and Claude starts answering sooner.',
62 live:L.cacheLine,
63 todo:'Reply while it is warm, and avoid changes that break it (switching model, editing CLAUDE.md, turning MCP servers on or off).' },
64 { id:'warmth', name:'Warm, cooling, cold',
65 what:'Warm: the copy is there. Cooling: under five minutes left. Cold: it lapsed, so the next request pays to store everything again.',
66 live:L.cacheState },
67 { id:'ttl', name:'Cache lifetime',
68 what:'How long the copy lasts after it was last used: five minutes, or an hour on some plans. Every reuse starts the clock again.',
69 live:`${L.ttl} on this session.` },
70 { id:'recache', name:'Re-cache',
71 what:'Storing the whole conversation again after the cache lapsed. It costs 1.25 times the normal price, on every token.',
72 live:L.api ? `Would cost ≈ ${L.coldCost} right now.` : `Would re-process ${L.used} right now.` },
73 { id:'hit', name:'Hit rate',
74 what:'How much of the last request came from the cache. High is good; a sudden drop means something broke it.',
75 live:`${L.hit}% on the last request.` },
76 { id:'keepwarm', name:'Keep-warm',
77 what:'While you are away, the plugin sends a tiny request just before the cache lapses, so it stays warm. API billing only; it stops after a few tries.',
78 live:L.api ? (L.keepWarmOn ? `On: ${L.pings} of ${L.pingCap} pings used this break.` : `Off: ${L.keepWarmWhy}.`) : 'Off: a subscription already keeps the cache for an hour.' },
79 ] },
80 { group:'Actions', terms:[
81 { id:'clear', name:'Clear',
82 what:'Starts a fresh conversation. Free and instant, but Claude forgets everything not written down.',
83 todo:'Write a handoff first.' },
84 { id:'compact', name:'Compact',
85 what:'Replaces the conversation with a summary, so you keep going with less. It reads everything one more time, so it is cheap only while the cache is warm.',
86 live:L.cacheWarm ? 'Warm now: compacting is cheap.' : 'Cold now: Clear is cheaper.' },
87 { id:'handoff', name:'Handoff',
88 what:'A short note of where you are and what comes next, so a fresh conversation picks up without you explaining again.',
89 todo:'Write one before you Clear.' },
90 { id:'marks', name:'Marks',
91 what:'Your thresholds. At watch, note decisions as you go; at handoff, write one and Clear; at compact, compact while warm.',
92 live:L.nextMark,
93 todo:'Change them in thresholds.json.' },
94 { id:'autocompact', name:'Auto-compact',
95 what:'Claude Code compacts on its own near the end of the window, whether the cache is warm or not.',
96 live:`At ${L.auto}%.` },
97 ] },
98 { group:'Savings', terms:[
99 { id:'saved', name:'Saved',
100 what:'Tokens the plugin itself kept off your bill this session: trimmed output, compactions it vetoed, and keep-warm net of its pings. Claude\'s own prompt cache is not counted.',
101 live:L.api ? `${L.saved} tokens ≈ ${L.savedUsd}.` : `${L.saved} tokens.` },
102 { id:'cut', name:'Cut % of input',
103 what:'Saved, divided by what you would have sent without the plugin.',
104 live:`${L.share}% this session.` },
105 { id:'cachereads', name:'Prompt cache',
106 what:'Tokens Claude served from its own cache at a tenth of the price. That happens with or without the plugin, so it is shown for context and never counted as saved.',
107 live:`${L.cacheRead} read this session.` },
108 { id:'trimmed', name:'Trimmed output',
109 what:'Long tool output cut to its start and end before Claude reads it. What was cut never enters the conversation, so it is never sent again.',
110 live:`${L.keptOut}, kept out of ${L.keptRequests} requests.` },
111 { id:'rewrites', name:'Quiet rewrites',
112 what:'Noisy commands changed to their quiet versions before they run, such as git status with short output.',
113 live:`${L.rewrites} this session.` },
114 { id:'blocked', name:'Cold compaction blocked',
115 what:'The plugin stops a compaction that would pay to store everything again while the cache is cold.',
116 live:`${L.vetoes} blocked, ${L.vetoTok} saved.` },
117 ] },
118 { group:'Billing', terms:[
119 { id:'api', name:'API billing',
120 what:'You pay per token. The pane shows dollars, worked out from your rate.' },
121 { id:'subscription', name:'Subscription',
122 what:'Your plan has usage limits instead of a bill. The pane shows tokens and your share of the 5-hour window, never dollars.' },
123 { id:'window5h', name:'5-hour window',
124 what:'Your plan’s rolling usage limit. When it is full you wait for it to reset.',
125 live:`${L.windowUsage}% used, resets in ${L.windowResets}.` },
126 ] },
127]
128}
129