Skip to content
Star on GitHub
Agent statusContext meter
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Context meter

How full the context is and what fills it, with Compact to free room. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add context-meter
In context0k of 200k tokens
Summarises older turns
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add context-meter

React

$npx designme add context-meter --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Context meter shows how full an agent's context window is and what fills it. A small pill with a ring and a percentage opens a panel with one bar split by kind (files, tool output, chat, instructions), a legend whose rows list what is in them on hover, and a Compact button that frees room and says how much.

  • When long agent sessions run close to the context limit and people should see it coming.
  • When people want to know why the context is full, not only how full it is.
  • When your agent can compact a session, and people should decide when and see what it freed.
  • When a new tool result can suddenly take a large share and you want to warn about it.
03

Usage

Import it and drive it from your own state.

Import

import { ContextMeter } from '@/components/designme/context-meter/ContextMeter';

Example

The demo above as a React component, 51 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useEffect, useRef, useState } from 'react';
import { ContextMeter, type ContextPart } from './ContextMeter';
import { useStory, useWait } from '../lib/useScript';

/* ---------- Context meter: what fills it, new tool output takes it close to the limit, Compact frees room ---------- */
const START: ContextPart[] = [
  { key: 'files', label: 'Files', tokens: 56200, items: 'Pricing.fig export 31.9k, PricingCard.tsx 18.2k, tokens.css 6.1k' },
  { key: 'tools', label: 'Tool output', tokens: 41800, items: 'Contrast check 17.4k, Figma frames 15.6k, grid audit 8.8k' },
  { key: 'chat', label: 'Chat', tokens: 32100, items: '48 turns with Maya Kim since 09:12' },
  { key: 'sys', label: 'Instructions', tokens: 6300, items: 'Studio guidelines 4.1k, pricing brief 2.2k' }
];
const LIMIT = 200000;
const sum = (ps: ContextPart[]) => ps.reduce((t, p) => t + p.tokens, 0);
const set = (ps: ContextPart[], key: string, tokens: number) => ps.map(p => p.key === key ? { ...p, tokens } : p);

export default function ContextMeterDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const wait = useWait();
  const [parts, setParts] = useState(START);
  const [before, setBefore] = useState<ContextPart[] | null>(null);
  const [message, setMessage] = useState<{ text: string; tone: 'warn' | 'ok' }>();
  const [open, setOpen] = useState(true);
  const live = useRef(parts);
  useEffect(() => { live.current = parts; });
  const at = (ps: ContextPart[], key: string) => ps.find(p => p.key === key)!.tokens;
  // Compact turns older turns into a summary and trims old tool output
  const compact = async () => {
    await wait(1000);
    const next = set(set(parts, 'chat', 4600), 'tools', at(parts, 'tools') - 13700);
    setBefore(parts); setParts(next);
    setMessage({ text: `Freed ${((sum(parts) - sum(next)) / 1000).toFixed(1)}k tokens. 9 decisions kept`, tone: 'ok' });
  };
  const undo = () => { if (before) setParts(before); setBefore(null); setMessage(undefined); };
  // the preview's story: new tool output lands and takes it close to the limit, the person compacts, then shuts the panel
  useStory(ref, async ({ wait, press }) => {
    await wait(8000);
    const next = set(live.current, 'tools', at(live.current, 'tools') + 38500);
    setParts(next); setMessage({ text: `${Math.round(sum(next) / LIMIT * 100)}% full. Older turns start to drop at 200k`, tone: 'warn' });
    await wait(2000); press('.dm-ctm-go');
    await wait(3500); setOpen(false);
  });
  return (
    <div ref={ref} style={{ width: '100%' }}>
      <ContextMeter parts={parts} limit={LIMIT} message={message?.text} messageTone={message?.tone} onCompact={compact} compacted={!!before} onUndo={undo}
        hint={before ? 'Older turns are now a summary' : undefined} open={open} onOpenChange={setOpen} />
    </div>
  );
}
04

Props

Everything ContextMeter takes.

PropTypeWhat it does
partsrequiredContextPart[]
limitrequirednumberThe size of the context window, in tokens, like 200000
warnAtnumberFrom this share of the limit (0 to 1) the ring turns amber. Defaults to 0.85
messagestringA line under the legend, like "Freed 41.2k tokens. 9 decisions kept". Crossfades when it changes
messageTone'warn' | 'ok'warn shows the message in amber with a warning sign, ok in green with a check
onCompact() => void | Promise<void>Called by Compact. Return a promise and the button spins until it settles
compactedbooleanSet once the context is compacted: the button turns into Undo, which calls onUndo
onUndo() => void
hintstringThe quiet line beside the button. Defaults to what Compact does
openbooleanWhether the panel is open. Leave it out to let the pill toggle it
defaultOpenboolean
onOpenChange(open: boolean) => void
classNamestring
05

Code

The full source, as the CLI installs it.

ContextMeter.tsxReact · 122 lines
'use client';
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useTween } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Spin, Swap, Tip } from '../lib/motion';
import '../designme.css';
import './context-meter.css';

/** One kind of thing in the context */
export interface ContextPart {
  key: string;
  /** Like "Files", "Tool output" or "Chat" */
  label: string;
  /** How many tokens it takes */
  tokens: number;
  /** Its colour in the bar and the legend. Defaults to blue, teal, violet and grey, in order */
  color?: string;
  /** What is in it, shown when you hover its row, like "PricingCard.tsx 18.2k, tokens.css 6.1k" */
  items?: string;
}

export interface ContextMeterProps {
  parts: ContextPart[];
  /** The size of the context window, in tokens, like 200000 */
  limit: number;
  /** From this share of the limit (0 to 1) the ring turns amber. Defaults to 0.85 */
  warnAt?: number;
  /** A line under the legend, like "Freed 41.2k tokens. 9 decisions kept". Crossfades when it changes */
  message?: string;
  /** warn shows the message in amber with a warning sign, ok in green with a check */
  messageTone?: 'warn' | 'ok';
  /** Called by Compact. Return a promise and the button spins until it settles */
  onCompact?: () => void | Promise<void>;
  /** Set once the context is compacted: the button turns into Undo, which calls onUndo */
  compacted?: boolean;
  onUndo?: () => void;
  /** The quiet line beside the button. Defaults to what Compact does */
  hint?: string;
  /** Whether the panel is open. Leave it out to let the pill toggle it */
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  className?: string;
}

const PALETTE = ['var(--dm-pb)', 'var(--dm-pt)', 'var(--dm-pl)', 'var(--dm-faint)'];
const k = (n: number) => (n / 1000).toFixed(1) + 'k';

/** A number that counts to its new value (it starts on its first value) */
function Count({ value, ms = 900, format }: { value: number; ms?: number; format: (n: number) => string }) {
  return <>{format(useTween(value, ms))}</>;
}

/** How full the context is and what fills it: a pill with a ring that opens one bar split by kind, a legend whose rows list
 *  what is in them, and Compact. Escape closes it. */
export function ContextMeter({ parts, limit, warnAt = .85, message, messageTone = 'ok', onCompact, compacted = false, onUndo,
  hint = 'Summarises older turns', open, defaultOpen = true, onOpenChange, className }: ContextMeterProps) {
  const [isOpen, setOpen] = useControllable(open, defaultOpen, onOpenChange);
  const [busy, setBusy] = useState(false);
  const [lit, setLit] = useState<string | null>(null);
  // it opens on its real numbers; they only count when something changes
  const ready = true;
  const total = parts.reduce((t, p) => t + p.tokens, 0), share = ready ? total / limit : 0, pct = Math.round(share * 100);
  const root = useRef<HTMLDivElement>(null), tg = useRef<HTMLButtonElement>(null);
  // Escape shuts the panel while the pointer or the focus is on the meter
  useEffect(() => {
    if (!isOpen) return;
    const key = (e: KeyboardEvent) => {
      const el = root.current; if (e.key !== 'Escape' || !el) return;
      const inside = el.contains(document.activeElement);
      if (inside || el.matches(':hover')) { setOpen(false); if (inside) tg.current?.focus({ preventScroll: true }); }
    };
    document.addEventListener('keydown', key);
    return () => document.removeEventListener('keydown', key);
  }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
  const compact = async () => {
    if (busy) return;
    if (compacted) { onUndo?.(); return; }
    setBusy(true);
    try { await onCompact?.(); } finally { setBusy(false); }
  };

  return (
    <div ref={root} data-dm="" className={cx('dm-ctm', share >= warnAt && 'dm-hot', className)} data-open={isOpen ? '1' : '0'}>
      <button ref={tg} type="button" className="dm-ctm-tg" aria-expanded={isOpen} aria-label={`Context, ${pct}% used`} onClick={() => setOpen(!isOpen)}>
        <span className="dm-ctm-ring" aria-hidden="true">
          <svg viewBox="0 0 20 20"><circle className="dm-tr" cx="10" cy="10" r="7.5" /><circle className="dm-fl" cx="10" cy="10" r="7.5" pathLength={100} style={{ strokeDasharray: `${pct} 100` }} /></svg>
        </span>
        <span><span className="dm-ctm-pc dm-num"><Count value={ready ? total : 0} format={n => Math.round(n / limit * 100) + '%'} /></span> of context used</span>
        <Icon name="chev-d" size="xs" />
      </button>
      <div className="dm-ctm-pop dm-panel" role="region" aria-label="What fills the context" inert={!isOpen}>
        <div className="dm-ctm-ph"><span>In context</span><span className="dm-num"><b className="dm-ctm-tot"><Count value={ready ? total : 0} format={k} /></b> of {Math.round(limit / 1000)}k tokens</span></div>
        <div className={cx('dm-ctm-bar', lit && 'dm-hl')} aria-hidden="true">
          {parts.map((p, i) => <i key={p.key} className={lit === p.key ? 'dm-on' : undefined} style={{ width: (ready ? p.tokens / limit * 100 : 0) + '%', '--dm-c': p.color ?? PALETTE[i % PALETTE.length] } as CSSProperties} />)}
        </div>
        <div className="dm-ctm-leg" onPointerLeave={() => setLit(null)}>
          {parts.map((p, i) => {
            const row = (
              <div key={p.key} className="dm-ctm-l" style={{ '--dm-c': p.color ?? PALETTE[i % PALETTE.length] } as CSSProperties}
                onPointerEnter={() => setLit(p.key)} onFocus={() => setLit(p.key)} onBlur={() => setLit(null)} tabIndex={p.items ? 0 : undefined}>
                <span className="dm-sw" />{p.label}<span className="dm-v dm-num"><Count value={ready ? p.tokens : 0} format={k} /></span>
              </div>
            );
            return p.items ? <Tip key={p.key} label={p.items}>{row}</Tip> : row;
          })}
        </div>
        <div className={cx('dm-ctm-msg', message && 'dm-on', message && 'dm-' + messageTone)} aria-live="polite">
          <span>{message && <><Icon name={messageTone === 'warn' ? 'warn' : 'ok'} size="xs" /><Swap>{message}</Swap></>}</span>
        </div>
        {(onCompact || onUndo) && (
          <div className="dm-ctm-f">
            <Swap className="dm-ctm-hint">{hint}</Swap>
            <button type="button" className="dm-pill dm-ghost dm-sm dm-ctm-go" onClick={compact}>{busy ? <><Spin />Compacting</> : compacted ? 'Undo' : 'Compact'}</button>
          </div>
        )}
      </div>
    </div>
  );
}
context-meter.cssReact · 45 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Context meter ---------- */
/* a pill with the ring opens what fills the context: one bar split by kind, a legend whose rows list their items, and Compact */
/* it never anchors the page's scroll, so the page can't follow it as it changes; on a card it sits in the middle of its room */
.dm-ctm{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:var(--dm-s-2);width:100%;max-width:340px;margin-inline:auto;overflow-anchor:none}
.dm-stage-in:has(>.dm-ctm){overflow-anchor:none}
.dm-stage-in>.dm-ctm{min-height:inherit}
.dm-ctm-tg{display:inline-flex;align-items:center;gap:var(--dm-s-2);height:32px;padding:0 10px 0 7px;border-radius:var(--dm-r-pill);font-size:13px;color:var(--dm-fg2);background:var(--dm-surface);box-shadow:var(--dm-shadow);transition:box-shadow var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-ctm-tg:hover{box-shadow:var(--dm-shadow-pop)}}
.dm-ctm-tg .dm-ctm-pc{color:var(--dm-fg);font-weight:500}
.dm-ctm-tg>svg:last-child{color:var(--dm-muted);transition:transform .32s var(--dm-ease)}
.dm-ctm[data-open="0"] .dm-ctm-tg>svg:last-child{transform:rotate(-90deg)}
/* the panel keeps its room when shut, so nothing below it moves, and nothing clips its shadow */
.dm-ctm-pop{width:100%;padding:14px var(--dm-s-4) var(--dm-s-3);display:flex;flex-direction:column;gap:var(--dm-s-3);transition:opacity .32s var(--dm-ease),transform .42s var(--dm-ease),visibility 0s}
.dm-ctm[data-open="0"] .dm-ctm-pop{opacity:0;transform:translateY(-6px);visibility:hidden;pointer-events:none;transition:opacity .16s var(--dm-ease),transform .32s var(--dm-ease),visibility 0s .32s}
.dm-ctm-ph{display:flex;align-items:baseline;justify-content:space-between;gap:var(--dm-s-3);font-size:13px;color:var(--dm-muted)}
.dm-ctm-ph b{color:var(--dm-fg);font-weight:500}
.dm-ctm-bar{display:flex;gap:2px;height:8px;border-radius:var(--dm-r-xs);background:var(--dm-chip);overflow:hidden}
.dm-ctm-bar i{height:100%;width:0;flex:none;background:var(--dm-c);transition:width .42s var(--dm-ease),opacity .16s}
.dm-ctm-bar.dm-hl i:not(.dm-on){opacity:.25}
.dm-ctm-leg{display:flex;flex-direction:column;margin:0 -6px}
.dm-ctm-l{display:flex;align-items:center;gap:var(--dm-s-2);padding:5px 6px;border-radius:var(--dm-r-sm);font-size:13px;color:var(--dm-fg2);cursor:default;transition:background var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-ctm-l:hover{background:var(--dm-row)}}
.dm-ctm-l .dm-sw{width:8px;height:8px;border-radius:2px;flex:none;background:var(--dm-c)}
.dm-ctm-l .dm-v{margin-left:auto;color:var(--dm-fg)}
/* the message line keeps its room while empty, so a warning or a result fades in without moving anything */
.dm-ctm-msg{min-height:18px;font-size:13px;line-height:18px}
.dm-ctm-msg>span{display:flex;align-items:flex-start;gap:6px;opacity:0;transition:opacity .32s var(--dm-ease)}
.dm-ctm-msg.dm-on>span{opacity:1}
.dm-ctm-msg>span>svg{flex:none;margin-top:2px}
.dm-ctm-msg.dm-warn{color:var(--dm-warn)}
.dm-ctm-msg.dm-ok{color:var(--dm-ok)}
.dm-ctm-f{display:flex;align-items:center;gap:10px;padding-top:10px;border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-ctm-f .dm-ctm-hint{flex:1;min-width:0;font-size:13px;color:var(--dm-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* the ring: inside the meter's pill, and as its own button where Agent harness puts it in the composer */
.dm-ctm-ft{display:flex;align-items:center;gap:var(--dm-s-2);font-size:13px;color:var(--dm-fg2)}
.dm-ctm-ring{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;flex:none;transition:background var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-ctm-ring:hover{background:var(--dm-row)}}
.dm-ctm-tg .dm-ctm-ring{width:20px;height:20px;background:none}
.dm-ctm-ring svg{width:18px;height:18px;transform:rotate(-90deg);overflow:visible}
.dm-ctm-ring circle{fill:none;stroke-width:2.6}
.dm-ctm-ring .dm-tr{stroke:var(--dm-rail)}
.dm-ctm-ring .dm-fl{stroke:var(--dm-accent-mark,var(--dm-accent));stroke-linecap:round;stroke-dasharray:0 100;transition:stroke-dasharray .42s var(--dm-ease),stroke .32s}
.dm-ctm.dm-hot .dm-ctm-ring .dm-fl{stroke:var(--dm-warn)}
context-meter.htmlHTML · 15 lines
<div class="card" data-c="context-meter">
  <div class="stage-in">
    <div class="ctm" data-open="1">
      <button class="ctm-tg" aria-expanded="true" aria-label="Context"><span class="ctm-ring" aria-hidden="true"><svg viewBox="0 0 20 20"><circle class="tr" cx="10" cy="10" r="7.5"/><circle class="fl" cx="10" cy="10" r="7.5" pathLength="100"/></svg></span><span><span class="ctm-pc num">0%</span> of context used</span><svg class="i xs"><use href="#i-chev-d"/></svg></button>
      <div class="ctm-pop panel" role="region" aria-label="What fills the context">
        <div class="ctm-ph"><span>In context</span><span class="num"><b class="ctm-tot">0k</b> of 200k tokens</span></div>
        <div class="ctm-bar" aria-hidden="true"></div>
        <div class="ctm-leg"></div>
        <div class="ctm-msg" aria-live="polite"><span></span></div>
        <div class="ctm-f"><span class="ctm-hint">Summarises older turns</span><button class="pill ghost sm ctm-go">Compact</button></div>
      </div>
    </div>
  </div>
</div>
context-meter.cssHTML · 44 lines
/* ---------- Context meter ---------- */
/* a pill with the ring opens what fills the context: one bar split by kind, a legend whose rows list their items, and Compact */
/* it never anchors the page's scroll, so the page can't follow it as it changes; on a card it sits in the middle of its room */
.ctm{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:var(--s-2);width:100%;max-width:340px;margin-inline:auto;overflow-anchor:none}
.stage-in:has(>.ctm){overflow-anchor:none}
.stage-in>.ctm{min-height:inherit}
.ctm-tg{display:inline-flex;align-items:center;gap:var(--s-2);height:32px;padding:0 10px 0 7px;border-radius:var(--r-pill);font-size:13px;color:var(--fg2);background:var(--surface);box-shadow:var(--shadow);transition:box-shadow var(--t-fast)}
@media (hover:hover) and (pointer:fine){.ctm-tg:hover{box-shadow:var(--shadow-pop)}}
.ctm-tg .ctm-pc{color:var(--fg);font-weight:500}
.ctm-tg>svg:last-child{color:var(--muted);transition:transform .32s var(--ease)}
.ctm[data-open="0"] .ctm-tg>svg:last-child{transform:rotate(-90deg)}
/* the panel keeps its room when shut, so nothing below it moves, and nothing clips its shadow */
.ctm-pop{width:100%;padding:14px var(--s-4) var(--s-3);display:flex;flex-direction:column;gap:var(--s-3);transition:opacity .32s var(--ease),transform .42s var(--ease),visibility 0s}
.ctm[data-open="0"] .ctm-pop{opacity:0;transform:translateY(-6px);visibility:hidden;pointer-events:none;transition:opacity .16s var(--ease),transform .32s var(--ease),visibility 0s .32s}
.ctm-ph{display:flex;align-items:baseline;justify-content:space-between;gap:var(--s-3);font-size:13px;color:var(--muted)}
.ctm-ph b{color:var(--fg);font-weight:500}
.ctm-bar{display:flex;gap:2px;height:8px;border-radius:var(--r-xs);background:var(--chip);overflow:hidden}
.ctm-bar i{height:100%;width:0;flex:none;background:var(--c);transition:width .42s var(--ease),opacity .16s}
.ctm-bar.hl i:not(.on){opacity:.25}
.ctm-leg{display:flex;flex-direction:column;margin:0 -6px}
.ctm-l{display:flex;align-items:center;gap:var(--s-2);padding:5px 6px;border-radius:var(--r-sm);font-size:13px;color:var(--fg2);cursor:default;transition:background var(--t-fast)}
@media (hover:hover) and (pointer:fine){.ctm-l:hover{background:var(--row)}}
.ctm-l .sw{width:8px;height:8px;border-radius:2px;flex:none;background:var(--c)}
.ctm-l .v{margin-left:auto;color:var(--fg)}
/* the message line keeps its room while empty, so a warning or a result fades in without moving anything */
.ctm-msg{min-height:18px;font-size:13px;line-height:18px}
.ctm-msg>span{display:flex;align-items:flex-start;gap:6px;opacity:0;transition:opacity .32s var(--ease)}
.ctm-msg.on>span{opacity:1}
.ctm-msg>span>svg{flex:none;margin-top:2px}
.ctm-msg.warn{color:var(--warn)}
.ctm-msg.ok{color:var(--ok)}
.ctm-f{display:flex;align-items:center;gap:10px;padding-top:10px;border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.ctm-f .ctm-hint{flex:1;min-width:0;font-size:13px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* the ring: inside the meter's pill, and as its own button where Agent harness puts it in the composer */
.ctm-ft{display:flex;align-items:center;gap:var(--s-2);font-size:13px;color:var(--fg2)}
.ctm-ring{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;flex:none;transition:background var(--t-fast)}
@media (hover:hover) and (pointer:fine){.ctm-ring:hover{background:var(--row)}}
.ctm-tg .ctm-ring{width:20px;height:20px;background:none}
.ctm-ring svg{width:18px;height:18px;transform:rotate(-90deg);overflow:visible}
.ctm-ring circle{fill:none;stroke-width:2.6}
.ctm-ring .tr{stroke:var(--rail)}
.ctm-ring .fl{stroke:var(--accent-mark,var(--accent));stroke-linecap:round;stroke-dasharray:0 100;transition:stroke-dasharray .42s var(--ease),stroke .32s}
.ctm.hot .ctm-ring .fl{stroke:var(--warn)}
context-meter.jsHTML · 61 lines
DM.def('context-meter', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{chev}', I('chev-d','xs'));
  const LIMIT = 200;
  // what fills the context, in thousands of tokens; each part lists what's in it on hover
  const P = [
    { k:'files', l:'Files', v:56.2, c:'var(--pb)', tip:'Pricing.fig export 31.9k, PricingCard.tsx 18.2k, tokens.css 6.1k' },
    { k:'tools', l:'Tool output', v:41.8, c:'var(--pt)', tip:'Contrast check 17.4k, Figma frames 15.6k, grid audit 8.8k' },
    { k:'chat', l:'Chat', v:32.1, c:'var(--pl)', tip:'48 turns with Maya Kim since 09:12' },
    { k:'sys', l:'Instructions', v:6.3, c:'var(--faint)', tip:'Studio guidelines 4.1k, pricing brief 2.2k' }
  ];
  const root = $('.ctm', r), tg = $('.ctm-tg', r), pop = $('.ctm-pop', r), arc = $('.ctm-ring .fl', r), pc = $('.ctm-pc', r), tot = $('.ctm-tot', r);
  const bar = $('.ctm-bar', r), leg = $('.ctm-leg', r), msg = $('.ctm-msg', r), go = $('.ctm-go', r), hint = $('.ctm-hint', r);
  bar.innerHTML = P.map(p => `<i data-k="${p.k}" style="--c:${p.c}"></i>`).join('');
  leg.innerHTML = P.map(p => `<div class="ctm-l" data-k="${p.k}" style="--c:${p.c}" data-tip="${p.tip}"><span class="sw"></span>${p.l}<span class="v num">0k</span></div>`).join('');
  const fmt = v => v.toFixed(1) + 'k', sum = () => P.reduce((t, p) => t + p.v, 0);
  // the numbers count to their new values; the bar, the ring and the percentage follow
  const shown = {}; let total = 0;
  function paint(ms = 900){
    const all = sum(), pct = Math.round(all / LIMIT * 100);
    P.forEach(p => { $(`i[data-k="${p.k}"]`, bar).style.width = (p.v / LIMIT * 100) + '%'; const el = $(`.ctm-l[data-k="${p.k}"] .v`, leg), from = shown[p.k] || 0; shown[p.k] = p.v; tween(from, p.v, ms, x => el.textContent = fmt(x)); });
    const from = total; total = all; tween(from, all, ms, x => { tot.textContent = fmt(x); pc.textContent = Math.round(x / LIMIT * 100) + '%'; });
    arc.style.strokeDasharray = pct + ' 100'; root.classList.toggle('hot', pct >= 85); tg.setAttribute('aria-label', `Context, ${pct}% used`);
  }
  const say = (text, tone, icon) => { msg.className = 'ctm-msg on ' + tone; swap(msg.firstElementChild, `${I(icon,'xs')}<span>${text}</span>`, true); };
  // hovering a part lights its share of the bar
  c.on(leg, 'pointerover', e => { const l = e.target.closest('.ctm-l'); if (!l) return; bar.classList.add('hl'); $$('i', bar).forEach(i => i.classList.toggle('on', i.dataset.k === l.dataset.k)); });
  c.on(leg, 'pointerout', e => { if (!leg.contains(e.relatedTarget)) bar.classList.remove('hl'); });
  // the pill opens and shuts the panel; Escape shuts it while the pointer or the focus is on the meter
  const setOpen = (o, back) => { root.dataset.open = o ? '1' : '0'; tg.setAttribute('aria-expanded', o); pop.inert = !o; if (back) tg.focus({ preventScroll:true }); };
  tg.onclick = () => setOpen(root.dataset.open !== '1');
  c.on(document, 'keydown', e => { if (e.key === 'Escape' && root.dataset.open === '1' && (root.contains(document.activeElement) || root.matches(':hover'))) setOpen(false, root.contains(document.activeElement)); });
  // new tool output lands and takes the context close to its limit
  function arrive(){ P[1].v = +(P[1].v + 38.5).toFixed(1); paint(700); say(`${Math.round(sum() / LIMIT * 100)}% full. Older turns start to drop at 200k`, 'warn', 'warn'); }
  // Compact turns older turns into a summary and trims old tool output; Undo puts them back
  const before = {}; let state = 'full';
  go.onclick = () => c.run(async () => {
    if (go._busy) return; go._busy = true;
    if (state === 'full'){
      P.forEach(p => before[p.k] = p.v); const was = sum();
      go.innerHTML = '<span class="spin"></span>Compacting'; swap(hint, 'Summarising 48 turns'); await c.wait(1000);
      P[2].v = 4.6; P[1].v = +(P[1].v - 13.7).toFixed(1); paint(1100);
      say(`Freed ${fmt(was - sum())} tokens. 9 decisions kept`, 'ok', 'ok'); swap(hint, 'Older turns are now a summary'); go.textContent = 'Undo'; state = 'compact';
    } else {
      P.forEach(p => p.v = before[p.k]); paint(700); msg.className = 'ctm-msg'; swap(hint, 'Summarises older turns'); go.textContent = 'Compact'; state = 'full';
    }
    go._busy = false;
  });
  // it opens on its real numbers; they only count when something changes
  paint(1);
  // the preview's story: read what fills it, new tool output pushes it close to the limit, compact, then shut it
  if (r === c.st) c.story(async s => {
    await s.beat(3);
    await s.point($('.ctm-l[data-k="tools"]', leg)); await s.beat(3);
    await s.point($('.ctm-l[data-k="files"]', leg)); await s.beat(3);
    s.away(); await s.beat(1);
    arrive(); await s.beat(4);
    await s.tap(go); s.away(); await s.until(() => state === 'compact'); await s.beat(4);
    await s.tap(tg); s.away(); await s.beat(2);
  });
});
06

Questions

What people ask about it.

What should the parts be?
Whatever your agent can tell apart. Files, tool output, chat and instructions work for most agents. Put the largest items of each into items so people can see what to drop.
How do I show what a compaction freed?
Make onCompact return a promise, update parts with the smaller counts when it resolves, set compacted, and pass the difference as message with messageTone: 'ok'. The numbers count down by themselves.
When does it turn amber?
When the tokens in parts add up to warnAt of limit or more. warnAt is 0.85 unless you change it.
07

More in Agent status

Show what the agent is doing, how far along it is, and what it is working with.

ThinkingThe live trace, written as the agent works, then folded away.
Reasoning trailThe agent's plan, notes, sources and edits, after it thinks.
Agent lanesSeveral agents working in parallel, each with its own lane and hand-offs.
Agent treeAn orchestrator and its sub-agents, with cost rolled up per branch.
Agent inboxEvery run that needs you, is running or is done, in one list.
Trace viewOne agent run as a waterfall of spans, with latency and tokens.
Browser sessionThe agent browsing for you, step by step, with Pause and Take over.
Run summaryWhat the agent did, what changed and what needs you.
All 18 in Agent status →