Agent statusContext meter
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callContext 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add context-meterReact
npx designme add context-meter --reactThe 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.
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.
partsare the kinds of thing in the context, each with alabel, atokenscount, an optionalcoloranditems, the list shown on hover.limitis the size of the window.- The pill shows the ring and the share used. From
warnAtof the limit, 85% by default, the ring turns amber. - The bar and the numbers count to their new values whenever
partschange, so a compaction counts down. - Hovering a legend row lights its share of the bar and shows its
items. messageadds a line under the legend, in amber with a warning sign formessageTone: 'warn'or in green with a check for'ok', like "Freed 41.2k tokens. 9 decisions kept". The line keeps its room while empty, so a message fades in without the panel changing height.- Compact calls
onCompact. Return a promise and the button spins until it settles. Setcompactedand the button becomes Undo, which callsonUndo. - The pill opens and shuts the panel, and
open,defaultOpenandonOpenChangelet you control it. The panel keeps its room while shut, so nothing around it moves.
- The pill is a button with
aria-expandedand a label that includes the share used, like "Context, 68% used". - The panel is a region labelled "What fills the context", and it is inert while shut so its controls can't be reached.
- Escape shuts the panel while the pointer or the focus is on the meter, and moves the focus back to the pill if it was inside.
- The message line is an
aria-live="polite"region, so a warning or the compaction result is announced.
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>
);
}
Props
Everything ContextMeter takes.
| Prop | Type | What it does |
|---|---|---|
partsrequired | ContextPart[] | |
limitrequired | number | The size of the context window, in tokens, like 200000 |
warnAt | number | From this share of the limit (0 to 1) the ring turns amber. Defaults to 0.85 |
message | string | A 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 |
compacted | boolean | Set once the context is compacted: the button turns into Undo, which calls onUndo |
onUndo | () => void | |
hint | string | The quiet line beside the button. Defaults to what Compact does |
open | boolean | Whether the panel is open. Leave it out to let the pill toggle it |
defaultOpen | boolean | |
onOpenChange | (open: boolean) => void | |
className | string |
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);
});
});
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
itemsso people can see what to drop. - How do I show what a compaction freed?
- Make
onCompactreturn a promise, updatepartswith the smaller counts when it resolves, setcompacted, and pass the difference asmessagewithmessageTone: 'ok'. The numbers count down by themselves. - When does it turn amber?
- When the tokens in
partsadd up towarnAtoflimitor more.warnAtis 0.85 unless you change it.
More in Agent status
Show what the agent is doing, how far along it is, and what it is working with.