Show the session's context window fill above the prompt, with a button to compact it

* . * _ . *
. (_) *
. _ __ ___ __ .
* | '_ \| \ \/ / *
| | | | |> < .
. |_| |_|_/_/\_\ *
aarch64-darwin
Personal macOS system config. Three areas: a Nix flake that declaratively manages two Macs, a full Claude Code global configuration, and vault-query, a query CLI for an Obsidian vault. The markdown core it builds on lives in md-for-agents and arrives here as a pinned flake input.
vdrn-studio and vdrn-mbp. Both aarch64-darwin, both managed by the same flake.nix on the nixos-25.11 branch.
flake.nix is the entry point. home/ holds all home-manager modules: packages, dotfiles, zsh, git (SSH signing, global hooks), tmux, starship, yazi, Ghostty, lazygit, micro, and delta with a Catppuccin Mocha theme.
rebuild.sh is the single command that runs darwin-rebuild switch, installs Zed/browser plugins, syncs agent symlinks, and installs npm globals.
home/claude/ contains settings.json with sandbox permissions and environment variables. home/claude/hooks/ has PreToolUse safety hooks: dangerous command blocking, sensitive file guards, /commit and /pr nonce enforcement, Firecrawl MCP routing, and sound notifications.
home/claude/plugins/ holds function-hook plugins, loaded from the main checkout through CLAUDE_CODE_PLUGIN_DIRS in settings.json. context-meter draws the context window fill above the prompt, green under 200k tokens, amber under 350k, red past that, with a button to compact. It runs on Claude Code 2.1.286; 2.1.280 lacks the $.state it keeps its values in. claude plugin test home/claude/plugins/context-meter runs its tests.
Formatting is agent-driven, not automatic: home/agents/skills/tools/autoformat/autoformat.ts (a bun CLI, on PATH as autoformat) routes each file to its formatter — the project's format:file script, else deno fmt, else mdformat for .md and oxfmt for the rest, or ruff, or alejandra, or rustfmt — and home/claude/hooks/hint-autoformat.sh names it in the PostToolUse context after every Write and Edit. Nothing rewrites a file behind the agent, so a reflow can never land between two Edits and break the second one.
agents/AGENTS.md is the shared reasoning and communication ruleset (dialectical method, formal logic, prose style). agents/skills/ holds ~30 skills (commit, pr, vault, debate, probe, work, tdd, writing-*, variants, etc.); it is also a bun workspace with pinned TypeScript and oxlint, whose tools/ member holds the autoformat CLI. agents/agents/ holds subagent definitions. agents/scripts/sync-agents.sh re-creates agent symlinks without a full rebuild.
vault-query is the only crate in this repo, a one-member workspace rooted at Cargo.toml. It links mdstruct as a git dependency on md-for-agents, pinned by rev in Cargo.lock. shell.nix provides the dev toolchain (nix-shell, then cargo test --workspace).
Built from the flake input as one derivation (nix build .#md-for-agents) carrying every binary in the workspace. One derivation rather than one per crate, so comrak compiles once and one cargoHash covers the lot. Bump the pin with nix flake update md-for-agents. The library vault-query links follows a separate pin on the same branch: cargo update -p mdstruct.
mdstruct — the shared comrak-backed structural-parsing core. It locates structure (headings, fenced code, tables, blockquotes, lists, links, wikilinks, comment-delimited regions, frontmatter) and emits half-open byte spans, never restringifying, so consumers slice their own original bytes and byte-exact write-back is preserved. A library and a thin JSON CLI (mdstruct FILES... → NDJSON, with check and stats).mdformat — comrak's parser plus our own printer, a sibling to mdstruct rather than built on it, since mdstruct's flat span index is deliberately not printable. Formats markdown to a configurable style. scripts/corpus.sh runs its partition and idempotence checks over the vault; scripts/dryrun.sh applies one rule to a throwaway copy and leaves a reviewable diff.mdsearch — rank a folder's markdown files against a query, best match first. BM25 over three fields: the file name, the frontmatter description:, and the prose after that block. Terms are stemmed in English and Russian. The walk obeys .gitignore, .ignore, and .mdsearchignore, and skips dot-files unless --hidden. The index is built in RAM for the one run, so an edit needs no reindex.A Rust crate in vault-query/. Full-text BM25 search via the shared mdsearch index. Commands cover the full vault surface: search, backlinks, tags, projects, tracks, log, lint, context, resolve, and more. Markdown structure (headings, links, wikilinks, frontmatter) comes from the shared mdstruct core. Reading is left to the agent's own file reader: get resolves an entry name fragment to its absolute path, and consult points a match too large to inline at its file and the lines of its best section. Built as a Nix package and installed system-wide.
hooks/register.tsx 235 lines1import { atom, read, update } from "claude-code";
2import type {
3 EngineInterface,
4 Register,
5 SessionCompactResult,
6 SessionContextUsage,
7} from "claude-code";
8
9import type { Reading } from "../types";
10
11const reading = atom({ plugin: "context-meter", key: "reading" } as const, null);
12const compacted = atom({ plugin: "context-meter", key: "compacted" } as const, null);
13const isCompacting = atom({ plugin: "context-meter", key: "isCompacting" } as const, false);
14
15// Desktop meter size in CSS pixels; terminal meter width in cells.
16const BAR_WIDTH = 160;
17const BAR_HEIGHT = 6;
18const BAR_CELLS = 24;
19
20// A desktop cell is 8 CSS px. The native header row's text sits 5 px further
21// in than the band's own inset, so 0.625 lines "Context" up with it.
22const DESKTOP_INSET = 0.625;
23
24// Quality drops with the absolute token count, so the zones are fixed sizes.
25const AMBER_FROM = 200_000;
26const RED_FROM = 350_000;
27
28const zoneColor = (tokens: number) => {
29 if (tokens >= RED_FROM) return "#d2553f";
30 if (tokens >= AMBER_FROM) return "#e2a33a";
31
32 return "#6ba368";
33};
34
35const meterSvg = (percent: number, color: string) => {
36 const r = BAR_HEIGHT / 2;
37 const fill = percent <= 0 ? 0 : Math.max(BAR_HEIGHT, (Math.min(100, percent) / 100) * BAR_WIDTH);
38
39 return [
40 `<svg xmlns="http://www.w3.org/2000/svg" width="${BAR_WIDTH}" height="${BAR_HEIGHT}" viewBox="0 0 ${BAR_WIDTH} ${BAR_HEIGHT}">`,
41 `<rect width="${BAR_WIDTH}" height="${BAR_HEIGHT}" rx="${r}" fill="#8a8a8a" fill-opacity="0.22"/>`,
42 fill > 0
43 ? `<rect width="${fill.toFixed(1)}" height="${BAR_HEIGHT}" rx="${r}" fill="${color}"/>`
44 : "",
45 `</svg>`,
46 ].join("");
47};
48
49const toReading = (context: SessionContextUsage): Reading => ({
50 tokens: context.tokens ?? null,
51 window: context.window,
52 percent: context.percent ?? null,
53});
54
55const size = (tokens: number | null) => {
56 if (tokens === null) return "?";
57
58 // Rounded first, so 999,700 reads "1.0M", not "1000k".
59 const thousands = Math.round(tokens / 1000);
60
61 if (thousands >= 1000) return `${(tokens / 1_000_000).toFixed(1)}M`;
62
63 return `${thousands}k`;
64};
65
66const record = async ($: EngineInterface, result: SessionCompactResult) => {
67 if (result.skip !== undefined) return;
68
69 await update($, compacted, () => ({
70 before: result.tokensBefore ?? null,
71 after: result.tokensAfter ?? null,
72 }));
73};
74
75// Runs `/compact` as if typed, queued until the session is idle.
76// `$.session.compact()` is refused in SDK-hosted sessions such as the
77// desktop's, where compaction runs inside a turn. The `session.compact` hook
78// below shows "Compacting…" only once it runs, so a mid-turn press toasts instead.
79// `run` resolves once the command has run, so presses until then queue nothing more.
80let isQueued = false;
81
82const compact = async ($: EngineInterface) => {
83 if (isQueued) return;
84
85 isQueued = true;
86
87 try {
88 await $.command.run({ command: "compact" });
89 } catch (error) {
90 $.ui.toast(`Compaction failed: ${error instanceof Error ? error.message : String(error)}`);
91 } finally {
92 isQueued = false;
93 }
94};
95
96export const register: Register = (on) => {
97 // A reload keeps `$.state` but drops a compaction in flight, so start clean.
98 on("session.start", async ($, e, next) => {
99 const { context } = await $.session.usage();
100 await Promise.all([
101 update($, reading, () => toReading(context)),
102 update($, compacted, () => null),
103 update($, isCompacting, () => false),
104 ]);
105
106 return next(e);
107 });
108
109 on("session.measure", async ($, e, next) => {
110 if (e.changed.includes("context")) {
111 await update($, reading, () => toReading(e.context));
112
113 // A reply ends the compacted note; skip the write when there is none.
114 if (e.context.tokens !== undefined && (await read($, compacted)) !== null) {
115 await update($, compacted, () => null);
116 }
117 }
118
119 return next(e);
120 });
121
122 // `/compact` and auto-compaction of the main conversation.
123 on("session.compact", async ($, e, next) => {
124 const isMain = e.agentId === undefined && e.trigger !== "precompute";
125
126 if (!isMain) return next(e);
127
128 await update($, isCompacting, () => true);
129
130 try {
131 const result = await next(e);
132 await record($, result);
133
134 return result;
135 } finally {
136 await update($, isCompacting, () => false);
137 }
138 });
139
140 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
141 if (e.props.hasSurvey || e.props.view.agentId !== undefined) return next(e);
142
143 const [now, last, isBusy] = await Promise.all([
144 read($, reading),
145 read($, compacted),
146 read($, isCompacting),
147 ]);
148
149 if (now === null) return next(e);
150
151 const ui = $.ui.resolve(e);
152 const { Box, Button, Text } = ui;
153 const Svg = "Svg" in ui ? ui.Svg : undefined;
154
155 const meter = [];
156
157 if (last !== null) {
158 meter.push(
159 <Text dimColor>
160 {`Compacted ${size(last.before)} → ${size(last.after)} · the meter returns after the next reply`}
161 </Text>,
162 );
163 } else if (now.tokens === null || now.percent === null) {
164 meter.push(<Text dimColor>{`Context · no reply yet · ${size(now.window)} window`}</Text>);
165 } else {
166 const percent = now.percent;
167 const color = zoneColor(now.tokens);
168 meter.push(<Text dimColor>Context</Text>);
169
170 if (Svg !== undefined) {
171 meter.push(
172 <Svg
173 source={meterSvg(percent, color)}
174 alt={`${percent}% of the context window used`}
175 width={BAR_WIDTH}
176 height={BAR_HEIGHT}
177 />,
178 );
179 } else {
180 const filled = Math.round((Math.min(100, percent) / 100) * BAR_CELLS);
181
182 meter.push(
183 <Box>
184 <Text color={color}>{"━".repeat(filled)}</Text>
185 <Text dimColor>{"━".repeat(BAR_CELLS - filled)}</Text>
186 </Box>,
187 );
188 }
189
190 meter.push(
191 <Text bold color={color}>{`${percent}%`}</Text>,
192 <Text dimColor>{`${size(now.tokens)} / ${size(now.window)}`}</Text>,
193 );
194 }
195
196 // Tokens are unknown until a reply, so a resumed session is told apart from
197 // a fresh one by its prompts; only a fresh one has nothing to compact.
198 const hasConversation = now.tokens !== null || last !== null || (await $.session.turns()) > 0;
199 const isWorking = e.props.isWorking;
200 const action = [];
201
202 if (isBusy) {
203 action.push(<Text dimColor>Compacting…</Text>);
204 } else if (hasConversation) {
205 // Mid-turn, a press queues `/compact` for when the turn ends.
206 action.push(
207 <Button
208 key="compact"
209 label="Compact"
210 onPress={() => {
211 if (isWorking) $.ui.toast("Compact queued: runs when this turn ends");
212
213 return compact($);
214 }}
215 />,
216 );
217 }
218
219 return (
220 <Box
221 justifyContent="space-between"
222 alignItems="center"
223 gap={2}
224 paddingLeft={e.surface === "terminal" ? 1 : DESKTOP_INSET}
225 paddingRight={1}
226 >
227 <Box alignItems="center" gap={1}>
228 {meter}
229 </Box>
230 {action}
231 </Box>
232 );
233 });
234};
235types/index.d.ts 16 lines1// The live window as the status line reads it; null until a reply reports it.
2export type Reading = { tokens: number | null; window: number; percent: number | null };
3
4// The last compaction's sizes, kept until the next reply reports a fill.
5export type Compacted = { before: number | null; after: number | null };
6
7declare module "claude-code" {
8 interface PluginState {
9 "context-meter": {
10 reading: Reading | null;
11 compacted: Compacted | null;
12 isCompacting: boolean;
13 };
14 }
15}
16