SLOPSHOPPER

token-saver

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-saver
│ ┃ Context ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Context 97.4k / 200k tok (49%) ? More │ token-saver │ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│▀▀▀▀▀▀▀│▀▀▀ ● token-saver: token-s│ token-saver is on: cache band above the │ │ ┃ ▪▪▪▪▪ Cache after 1st reply · 0 saved [ Clea ● token-saver: token-s│ prompt · /tokens · /loadout · /compact is │ │ ⏺ Read(src/auth.ts) │ guarded when the cache is cold │ │ ⎿ Read 6 lines ╰────────────────────────────────────────────╯ │ ⏺ Update(src/auth.ts) ╭───────────────────────────╮ │ ⎿ Added 2 lines, removed 1 line │ token-saver │ │ ⏺ Bash(bun test) │ dev. See /loadout suggest │ │ ⎿ 3 pass, 1 fail ╰───────────────────────────╯ │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tokens │ ⎿ token-saver: Context pane opened. Esc closes it. /tokens list pr │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Context
Context 97.4k / 200k tok (49%) ? More ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│▀▀▀▀▀▀▀│▀▀▀▀▀▀▀▀▀▀▀▀▀ ▪▪▪▪▪ Cache after 1st reply · 0 saved [ Clear ] [ Compact ]
Pane · Loadout
token-saver: JSON Parse error: Unexpected identifier "dev" skills.py needs python3 or python on PATH [ Refresh ]
README

Token Saver for Claude Code

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.

Install (once per machine)

unzip claude-token-saver.zip
cd claude-token-saver
./install.sh

That is the whole install. No questions. It:

  1. Copies the pack to ~/.claude/token-saver/.
  2. Installs the in-process mod through 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.
  3. Enables the recommended modules: rules, the two skills, the session-start line, the quiet map. The Python quiet-bash and trim hooks come on only when the mod is not.
  4. Preflights your existing 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.
  5. Prints a summary and the one thing left to do: open a new session.

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

Mod

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.

Progress bars (mod only)

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.

Compaction guard (mod only)

A settings hook can only add a note before /compact. The mod sits on the compaction itself:

TriggerWhat happens
/compact, cache warmRuns. The keep-list (task, files touched, decisions, failing tests, next steps, exact errors) is appended to the summarizer's instructions.
/compact, cache coldRefused. One line names the idle time and the tokens a compaction would re-send, and points at /handoff then /clear.
/compact forceRuns anyway. Keep-list still appended.
auto-compactionNever refused. Keep-list appended. Blocking it would let the window overflow.
subagent transcriptsLeft 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.

  • It stops after keepWarmPings pings per idle stretch (default 5, about 20 minutes, the break-even if you come back half the time).
  • It stops on the first miss, and it never runs during a turn.
  • It is off on a subscription, which already caches the main thread for an hour; pings there would spend plan quota.
  • /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.

Glossary

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.

WhatWhere it works
Quiet bash + trimCLI, Desktop, VS Code panel, claude -p
Cache band above the promptCLI 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.
/tokensOpens 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 toastOnce per threshold crossed (40, 60, 75 percent by default), with the cache-aware hint.
First-run welcomeOne toast the first time the mod loads on a machine, saying what is now on.
Compaction guardEverywhere the mod loads.
/loadoutOpens 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.

What we shipped next

./install.sh --enable statusline,quiet-map
ModuleWhat it does
statuslineCLI 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-mapCopies ~/.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
}

Skills: park what you do not use

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.

ActionWhat happensUndo
parkMoves the skill (or its symlink) to ~/.claude/skills-parked/ and records where it came from in .parked.json.unpark
unparkMoves it back to its original place.park
linkSymlinks a parked skill into this repo's .claude/skills/. Loads only when you work here.unlink
unlinkRemoves 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.

Let Claude pick skills for the repo

You should not have to run an installer per session. Three things close the loop:

WhereWhatCost
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 skillWhen 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

Dashboard and bench

./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"
CommandCalls Claude?What “saved” means
dashnoReal tokens your past sessions used. Does not invent a savings %.
benchnoTokens the hooks strip from command output (used vs after). Shows the trimmed result.
bench --liveyes, twiceOFF 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.

Where it lives

PathScope
Files~/.claude/token-saver/You, all repos
Hooks spliced into~/.claude/settings.jsonYou, all repos
Rules (if enabled)~/.claude/rules/token-budget.mdYou, all repos
Skill (if enabled)~/.claude/skills/token-saver/You, all repos
State~/.claude/token-saver/enabled.jsonWhat 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.

One repo only (does not touch teammates)

./install.sh --project --enable rules,quiet-bash

Writes hooks into .claude/settings.local.json (gitignored) and files into .claude/token-saver/.

Modules

Recommended (on if you press Enter):

idWhat it does
rulesStanding policy: /clear on topic change, @file, Haiku subagents
skill/token-saver — loads only when you ask about tokens
session-auditShort budget line at session start
quiet-bashRewrites git status / git log / tests to short output
trim-outputCaps huge successful bash stdout (never trims failures)

Opt-in (off until you enable them):

idWhat it does
compact-keepKeep-list injected before /compact
cache-guardAsks before a mid-session model switch when the cache is warm
slim-startupCaps bash at 8k chars; sets CLAUDE_CODE_DISABLE_ARTIFACT=1 (reversible)
deny-secretsBlocks Read on .env, secrets/, node_modules
thinking-capMAX_THINKING_TOKENS=8000
no-connectorsDisables claude.ai connectors (only if you do not use Slack/Jira/etc)
no-remoteDisables 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.

Company use without mandating it

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.

After enable

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

Honest limits

  • trim-output depends on updatedToolOutput, which has been flaky on some 2.1.x builds. quiet-bash is the reliable layer.
  • Hooks fail open. If a script throws, the tool still runs.
  • This cannot shrink Anthropic’s bundled system prompt.
  • Uninstall removes our hooks, rules, skill, and owned settings keys. Everything else in settings.json stays.
Source 2 files
hooks/register.js 2439 lines
1/**
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, '&amp;').replace(/</g, '&lt;')
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 lines
1/**
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