SLOPSHOPPER

context-meter

A band above the prompt with what fills the context window, by category, a countdown to when the prompt cache expires, and a Compact button

newbandcommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-meter
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /context-meter ⎿ context-meter: band on (/context-meter off hides it) context ▸ 97k of 200k · 49% · auto-compact off [ Compact ] ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ■ free 103k 51% cache ▸ starts with the next message (1h TTL, assumed: subscription) ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
context ▸ 97k of 200k · 49% · auto-compact off [ Compact ] ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ■ free 103k 51% cache ▸ starts with the next message (1h TTL, assumed: subscription) ⟨Claude Code's own drawing⟩
README

claude-code-mods-kit

Four small Claude Code mods, free to use under the MIT licence. A mod is a plugin made of function hooks: Claude Code calls it at every step (a tool call, a slash command, a redraw), and it can answer, change or watch that step.

ModWhat it doesCommand
secret-guardReads every .env and .env.* from your session's folder up to the drive root when a session starts and hides their secret values (keys named like PASSWORD, SECRET, TOKEN, API_KEY) in every tool result before Claude reads it, along with anything that looks like a secret on its own: KEY=VALUE under such a name, the password in postgres://user:REDACTED@host, private keys, JWTs, AWS keys, Kubernetes Secret data. A secret reaches Claude as ‹hidden: DB_PASSWORD›. It tells Claude, in every conversation, to use $DB_PASSWORD instead of printing the value, and it refuses the few commands that would print a whole env file or a decrypted secret./secret-guard lists the protected files and key names
plan-meterA one-line band above the prompt that says how far your plan is: plan ▸ Ship the export · phases 1/3 · steps 3/6 (50%) · now: API · Claude's tasks 2/5. It reads your plan file (and the phase files it links to) in many formats, and Claude's own task list. It updates when a file changes./plan-meter on / /plan-meter off shows or hides the band; /plan-meter opens a pane with the details; /plan-meter docs/roadmap.md picks a file
done-gateWhen Claude marks a task done while code it changed has not been tested since, it tells Claude, in the tool result Claude reads, and you, in a toast. A band shows the last test run: done-gate ▸ tests ✔ passed 4 min ago · 2 files changed since. It warns; it never blocks./done-gate on / /done-gate off shows or hides the band; /done-gate lists the changed files and the last test command
context-meterA band above the prompt with what fills the context window, by category and in /context's colours (context ▸ 90k of 1M · 9% · compacts at 987k), and a countdown to when the prompt cache expires, which turns from green through amber to red: cache ▸ 41:07 left (1h TTL, assumed: subscription). A Compact button (or c while the band has focus) runs the same compaction as /compact./context-meter shows or hides the band (on / off to set it); the button

Tested on Claude Code 2.1.291 on Windows 11. Mods are an early-access feature, so the API can change between versions; if a mod stops loading after an update, check claude plugin validate on its folder.

Try one without installing anything

git clone https://github.com/vumichien/claude-code-mods-kit.git
claude --plugin-dir claude-code-mods-kit/plugins/plan-meter

--plugin-dir loads the mod for that session only. Repeat the flag to load more than one.

Install

claude plugin marketplace add vumichien/claude-code-mods-kit
claude plugin install secret-guard@chien-mods
claude plugin install plan-meter@chien-mods
claude plugin install done-gate@chien-mods
claude plugin install context-meter@chien-mods

Start a new session afterwards. To remove one: claude plugin uninstall plan-meter@chien-mods.

The bands start hidden. plan-meter, done-gate and context-meter each draw a band above the prompt, but only when you ask: type /plan-meter on, /done-gate on or /context-meter on when you want to see it, and off to hide it again (a bare /context-meter flips it). The choice lasts for the session. Hiding a band changes only what is drawn: the plan is still read, done-gate still tells Claude when a task is marked done too early, and context-meter still measures, so a band is current the moment it comes back. To have a band from the start, set that mod's band option to on.

Options

Every option has a default, so all four mods work without any. plan-meter, done-gate and context-meter share one: band, off (default) or on, whether the band shows before you switch it with its command. To change one, use /plugin configure <name>@chien-mods inside Claude Code, pass --config key=value to claude plugin install, or pipe a JSON object to claude plugin configure <name>@chien-mods --values-stdin. With --plugin-dir, put them in a settings file: --settings '{"pluginConfigs":{"done-gate":{"options":{"testCommands":"make ci"}}}}'.

secret-guard

  • mode: value (default) hides secrets in results and refuses the commands listed below. command instead refuses any call whose command or path names a protected env file, except to load it (source .env, --env-file .env), without reading values or changing results; it is simpler, but it blocks harmless commands and misses reads that don't name the file.
  • secretFiles (default .env, .env.*, !*.example): comma-separated globs of the env files to read. A file name is looked for in the session's folder and every folder above it, up to the drive root. A path is read where it points: ~/vault//.env (from your home folder; goes up to 8 folders deep and skips node_modules, .git, .venv, venv and __pycache__), an absolute path, or one relative to the session's folder. !glob leaves files out. Each file must be in .env format.
  • secretKeys: comma-separated key names to treat as secrets on top of the built-in rule. The rule: a key is a secret when a part of its name (split at _, -, . and camelCase) is PASS, PASSWD, PASSWORD, PW, PWD, SECRET, TOKEN, KEY, DSN, CREDENTIAL or PRIVATE, or ends with one of the first seven (APIKEY, DBPASS). So DB_PASSWORD, apiKey and AWS_SECRET_ACCESS_KEY are secrets; DB_NAME, DB_HOST, ACCOUNT_ID, MAX_TOKENS, TOKENIZER_PATH and the shell's own PWD are not.
  • identifierKeys: comma-separated keys that match the rule but hold names, not secrets (KMS_KEY_ID, SSH_KEY_NAME). They are never hidden.

Values shorter than 8 characters are never hidden (except the password in a URL such as postgres://user:REDACTED@host, which is always hidden), so PW=1 or TOKEN_TTL=60 does not mask every 1 or 60. The values of non-secret keys are never hidden at all, so database names, hosts, users and account ids stay readable.

In value mode it refuses these commands, each time naming a way to do the same without printing the secret: cat, type, Get-Content, less, more, head, tail or bat of a protected file (cat .env | cut -d= -f1 is allowed, it prints names); a bare env, printenv, set or Get-ChildItem env:; and, unless the output goes to a file or a variable (> out.json, VALUE=$(...)), aws ssm ... --with-decryption, aws secretsmanager get-secret-value and kubectl get secret ... -o yaml|json. It never refuses loading a file: source .env, . .env, set -a, --env-file .env.

Every conversation starts with a short # secret-guard note to Claude: the protected files and key names (names only), what ‹hidden: NAME› means, and the convention (reference $NAME, let scripts load the env file, never print a secret, report only whether a command worked). So you no longer need to remind each session. The note is rebuilt after a compaction or /clear. Messages Claude sends to another agent or session (SendMessage) are scrubbed like tool results. So is what Claude Code attaches to a message on its own, which never passes through a tool call: a file Claude read, attached again after a compaction; a file changed on disk; a file you @-mention; a settings hook's output.

plan-meter

  • plan: comma-separated paths, relative to the project, tried in order; the first one that matches a file wins. A * in any part matches anything, and among several matches the most recently changed file wins. Default: plans/*/plan.md, PLAN.md, plan.md, TODO.md, TASKS.md, ROADMAP.md, todo.txt, TODO.org.
  • refreshSeconds (default 15, at least 5): how often the plan is read again, so an edit you make in your own editor shows up too. Edits Claude makes show up at once.

The band shows only when there is a plan or a task list. The pane draws in the terminal and the desktop app; under claude -p, /plan-meter answers with the band's line. (The command was /plan up to 0.1.0; Claude Code 2.1.294 has a built-in /plan, which refused the name.)

context-meter

  • cacheTtl: auto (default), 5m or 1h. A mod cannot read the cache lifetime Claude Code asks for, so auto follows Claude Code's defaults: one hour on a Claude subscription (the session reports rate-limit windows), five minutes with an API key or a cloud provider. Set it when you know better: you set promptCacheTtl or ENABLE_PROMPT_CACHING_1H, or you are drawing on usage credits, where Claude Code drops to five minutes. The band always says which lifetime it assumed and why.
  • breakdown: summary (default) estimates the categories locally and sends nothing. full counts them with the token-count API after every turn, as /context does: more exact, one request per tool and memory file.

Every request of the main conversation that hits the cache resets its timer, so the clock restarts at each model request, from the moment it was sent, not only when a turn ends. While Claude works the band says the cache is being kept warm; the countdown runs between turns, from the last request. A subagent's requests have caches of their own and are left out. After a compaction it starts again with the next message. The button is hidden while a turn runs and before the conversation's first reply, when Claude Code refuses a compaction ("Not enough messages to compact"); if a compaction is refused or a hook vetoes it, the band says why, and so does a toast. If you set CLAUDE_AUTOCOMPACT_PCT_OVERRIDE, which can only bring auto-compaction earlier, the header shows that point and marks it as your setting (compacts at 500k (your 50% setting)), since the breakdown Claude Code returns may still give its default; that one environment variable is all context-meter reads. In the desktop app and the IDE extensions, which run Claude Code through its SDK, Claude Code cannot compact between turns yet, so there the button runs /compact as if you had typed it. Below the bar, every category and the free part get a coloured entry with their tokens and share of the window, wrapped onto as many rows as they need. Compacting is a model call: the band costs no tokens, the button does. With little room above the prompt the band keeps two rows, the fill and the cache clock.

done-gate

  • testCommands: comma-separated commands that run your tests, added to the usual runners. Example: ./scripts/check.sh, make ci. Each one counts when it is the command itself, with or without arguments, so check does not match git checkout.
  • ignore (default .md,.mdx,.markdown,.txt,.rst,.adoc,.org): file endings whose changes need no test run.

The usual runners it knows: pytest, python -m pytest or unittest, npm/pnpm/yarn/bun test or run test, vitest, jest, mocha, go test, cargo test, cargo nextest, mvn test or verify, gradle test, dotnet test, rspec, phpunit, mix test, swift test, ctest, make test or check, tox, nox, deno test, claude plugin test. A runner counts when it is a command in the line, after &&, ; or cd api && and behind FOO=1, npx, uv run or poetry run; a runner named inside an argument (echo pytest, git commit -m "fix pytest") does not.

Plan formats plan-meter reads

plan-meter does not ask you to write your plan its way. It recognises these, alone or mixed in one file:

FormatExampleWhat it counts
Checklist- [x] write the schemaOne step per item. Bullets -, *, +, 1., 1). Marks: x or X done; / or ~ in progress (Obsidian); - cancelled, left out of the count; a space, >, <, ! or ? still to do.
Status table`\Phase \Name \Status \ with a row \2 \API \🚧 In progress \`One phase per row. The status column is the one headed Status, State, Progress, Done or Done?, Trạng thái, Tình trạng, ステータス or 状態. The name comes from a Name, Title, Task, Item, What, Deliverable, Tên or Công việc column, else Phase, Step, Milestone, Stage or Giai đoạn, else the first cell with words in it (so a Phase column holding only 2 is skipped).
Headings as phases## Phase 2: API (in progress), ## Step 3 ✅Used when the file has no status table. A heading named Phase, Step, Stage, Milestone, Sprint, Part or Task with a number (also Giai đoạn 1, Bước 2, フェーズ1) is a phase. Its status comes from a mark in it, a status at its end ((done), [WIP], — done, : in progress), the checklist beneath it (all ticked is done, some ticked is in progress), or the phase file it links to. Any other heading counts only when it ends with a status alone: ## Setup (done).
Linked phase filesPhase 1Links in the plan (in text, tables or headings) to Markdown files whose name starts with phase, step, stage, milestone, sprint, part or task and goes on with a number or a dash: phase-01-schema.md, phase1.md, steps.md, part_2.md, but not department.md. Relative to the plan's folder, up to 30; a #section part is ignored, and two links to one file count it once. Their checklists add to the steps. A phase whose status the plan leaves blank or unknown takes the file's status from its frontmatter or status line, else from its checklist; a status the plan states, such as Pending, wins. A plan with no phases of its own takes one phase per linked file.
YAML frontmattertitle: Ship the export / status: in_progressThe plan's title and its own status.
Status lineStatus: Draft, phase 3 next, Status: …, Status (2026-10-07): …The plan's own status, shown as written when nothing in the file can be counted.
org-mode* TODO write the schema, ** DONE testsOne step per headline. DONE done; DOING, IN-PROGRESS, STARTED, WAITING, HOLD in progress; CANCELLED left out; TODO, NEXT to do.
todo.txtx 2026-10-01 call the bankA file named todo.txt (or *.todo.txt): one step per line, x at the start is done.

Status words it understands in tables, headings, status lines and frontmatter, in English, Vietnamese and Japanese. When a cell holds several, the first one wins, so Done (review pending) is done and Not started is to do.

  • Done: done, complete, completed, finished, shipped, merged, closed, resolved, delivered, passed, xong, hoàn thành, 完了, ✅ ✔ ☑ ✓ [x]
  • In progress: in progress (also in-progress, in_progress), WIP, doing, ongoing, active, started, running, in review, reviewing, blocked, đang, đang làm, 進行中, 🚧 🔄 ⏳ ▶
  • To do: not started, not done, not yet, incomplete, unfinished, todo, to do, pending, planned, backlog, queued, open, chưa, chưa làm, chưa xong, 未着手, ⬜ ☐ [ ]
  • Dropped, left out of the count: cancelled, canceled, dropped, won't do, won't fix, wontfix, skipped, obsolete, abandoned, n/a, hủy, bỏ qua, 中止, 🚫

The title is the frontmatter title:, else the first # heading (a leading Plan: is dropped). Anything inside a fenced code block is skipped.

Claude's own tasks. When Claude keeps a task list (its TodoWrite, TaskCreate and TaskUpdate tools), the band adds Claude's tasks done/total, and the pane lists them. That list is the session's: it starts empty in a new session.

What it cannot read. A plan that says how far it is only in prose ("we finished the API last week") has nothing to count; the band then shows its status line if it has one. Headings named Phase with no status and no checklist beneath give no phase count, rather than a made-up 0 of N. On the 21 plan files in the author's own writing workspace, 10 gave a phase count, 8 a step count, 10 had only a status line to show, and 1 had nothing plan-meter could read.

What done-gate does and does not do

  • It watches the files Claude changes with its Edit, Write, MultiEdit and NotebookEdit tools, and the commands Claude runs with Bash or PowerShell. A test command that passes clears every file changed before it started; one that fails keeps them and turns the band red.
  • When the exit status could belong to another command (pytest -q | tail -20, pytest; echo done, pytest || true), done-gate reads the runner's own summary line in the output instead: 5 passed, 1 failed, 18 pass … 0 fail, test result: ok, ok pkg, OK. Failure words win. With no summary to read, the run counts for nothing.
  • A passing command that runs a changed file (python scripts/report.py, ./build.sh) clears that file, since running a script checks at least that it runs. Reading it (cat, git diff) does not.
  • When Claude marks a task done (TaskUpdate to completed, or a TodoWrite item newly completed) while changed files are unchecked, Claude reads this beside the tool's result, and you see the same as a toast:

done-gate: "Add the export" was marked done, but 1 code file was changed and no test has run in this session: src/export.py. Before you report this task as finished, run the tests that cover these files, or tell the user plainly that they were not tested and why.

  • It does not refuse anything, and it cannot tell whether your tests cover the changed files: any passing test run counts.
  • It does not see files changed by a shell command (sed -i, a code generator) or outside Claude Code, nor a test run sent to the background, whose result it cannot know.
  • It keeps its record for the session only.

What secret-guard does not do

  • It is not a security boundary. It hides exact copies of the values in your env files, and values that look like secrets by their key name or their shape. In the author's tests it missed a base64-encoded copy of a value and the value's first 8 characters printed alone. A secret printed bare, with no key name beside it and in no env file (aws ssm get-parameter --query Parameter.Value --output text, a password file, a column of a CSV), is not recognised; that is why the commands above are refused. To stop Claude from reading a file at all, use permission deny rules such as Read(./.env), the sandbox, OS file permissions or a secrets manager.
  • It reads the env files at session start. A value added later is protected from the next session. A file outside the folders above the session (another project, a secrets folder in your home directory) is read only when secretFiles names its path.
  • A file it cannot parse (a quoted value that never closes) is skipped: its values are not protected, while every other file still is. secret-guard names the file and the reason, never its content, in the status line, in the # secret-guard note (so Claude tells you) and in /secret-guard, and it still refuses cat of that file. Fix the file, or leave it out with ! in secretFiles. A file it cannot read at all (no permission) makes it refuse every call in that session; claude plugin disable secret-guard@chien-mods turns it off.
  • The detectors also hide what only looks like a secret: a literal under a secret-looking name in code you read (password: "hunter2-test" in a test), the values of a ConfigMap listed together with a Secret. When Claude then edits that file, the marker is turned back into the value, so the edit works. It leaves alone a value already masked (sk_****…), a key inside a quoted pattern (rg -c "API_SECRET=" deploy/*.ini) and an attribute assigned in code (self.api_key = config.service_key).
  • Edits. A file Claude read with a hidden value shows ‹hidden: NAME›. When an Edit, Write, MultiEdit or NotebookEdit carries that marker, secret-guard puts the real value back only when the target file already holds it, so a value is restored where it was and never copied into a new file. Otherwise the call is refused and Claude is told why. A marker in a Bash or PowerShell command that stands for a value is refused, never filled in. A marker that stands for no value secret-guard knows (a note or a README quoting the format, ‹hidden: NAME›) is plain text, written and run as it is; so is a marker the file already holds as text.
  • An AWS secret access key is caught by its key name (aws_secret_access_key, SecretAccessKey) or when it sits on the same line as an access key id; a bare 40-character string elsewhere is not, since every git commit hash would match.
  • When it cannot check a result, it withholds the result rather than letting it through. If an env file could not be read, it refuses every call and every outgoing message and /secret-guard says why; if a check failed, or a secret sat in a result as a number it can't replace, /secret-guard counts the results it withheld.
  • It keeps the values in memory only: never in a file, a log, the status line, the context block or Claude Code's state. The context block holds the key names only.

Before you install any mod

Mods are not sandboxed. A mod's hooks run with your permissions and can read files and start processes. These four are short; read them first. claude plugin validate plugins/<name> lists every hook a mod registers and every call it makes. None of the four starts a process or uses the network of its own. (context-meter's breakdown: full asks Claude Code to count tokens, which Claude Code does with the API; its Compact button asks Claude Code for a compaction, which is a model call.)

How it was tested

  • Each mod passes claude plugin validate and claude plugin test (plugins/<name>/tests/) and type-checks with tsc.
  • In a throwaway folder holding a .env of fake canary values, a demo plan with two phase files, and a small Python file, real Claude Code sessions (2.1.291, haiku) ran with the three mods, once loaded by --plugin-dir and once installed from this repository with claude plugin marketplace add vumichien/claude-code-mods-kit:
  • asked to run cat .env, Claude received two ‹hidden: …› markers and no canary value. Since 0.3.0 that command is refused before it runs, with the command that prints the key names only and the way to load the file without printing it; rerun on 2.1.295 with --plugin-dir, cat .env was refused and a following grep -r DEMO_ . reached Claude with two markers and no canary value;
  • asked to add a task, change the Python file, tick the plan's step and mark the task done without running anything, Claude received the done-gate note and quoted it back;
  • /plan (now /plan-meter) answered phases 0/2 · steps 1/4 (25%) before that session and steps 2/4 (50%) after it, with no model turn.
  • A second reviewer, OpenAI's Codex, read both new mods; its 13 findings (a test runner named only inside echo, cat counted as running a file, and parsing and path cases) are fixed and each has a test.
  • In a control session without the mods, both canary values reached Claude and no note appeared.
  • The live-session checks above are for the first three mods. context-meter (added 2026-10-08, on Claude Code 2.1.294) is checked by validate, tsc and its 26 tests, which drive its band, countdown and button against the engine's test host, and in one live terminal session (2.1.294, a 1M window): before /compact the band read 178k of 1M · 18%, against the 179,681 tokens Claude Code recorded for the compaction; once the compaction finished, before any new message, it read 98k of 1M · 10%, and the cache line had reset to starts with the next message.
  • secret-guard 0.3.0 (2026-10-09, Claude Code 2.1.295, haiku) is checked by validate, tsc and its 47 tests, and in live sessions loaded with --plugin-dir (the debug log confirms it replaced the installed 0.2.0) in a throwaway
Source 3 files
hooks/register.tsx 229 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { bar, cacheTtl, cacheView, fade, legend, tokens, toReading, withOverride } from './meter'
5
6const reading = atom({ plugin: 'context-meter', key: 'reading' } as const, null)
7const cache = atom({ plugin: 'context-meter', key: 'cache' } as const, { lastAt: null })
8// The clock the cache line was last drawn at; written only when that line's text would change.
9const now = atom({ plugin: 'context-meter', key: 'now' } as const, 0)
10const onSubscription = atom({ plugin: 'context-meter', key: 'onSubscription' } as const, false)
11const notice = atom({ plugin: 'context-meter', key: 'notice' } as const, null)
12// The band shows only when asked: `/context-meter` flips it, `/context-meter on` or `off` sets it, for this session;
13// until then the `band` option decides. Hiding it changes the drawing only: the window is still measured and the
14// cache clock still runs, so the band is current the moment it comes back.
15const shown = atom({ plugin: 'context-meter', key: 'shown' } as const, null)
16const SWITCH: Record<string, boolean> = { on: true, off: false }
17
18async function isShown($: any, band: unknown): Promise<boolean> {
19  return (await read($, shown)) ?? band === 'on'
20}
21
22type Detail = 'summary' | 'full'
23
24async function ttlFor($: any, option: unknown) {
25  return cacheTtl(typeof option === 'string' ? option : undefined, await read($, onSubscription))
26}
27
28// The breakdown /context draws, read again after each turn. `summary` estimates locally and sends nothing.
29async function measure($: any, detail: Detail): Promise<void> {
30  const usage = await $.session.usage({ breakdown: detail, columns: 80 })
31  // Rate-limit windows come back only on a subscription: the one hint there is about the cache's lifetime.
32  await update($, onSubscription, () => usage.rateLimits.length > 0)
33  const pct = await $.env.get('CLAUDE_AUTOCOMPACT_PCT_OVERRIDE').catch(() => undefined)
34  await update($, reading, () => withOverride(toReading(usage.context), pct))
35}
36
37// How long after a compaction the band reads the window again: the engine puts the summary in place of the
38// conversation only once every session.compact hook has returned, so a reading taken inside the hook (or
39// as the call resolves) still counts the old messages.
40const SETTLE_MS = 1000
41// A compaction whose new window the band has not read yet; the turn that ran /compact reads it as it ends.
42let isPending = false
43
44// The main conversation's cache is rewritten by the compaction's next request, so its clock starts over.
45async function compacted($: any, detail: Detail): Promise<void> {
46  await update($, cache, () => ({ lastAt: null }))
47  isPending = true
48  $.clock.after(SETTLE_MS, () => void remeasure($, detail))
49}
50
51async function remeasure($: any, detail: Detail): Promise<void> {
52  isPending = false
53  await measure($, detail).catch(() => undefined)
54}
55
56// Moves the drawn clock on, but only when the cache line's text would change, so the band redraws once a
57// second while the countdown runs and once a minute after it has run out.
58async function tick($: any, option: unknown): Promise<void> {
59  const [stamp, at, t, r] = [await read($, cache), await $.clock.now(), await ttlFor($, option), await read($, reading)]
60  const before = cacheView(stamp.lastAt, await read($, now), t.ttl, t.why, r?.tokens ?? null).text
61  if (cacheView(stamp.lastAt, at, t.ttl, t.why, r?.tokens ?? null).text !== before) await update($, now, () => at)
62}
63
64// Restarts the cache clock at `at`, the moment a request that used the cache was sent.
65async function stamp($: any, at: number): Promise<void> {
66  const drawnAt = await $.clock.now()
67  await update($, cache, () => ({ lastAt: at }))
68  await update($, now, () => drawnAt)
69}
70
71const reason = (err: unknown) => (err instanceof Error ? err.message : String(err))
72
73// The band's button: the same compaction /compact runs. It is refused while a turn runs or with too few
74// messages, and a hook may veto it. The outcome shows in the band as well as a toast, which some surfaces hide.
75// An SDK host (the desktop app's Code tab, the IDE extensions) has no between-turns compaction yet: there
76// /compact runs as a command inside a turn, so the button runs that command, as if it were typed, and the
77// session.compact hook below re-reads the window when it lands.
78async function compactNow($: any, detail: Detail, surface: string): Promise<void> {
79  await update($, notice, () => 'compacting… (the model is writing the summary)')
80  let said: string | null = null
81  const viaCommand = async () => {
82    try {
83      await $.command.run({ command: 'compact', args: '' })
84    } catch (err) {
85      said = `could not compact now: ${reason(err)}`
86    }
87  }
88  if (surface !== 'terminal') {
89    await viaCommand()
90  } else {
91    try {
92      const result = await $.session.compact()
93      if (result.skip !== undefined) said = `compaction skipped: ${result.skip}`
94      else await compacted($, detail)
95    } catch (err) {
96      if (/headless/i.test(reason(err))) await viaCommand()
97      else said = `could not compact now: ${reason(err)}`
98    }
99  }
100  await update($, notice, () => said)
101  if (said !== null) $.ui.toast(`context-meter: ${said}`)
102}
103
104export const register: Register = (on, options) => {
105  const detail: Detail = options.breakdown === 'full' ? 'full' : 'summary'
106  // session.start can fire again in one load (an enable, a worker respawn); one ticking clock is enough.
107  let ticking: { cancel: () => void } | undefined
108
109  on('session.start', async ($, e, next) => {
110    const started = await next(e)
111    // `immediate`: a switch for the band works while Claude is still working.
112    await $.command
113      .register({ name: 'context-meter', description: 'Show or hide the context and cache band', argumentHint: '[on|off]', immediate: true })
114      .catch(() => undefined)
115    await measure($, detail).catch(() => undefined)
116    ticking?.cancel()
117    ticking = $.clock.every(1000, () => void tick($, options.cacheTtl).catch(() => undefined))
118    return started
119  })
120
121  on('command.run', { command: 'context-meter' }, async ($, e) => {
122    const word = e.args.trim().toLowerCase()
123    const show = SWITCH[word] ?? (word === '' ? !(await isShown($, options.band)) : undefined)
124    if (show === undefined) return { text: 'use /context-meter, /context-meter on or /context-meter off' }
125    await update($, shown, () => show)
126    return { text: show ? 'band on (/context-meter off hides it)' : 'band off (/context-meter on shows it)' }
127  })
128
129  on('session.measure', async ($, e, next) => {
130    const measured = await next(e)
131    await measure($, detail).catch(() => undefined)
132    return measured
133  })
134
135  // Every request of the main conversation reads or writes its cache, and each one that hits it resets the
136  // cache's timer. So the clock restarts at each model request (each step of a turn, not only the turn's end),
137  // from the moment it was sent; a request that brought back no usage (failed, interrupted) did not count.
138  // A subagent's requests have caches of their own.
139  on('turn.step', async function* ($, e, next) {
140    const sentAt = await $.clock.now()
141    const response = yield* next(e)
142    if (e.agentId === undefined && response.usage !== null) await stamp($, sentAt).catch(() => undefined)
143    return response
144  })
145
146  on('turn.complete', async ($, e, next) => {
147    const done = await next(e)
148    if (e.agentId === undefined) {
149      await update($, notice, () => null).catch(() => undefined)
150      if (isPending) await remeasure($, detail)
151    }
152    return done
153  })
154
155  // /compact and auto-compaction; the band's own button calls compact() and sees its answer there instead.
156  on('session.compact', async ($, e, next) => {
157    const result = await next(e)
158    // An observer: the compaction's result goes back as it came, whatever happens to the band.
159    if (e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined) await compacted($, detail).catch(() => undefined)
160    return result
161  })
162
163  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
164    const below = await next(e)
165    if (!(await isShown($, options.band))) return below
166    const r = await read($, reading)
167    if (e.props.hasSurvey || r === null || r.window <= 0) return below
168    const { Box, Button, Text } = $.ui.resolve(e)
169    const t = await ttlFor($, options.cacheTtl)
170    // While a turn runs, each of its requests resets the timer, so the countdown only means something between
171    // turns: then it runs from the last request.
172    const idle = cacheView((await read($, cache)).lastAt, await read($, now), t.ttl, t.why, r.tokens)
173    const view = e.props.isWorking
174      ? { state: 'warm' as const, left: 1, text: `cache ▸ kept warm while Claude works: each request resets the ${t.ttl} timer` }
175      : idle
176    const width = Math.max(10, Math.min(60, e.props.bodyColumns - 2))
177    const head = [
178      `context ▸ ${r.used !== null ? tokens(r.used) : '?'} of ${tokens(r.window)}`,
179      r.percent !== null ? `${r.percent}%` : null,
180      r.compactsAt === null
181        ? 'auto-compact off'
182        : `compacts at ${tokens(r.compactsAt)}${r.compactsWhy !== null ? ` (${r.compactsWhy})` : ''}`,
183    ].filter(Boolean).join(' · ')
184    const columns = Math.max(20, e.props.bodyColumns - 2)
185    const rows = legend(r, columns)
186    // The whole band when there is room, else the two rows that matter: the fill and the cache clock.
187    const isFull = e.props.maxRows >= 6 + rows.length
188    const said = await read($, notice)
189    // Nothing to compact before the live window's first response (a new or just-compacted conversation),
190    // and a compaction is refused while a turn runs: the button shows only when one can work.
191    const canCompact = !e.props.isWorking && r.tokens !== null && said?.startsWith('compacting') !== true
192    return (
193      <Box flexDirection="column">
194        <Box justifyContent="space-between" width={columns}>
195          <Text wrap="truncate">{`${head} `}</Text>
196          {canCompact && <Button key="compact" label="Compact" hotkey="c" onPress={() => compactNow($, detail, e.surface)} />}
197        </Box>
198        {isFull && (
199          <Box>
200            {bar(r, width).map((run, i) => (
201              <Text key={`run-${i}`} color={run.color ?? undefined} dimColor={run.color === null}>
202                {(run.color === null ? '░' : '█').repeat(run.cells)}
203              </Text>
204            ))}
205          </Box>
206        )}
207        {isFull &&
208          rows.map((row, i) => (
209            <Box key={`legend-${i}`}>
210              {row.map((item, k) => (
211                <Box key={item.name}>
212                  <Text color={item.color ?? undefined} dimColor={item.color === null}>{`${k > 0 ? '   ' : ''}■ `}</Text>
213                  <Text>{`${item.name} `}</Text>
214                  <Text bold>{item.tokens}</Text>
215                  <Text dimColor>{` ${item.share}`}</Text>
216                </Box>
217              ))}
218            </Box>
219          ))}
220        <Text wrap="truncate" color={view.state === 'warm' ? fade(view.left) : view.state === 'expired' ? 'error' : undefined} dimColor={view.state === 'none'}>
221          {view.text}
222        </Text>
223        {said !== null && <Text color={said.startsWith('compacting') ? 'warning' : 'error'} wrap="truncate">{`context-meter: ${said}`}</Text>}
224        {below}
225      </Box>
226    )
227  })
228}
229
hooks/meter.ts 138 lines
1// Pure helpers for context-meter: token labels, the category bar, the cache clock and its colour.
2
3import type { CacheTtl, Category, ContextReading } from '../types'
4
5const MINUTE = 60_000
6export const TTL_MS: Record<CacheTtl, number> = { '5m': 5 * MINUTE, '1h': 60 * MINUTE }
7
8// 4.2k, 17k, 1M: the way /context writes token counts.
9export function tokens(n: number): string {
10  if (n >= 1_000_000) return `${trim(n / 1_000_000)}M`
11  if (n >= 1_000) return `${n >= 10_000 ? Math.round(n / 1_000) : trim(n / 1_000)}k`
12  return String(Math.round(n))
13}
14
15function trim(x: number): string {
16  return x.toFixed(1).replace(/\.0$/, '')
17}
18
19// The breakdown as the band draws it: the categories that fill the window, in /context's order and colours.
20export function toReading(context: any): ContextReading {
21  const b = context?.breakdown
22  const categories: Category[] = (b?.categories ?? [])
23    .filter((c: any) => c.kind === 'used' && c.tokens > 0)
24    .map((c: any) => ({ name: String(c.name).toLowerCase(), tokens: c.tokens, color: c.color }))
25  return {
26    tokens: context?.tokens ?? null,
27    window: b?.rawMaxTokens ?? context?.window ?? 0,
28    percent: b?.percentage ?? context?.percent ?? null,
29    used: b?.totalTokens ?? context?.tokens ?? null,
30    compactsAt: b?.isAutoCompactEnabled ? (b.autoCompactThreshold ?? null) : null,
31    compactsWhy: null,
32    categories,
33  }
34}
35
36// CLAUDE_AUTOCOMPACT_PCT_OVERRIDE (1-100) moves auto-compaction to that share of the auto-compact window; it
37// can only lower the threshold. The breakdown's threshold need not include it (on 2.1.294 a 1M session set to
38// 50 still reports 967k), so the band takes the lower of the two, marked as the person's setting when it is
39// the override. Where the breakdown already includes it, its own lower figure wins and nothing is marked.
40export function withOverride(reading: ContextReading, pct: string | undefined): ContextReading {
41  const n = Number(pct)
42  if (reading.compactsAt === null || !Number.isInteger(n) || n < 1 || n > 100) return reading
43  const at = Math.floor((reading.window * n) / 100)
44  return at < reading.compactsAt ? { ...reading, compactsAt: at, compactsWhy: `your ${n}% setting` } : reading
45}
46
47// One bar `width` cells wide: a run of cells per category, in proportion to the window, then the free part.
48// A category too small for a cell still gets one, so nothing that fills the window disappears from the bar.
49export function bar(reading: ContextReading, width: number): { cells: number; color: string | null }[] {
50  if (reading.window <= 0 || width <= 0) return []
51  const runs = reading.categories.map(c => ({ cells: Math.max(1, Math.round((c.tokens / reading.window) * width)), color: c.color }))
52  let used = runs.reduce((sum, r) => sum + r.cells, 0)
53  // Rounding up the small ones can overrun; take the excess back from the largest runs.
54  while (used > width) {
55    const largest = runs.reduce((a, b) => (b.cells > a.cells ? b : a))
56    if (largest.cells <= 1) break
57    largest.cells -= 1
58    used -= 1
59  }
60  return used < width ? [...runs, { cells: width - used, color: null }] : runs
61}
62
63export type LegendItem = { name: string; tokens: string; share: string; color: string | null }
64
65// One entry per category plus the free part, laid out in rows no wider than `columns` cells, so every one shows.
66export function legend(reading: ContextReading, columns: number): LegendItem[][] {
67  const share = (n: number) => {
68    const p = (n / Math.max(1, reading.window)) * 100
69    return p > 0 && p < 1 ? '<1%' : `${Math.round(p)}%`
70  }
71  const items: LegendItem[] = reading.categories.map(c => ({ name: c.name, tokens: tokens(c.tokens), share: share(c.tokens), color: c.color }))
72  if (reading.used !== null && reading.window > reading.used) {
73    const free = reading.window - reading.used
74    items.push({ name: 'free', tokens: tokens(free), share: share(free), color: null })
75  }
76  // An entry draws as "■ name 4.2k 1%", entries three cells apart.
77  const width = (i: LegendItem) => 2 + i.name.length + 1 + i.tokens.length + 1 + i.share.length
78  const rows: LegendItem[][] = []
79  let row: LegendItem[] = []
80  let used = 0
81  for (const item of items) {
82    const gap = row.length > 0 ? 3 : 0
83    if (row.length > 0 && used + gap + width(item) > columns) {
84      rows.push(row)
85      row = []
86      used = 0
87    }
88    used += (row.length > 0 ? 3 : 0) + width(item)
89    row.push(item)
90  }
91  if (row.length > 0) rows.push(row)
92  return rows
93}
94
95// The cache's lifetime: the option when it names one; otherwise one hour on a subscription (Claude Code's
96// default there, within the plan's included usage) and five minutes with an API key or a cloud provider.
97export function cacheTtl(option: string | undefined, onSubscription: boolean): { ttl: CacheTtl; why: string } {
98  if (option === '5m' || option === '1h') return { ttl: option, why: 'set in options' }
99  return onSubscription ? { ttl: '1h', why: 'assumed: subscription' } : { ttl: '5m', why: 'assumed: API key' }
100}
101
102export type CacheView = { state: 'none' | 'warm' | 'expired'; text: string; left: number }
103
104// What the cache line says, `now` milliseconds into the session's clock.
105export function cacheView(lastAt: number | null, now: number, ttl: CacheTtl, why: string, contextTokens: number | null): CacheView {
106  const life = TTL_MS[ttl]
107  if (lastAt === null) return { state: 'none', text: `cache ▸ starts with the next message (${ttl} TTL, ${why})`, left: 1 }
108  const remaining = lastAt + life - now
109  if (remaining > 0) return { state: 'warm', text: `cache ▸ ${clock(remaining)} left (${ttl} TTL, ${why})`, left: remaining / life }
110  const resend = contextTokens !== null ? `: the next message writes ${tokens(contextTokens)} to the cache again` : ''
111  return { state: 'expired', text: `cache ▸ expired ${ago(-remaining)} ago${resend}`, left: 0 }
112}
113
114// 52:07, or 4:05 under an hour's last ten minutes; never a fraction of a second.
115export function clock(ms: number): string {
116  const s = Math.ceil(ms / 1000)
117  const h = Math.floor(s / 3600)
118  const m = Math.floor((s % 3600) / 60)
119  const sec = String(s % 60).padStart(2, '0')
120  return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`
121}
122
123function ago(ms: number): string {
124  const m = Math.floor(ms / MINUTE)
125  return m < 1 ? `${Math.floor(ms / 1000)} s` : m < 120 ? `${m} min` : `${Math.floor(m / 60)} h`
126}
127
128// Green with the whole lifetime left, through amber, to red as it runs out: `left` is the share remaining.
129export function fade(left: number): string {
130  const red = [220, 38, 38]
131  const amber = [217, 119, 6]
132  const green = [22, 163, 74]
133  const x = Math.min(1, Math.max(0, left))
134  // Two straight runs: red to amber over the last half, amber to green over the first.
135  const [from, to, t] = x <= 0.5 ? [red, amber, x / 0.5] : [amber, green, (x - 0.5) / 0.5]
136  return `#${from.map((v, k) => Math.round(v + ((to[k] ?? v) - v) * t).toString(16).padStart(2, '0')).join('')}`
137}
138
types/index.d.ts 32 lines
1export type CacheTtl = '5m' | '1h'
2
3// One row of /context's breakdown that fills the window (free space and the compaction buffer are left out).
4export type Category = { name: string; tokens: number; color: string }
5
6export type ContextReading = {
7  // Input tokens the last response was answered over; null before the first response of the live window.
8  tokens: number | null
9  // The window the breakdown measures against (the compaction window when that is smaller).
10  window: number
11  percent: number | null
12  // The breakdown's estimated total; null without a breakdown.
13  used: number | null
14  // The token count at which auto-compaction runs; null when it is off.
15  compactsAt: number | null
16  // Where compactsAt came from when it is not Claude Code's own figure (the person's percentage override).
17  compactsWhy: string | null
18  categories: Category[]
19}
20
21// When the main conversation's last model response arrived, by the session's clock; null before one, or
22// after a compaction, whose next request writes a new cache.
23export type CacheStamp = { lastAt: number | null }
24
25declare module 'claude-code' {
26  interface PluginState {
27    // notice: what the Compact button last did ("compacting…", or why it could not), until the next turn ends.
28    // shown: the band switched on or off with /context-meter this session; null until then (the band option decides).
29    'context-meter': { reading: ContextReading | null; cache: CacheStamp; now: number; onSubscription: boolean; notice: string | null; shown: boolean | null }
30  }
31}
32