SLOPSHOPPER

context-meter

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

newbandtoast
★ 2v0.1.0no licenseupdated 2026-10-03vadirn/nix/home/claude/plugins/context-meter
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 ━━━━━━━━━━━━━━━━━━━━━━━━ 49% 97k / 200k [ Compact ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Context ━━━━━━━━━━━━━━━━━━━━━━━━ 49% 97k / 200k [ Compact ]
README
   *    .    *      _       .    *
              .    (_)   *
  .      _ __  ___  __         .
   *    | '_ \| \ \/ /    *
        | | | | |>  <       .
   .    |_| |_|_/_/\_\  *
         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.

Machines

vdrn-studio and vdrn-mbp. Both aarch64-darwin, both managed by the same flake.nix on the nixos-25.11 branch.

Nix / home-manager

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.

Claude Code configuration

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.

Rust workspace

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).

md-for-agents

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.

vault-query

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.

Source 2 files
hooks/register.tsx 235 lines
1import { 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};
235
types/index.d.ts 16 lines
1// 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