Native account usage above the prompt, with opt-in live token-burn checks. Local only.

The local development artifact is antiburn-dev, separate from the public antiburn candidate. Public output excludes CLI discovery, live checks and previews. This guide documents the development feature set; see the plugin page for current distribution status.
Burn animation and its preview controls are temporarily disabled until refined. App-backed predefined checks, development previews, static branding, quota controls and loading indicators remain available. Animation algorithms remain in the source for later work.
This folder contains the maintained development implementation, including the app-backed Checks pane and debug tools. Selected shared usage files and the public-specific inputs in release/ also feed the generated antiburn artifact. The public entry points come from integrations/antiburn/claude; the development entry is hooks/register.tsx. The public artifact excludes development checks and debug controls. See the plugin architecture for the complete source-to-artifact map. The cross-vendor core keeps the antiburn name.
The command examples below describe this source's /antiburn-dev registration. Assembly rewrites commands and state owners to antiburn-dev in the local development artifact, so it can coexist with the release and core.
A Claude Code mod that shows account usage above the prompt. Usage is the default mode. Set ANTIBURN_LIVE_MODE=checks before launching Claude Code to add the app-backed predefined Checks pane. Other values select usage. Changing the flag requires a reload or restart; /clear retains the selected mode. The mod makes no network calls.
In this README, "desktop" means the Claude desktop app. The development mod reads saved antiburn app results through the CLI; "desktop" does not refer to that evaluator. See "Privacy and resources" below.
Generate the local-only development artifact from the repository root:
node scripts/assemble-claude-mod.mjs --dev
claude --settings '{"tui":"fullscreen"}' \
--plugin-dir .agent-artifacts/claude-mod/dev/plugins/antiburn-dev
The development launch above selects fullscreen for this process so panes dock on the right when the terminal is at least 110 columns wide. It does not change the user settings. Narrower terminals use the inline fallback.
Automatic CLI discovery notices are disabled by default. Enable the development option Debug: show CLI discovery at startup only when diagnosing installation. Explicit status commands remain available. Missing CLI or app data must not block the coding session; the checks pane may report unavailable or retain last-known results.
Regenerate after changing source files. The host can reload changes in the emitted folder; it does not run the assembler for you. Use a separate test session and normally enable only one usage strip per session.
The generated marketplace at .agent-artifacts/claude-mod/dev is named antiburn-local-dev; it lists only antiburn-dev. Keep it local. The repository marketplace lists only public antiburn 0.2.3 at its immutable artifact commit. It does not publish this development build. See the plugin page for current distribution status and remaining gates.
The source manifest retains its development name. Do not publish this folder. Its hooks module is hooks/register.tsx.
The small a mark accompanies the “5-hour” and “Weekly” meters. Usage stays closest to the composer, below optional checks, including while Claude works. A host survey temporarily owns the band. Claude Code scrolls a tree taller than its maxRows; the mod does not override that height limit.
The Usage card in /antiburn-dev debug holds usage details and a Refresh button for the native snapshot. Details include the exact percentage, reset timestamp and recognized additional windows. Values come from session.usage() and session.measure, as last reported by Claude Code. There is no independent provider fetch or freshness claim. Desktop engine 2.1.295 was observed returning only a weekly window from session.usage() after the five-hour reset; the mod preserves that missing percentage as unknown and uses “Starts when a message is sent” for its reset text when a valid weekly reading is present. This copy is a presentation fallback; the SDK does not expose an inactive-window flag. Empty readings say “Usage not available yet”; missing windows use an unknown value, not zero. Failed refreshes label retained values “Last known” in the debug pane and accessible meter descriptions. Desktop keeps the same strip height across ready, empty, and failed readings. An exceeded percentage stays exact while its meter saturates at 100%.
The pace marker uses the elapsed fraction of the known five-hour or weekly period. It is a continuous neutral line before usage reaches that unrounded fraction, then a continuous orange line. High usage remains static orange.
Desktop draws the labels, exact percentages and whole resting meter dots in SVG. The filled count rounds to the nearest dot, with one lit for nonzero usage and all dots lit only at 100%. Desktop fill transitions can pass through partial dots. Terminal uses whole filled or empty glyphs. Red warning dots stay fully to the right of a reached pace marker; the marker never splits a dot's color. Bundled Bitcount glyphs provide dot typography without loading a font at runtime. Desktop primary captions use 14px lettering; dim secondary copy stays at 12px. The usage-strip mark is 13px wide, including activity previews. The small mark centers in its unchanged 14px slot against the complete meter block. A 5px leading inset balances the host padding against the 13px gap before the meters. A matching 5px trailing inset gives the last dot the same band-edge clearance as the mark. Reset details occupy reserved caption space: hover reveals them after a 300ms dwell; focus reveals them immediately without adding rows. Click away or move focus to dismiss. Reset text centers vertically against the label. The 18px pace marker centers on the dots and uses one continuous stroke. Pace hover contrast uses the same 300ms dwell; focus and tap stay immediate. Missing percentage readings display —; internal availability and read-error states remain distinct. Desktop draws no native tooltip; the Usage card in /antiburn-dev debug keeps full reset and pace text available, and accessible labels retain the same information.
Desktop presentation includes the once-per-session entrance, 600–960ms fill sweep, changed-digit rolls and reset hover transitions. SVG CSS handles motion; host events release expired presentation bookkeeping without scheduling timers. New readings interrupt entrance and settle to current values. Resize, surveys, clear and teardown cancel presentation work. Same-session reload does not grant another entrance. CSS reduced-motion rules remove movement and delayed fill and use 80ms opacity for entrance/details. Live reduced-motion preference changes were verified on desktop 2.37337.1. Full native boundary-frame validation is tracked separately; these implementation rules are not a claim of pixel-identical native output.
Terminal retains an explicitly accepted static fallback: native text captions, whole/half-dot glyph approximations, exact numeric values and a two-cell brand gap. It schedules no presentation timers and does not assume pixel Image support, pixel cell dimensions, or an unavailable reduced-motion signal. Both adapters reduce dot counts as space shrinks. The desktop's 80-column threshold uses the tested 8px-per-column scale; other host scales still need validation.
ANTIBURN_LIVE_MODE=checks)Antiburn's desktop app evaluates installed YAML checks. The development mod reads saved results through antiburn checks SESSION --delivery-v1 and displays them in the existing checks strip, footer indicator and expandable pane. It never detects problems from prompts, tools, token counts or turn events.
Build the new CLI from apps/cli before testing this mode. The app must run and be configured to produce new assessments. A CLI read can return saved results while the app is closed; it does not prove freshness or recent evaluation. See session checks for the protocol, coverage and native context-delivery adapters.
Open the pane with /antiburn-dev checks, the footer indicator or the strip's View control. The existing expandable cards, section navigation and keyboard controls remain. Cards use dynamic identities from Antiburn, so adding a YAML check requires no matching local detector or fixed catalog entry.
Each real card displays the authored assessment message, assessment time and checking activity. It offers local dismissal only. It provides no fix prompt, verification action, repair claim or savings estimate. A saved eligible finding can remain visible while another assessment runs.
Connection labels distinguish these states:
Connected · freshness not evaluated means the CLI protocol returned results.Waiting for Antiburn to discover this session retains prior warnings while the app has not produced an authoritative discovery snapshot.Antiburn disconnected · showing last known checks retains previous cards after a failed process call.Antiburn could not read the latest checks · showing last known checks retains previous cards for unavailable or incompatible saved results.Antiburn checks are inactive for this session removes cards when checks are disabled, unconfigured or unsupported.An explicit cleared update moves a prior warning into the resolved group. It means the saved assessment no longer reports the warning; it does not prove repair. An invalidated update removes advice whose eligibility is no longer confirmed. Connection failures never simulate either transition.
Dismiss this check hides a card locally. The pane's claude-code:ui consumer is independent from claude-code:context; dismissing or viewing a card cannot consume the model's advice. Usage meters remain available when the CLI or app is unavailable.
The terminal docks the pane on the right in fullscreen layout (/tui fullscreen or "tui": "fullscreen"). Classic layout shows the pane inline. Use the isolated fullscreen development launch above for pointer testing. Desktop, terminal and narrow surfaces still need separate visual acceptance.
The mod registers /antiburn-dev. The separate usage pane is currently disabled; usage readings, developer examples and animation inspection are in the Usage card of /antiburn-dev debug. The Checks card groups mode controls and check demos. Only checks mode advertises and accepts the checks and demo arguments. Usage mode does not collect checks, discover skills, publish check status, open check panes or run check actions.
| Command | Action |
|---|---|
/antiburn-dev | Opens live checks in checks mode. In usage mode, confirms that limits are shown above the prompt without opening a pane. |
/antiburn-dev checks | Opens the checks pane in checks mode. |
/antiburn-dev demo | Shows a sample view with all eight checks and opens the pane. Five are active (E1, D, T, K1, K2), one is watched (X1) and two are resolved (O by its fix, C1 by itself). The sample stays until demo off. demo states is an alias. |
/antiburn-dev demo tour | Starts a tour of 4 steps on an empty, separate set of checks, and opens the pane. |
/antiburn-dev demo next | Plays the next tour step as one turn. The Next step button in the pane does the same. |
/antiburn-dev demo off | Ends the sample view or the tour, and shows the live checks again. |
Any unrecognized argument replies with the usage line.
The tour steps are:
(N new).The tour does not show O, because O fires only on the first turn. The tour uses its own set of checks. The app-result consumer remains separate from the synthetic demo tracker.
Caution: a fix in a demo or a tour acts for real. It sends the prompt to Claude, fills the prompt box, or compacts the session.
Real-session checks come only from Antiburn's installed YAML definitions. The old LiveChecks rules receive no real prompts, tool results, steps, usage, completion or compaction events. There is no local detector fallback.
The eight fixed catalog samples remain in the development demo and tour. They exercise the existing visual states and keyboard navigation; they do not establish app evaluation or check coverage. Sample verification and savings labels are not claims about the current session. See hooks/catalog.ts and hooks/checks.ts for the retained sample implementation.
From the repository root:
node scripts/assemble-claude-mod.mjs --dev-test
claude plugin test .agent-artifacts/claude-mod/dev-test/plugins/antiburn-dev
claude plugin validate .agent-artifacts/claude-mod/dev-test/plugins/antiburn-dev
The test stage excludes release-source tests and runs development tests against the generated development identity. Do not run claude plugin test directly on this source folder: the host recursively discovers the separate release entry's tests, which belong to the public artifact's test stage.
For a source type check, load plugins/antiburn-live directly in a separate interactive test session to generate its local .claude-plugin/types, then run:
npx -y -p typescript@5.6.3 tsc -p plugins/antiburn-live --noEmit
Generated declarations reflect the installed host and can include account MCP types. Keep them ignored and out of both artifacts. CI validates capabilities and tests both emitted identities; the separate SDK type check remains a local release gate.
The policy part of hooks/catalog.ts must agree with the engine catalog (ReportCatalogs): the revisions, the depth cap (D), the effort cap (T), the premium models (S) and the model replacements (O). A test compares each one, and it fails when policy holds a field that has no test. The live thresholds and the checks text exist only in the mod, because the engine has no copy of them. From the repository root, run:
cargo nextest run --manifest-path crates/antiburn-local/Cargo.toml --test live_catalog_drift
The antiburn Claude plugin workflow uses Node 24 and Claude Code 2.1.303. It checks deterministic packaging, strictly validates the public marketplace/plugin, tests the exact public artifact and separate development build, and runs an isolated installation lifecycle check. The public capability policy permits only the approved account-usage and website actions; development capabilities are separate. This workflow does not publish a release or replace native acceptance. See the architecture checks.
| Path | Contents |
|---|---|
.claude-plugin/plugin.json | The manifest: name, version, description, and the state types. |
hooks/hooks.json | Loads register.tsx. |
hooks/register.tsx | The hooks, the /antiburn-dev command, the strip, the chip, the pane, the app-result bridge and the demo. |
hooks/checks.ts | LiveChecks, retained rules and stages for development samples only. It takes no $ and does no I/O. |
hooks/catalog.ts | The check catalog: ids, names, sentences, fixes, thresholds and model replacements. Keep the text between the catalog:begin and catalog:end markers valid JSON. |
hooks/tour.ts | The 4 demo tour steps. |
hooks/*.test.ts | Tests for the checks, the surfaces and the tour. |
hooks/usage.ts | Pure usage normalization, pace calculations and read ordering. |
hooks/usage-graphics.ts | SVG typography, meter drawing and bounded CSS motion. |
hooks/usage-presentation.ts | Shared geometry, timeline calculations and entrance ownership. |
hooks/usage-font.ts | Bundled Bitcount glyph layout; license in assets/bitcount. |
types/index.d.ts | The check view and the state types. |
types/claude-code.d.ts | Names the claude-code module for tools that read only the committed source. |
tsconfig.json | Extends the generated .claude-plugin/types/tsconfig.json. |
.gitignore | Ignores .claude-plugin/types/. |
Seen in Claude Code 2.1.296 in the desktop Code tab.
When the prompt holds the keyboard, a click on a pane button moves the keyboard to the pane but does not press the button. You must click again. While the pane holds the keyboard, later clicks press at once.
The mod cannot work around this. The host does not raise ui.press for that click, and the ui.focus event that it raises has no element, so the mod cannot tell which button you clicked. The mod handles a press that it receives in a few milliseconds, so the delay is the wait for the second click.
A fix needs a change in Claude Code: the click should press the button, or ui.focus should name the clicked element.
Open /antiburn-dev debug to show the separate developer debug pane. The debug argument is unadvertised: it is omitted from the normal command hint and help response. The host can hide whole commands but has no per-argument hiding setting. No environment variable or restart is required. Usage defaults to Label reveal: label, adaptive dot track, and percentage share a row per window. Narrow views stack complete rows. The Usage card's layout buttons select Current (captions above the tracks) for comparison with the same live or synthetic readings. Desktop dots are 7px wide with a fixed 4px gap (11px center to center) and 85% fill opacity in both layouts; available width changes the count, never the spacing. Layout changes cancel pending motion and reset on plugin reload. Hover a label for reset text over its track; focus or tap reveals it immediately. Pace has its own control. Terminals retain the static glyph fallback with label and pace controls, without graphical animation. The debug pane alone shows the effective mode: Mode 0 · Usage limits or Mode 1 · Usage limits + live checks. The mode button switches between usage-only and live checks for the current session. Checks start fresh after each change; switching to Mode 0 closes the checks pane and clears its status. View live checks opens the existing checks pane in Mode 1. Reload restores ANTIBURN_LIVE_MODE (usage or unset for Mode 0, checks for Mode 1); the button does not change that environment setting. Normal checks panes omit developer controls and mode labels. The separate usage pane is disabled; current readings and Refresh are in the debug Usage card. Select inactive, low, mid, beyond-pace, high, near-limit, missing, or read-error examples. Extra 9%, 10%, 99%, and 100% examples exercise digit rolls.
hooks/register.tsx 2406 lines1import { liveMode, type LiveMode } from "./mode";
2import { enteredStages, renderChecksPaneView } from "./register-pane-view";
3import { renderUsageDetailsView } from "./register-usage-details";
4import {
5 buildDesktopUsageAssets,
6 renderUsageView,
7 hoverSites,
8 terminalHoverBinding,
9 type DesktopAssetCache,
10} from "./register-usage-view";
11import { renderUsageDebugView } from "./register-debug-view";
12import {
13 resetUsageBurnControls,
14 renderSessionModeView,
15 renderUsageComposition,
16} from "./register-usage-bands";
17export {
18 commandSummary,
19 promptPreview,
20 splitSentence,
21} from "./register-visuals";
22import {
23 BRAND_MARK_SVG,
24 chipLabel,
25 chipView,
26 toggleGroup,
27 visibleRowIds,
28 renderStripView,
29 renderTerminalMarkView,
30 renderUsageMarkView,
31 renderDesktopBrandLinkView,
32 centredLineView,
33} from "./register-visuals";
34import { atom, read, update } from "claude-code";
35import {
36 describe,
37 parseCommand,
38 renderHelp,
39 renderStatus,
40} from "../../../packages/antiburn-plugin-core/product.mjs";
41import { renderNotice } from "../../../packages/antiburn-plugin-core/core.mjs";
42import { APP_URL } from "./brand";
43import type {
44 EngineInterface,
45 Register,
46 RenderInput,
47 RenderNode,
48} from "claude-code";
49
50import type {
51 LiveCheckId,
52 LiveCheckView,
53 LivePaneState,
54 LiveStripState,
55 LiveViewState,
56 LiveUsageState,
57 LiveBurnState,
58} from "../types";
59import { demoView, LiveChecks, liveChecks, type LiveView } from "./checks";
60import {
61 applySessionChecks,
62 disconnectedSessionChecks,
63 parseSessionChecks,
64 sessionConnectionText,
65 type SessionCheckConnection,
66} from "./session-checks-ui";
67import { TOUR } from "./tour";
68import {
69 normalizeUsage,
70 pace,
71 percentage,
72 primaryWindow,
73 resetText,
74 usageFailure,
75 UsageOrder,
76 PRIMARY_WINDOWS,
77 type UsageSnapshot,
78} from "./usage";
79import { meterDescription } from "./usage-graphics";
80import {
81 EntranceOwner,
82 usageLayout,
83 meterGeometry,
84 bezier,
85} from "./usage-presentation";
86import { USAGE_FIXTURES, usageFixture } from "./usage-fixtures";
87import { TERMINAL_FLAME, terminalBrandCells } from "./terminal-flame";
88import {
89 BURN_ANIMATION_ENABLED,
90 BurnActivity,
91 BURN_HEIGHT,
92 burnAnimationSvg,
93} from "./burn";
94
95// The validator follows $ and state references only inside this file, so every
96// function that takes $ and every atom is here. checks.ts takes no $.
97
98type PaneEvent = RenderInput<"Pane">;
99
100const PANE = "checks";
101
102const view = atom(
103 { plugin: "antiburn-live", key: "view" } as const,
104 {
105 checks: [],
106 resolvedLine: [],
107 entered: [],
108 } as LiveViewState,
109);
110
111const strip = atom(
112 { plugin: "antiburn-live", key: "strip" } as const,
113 {
114 isDismissed: false,
115 newCount: 0,
116 armedId: null,
117 } as LiveStripState,
118);
119
120const pane = atom(
121 { plugin: "antiburn-live", key: "pane" } as const,
122 {
123 selectedId: null,
124 openGroups: [],
125 turned: null,
126 fullPromptId: null,
127 } as LivePaneState,
128);
129
130const usage = atom(
131 { plugin: "antiburn-live", key: "usage" } as const,
132 {
133 snapshot: { status: "empty", windows: [], capturedAt: 0 },
134 revealed: null,
135 activated: null,
136 } as LiveUsageState,
137);
138const burn = atom(
139 { plugin: "antiburn-live", key: "burn" } as const,
140 {
141 active: false,
142 previewUntil: 0,
143 reduced: false,
144 startedAt: 0,
145 coolingAt: null,
146 revision: 0,
147 estimatedTps: 0,
148 measuredTps: null,
149 source: "unknown",
150 } as LiveBurnState,
151);
152const burnActivity = new BurnActivity();
153const burnPending = new Map<string, number>();
154let burnGeneration = 0;
155let burnSequence = 0;
156let burnPublish: Promise<void> = Promise.resolve();
157let burnObservation: Promise<void> | undefined;
158let burnVisible = false;
159const burnSites = new Set<string>();
160let burnPreviewRevision = 0;
161let burnLeaseAt = 0;
162let burnSvgKey = "";
163let burnSvgSource = "";
164let burnPlaybackUntil = 0;
165let burnCoolingStrength = 0;
166
167type TerminalFlameSite = { revision: string; cancel: () => void };
168const terminalFlames = new Map<string, TerminalFlameSite>();
169
170const terminalPulses = new Map<
171 string,
172 { revision: string; startedAt: number; cancel: () => void }
173>();
174const TERMINAL_PULSE_HALF_MS = 800;
175const TERMINAL_PULSE_DURATION_MS = 9600;
176
177function stopTerminalPulse(requestId: string): void {
178 terminalPulses.get(requestId)?.cancel();
179 terminalPulses.delete(requestId);
180}
181
182function terminalPulse(
183 $: EngineInterface,
184 requestId: string,
185 revision: string,
186 now: number,
187 enabled: boolean,
188): boolean {
189 if (!enabled) {
190 stopTerminalPulse(requestId);
191 return false;
192 }
193 if (terminalPulses.get(requestId)?.revision !== revision) {
194 stopTerminalPulse(requestId);
195 const site = { revision, startedAt: now, cancel: () => {} };
196 terminalPulses.set(requestId, site);
197 const schedule = () => {
198 const timer = $.clock.after(TERMINAL_PULSE_HALF_MS, () => {
199 void (async () => {
200 try {
201 const time = await $.clock.now();
202 if (terminalPulses.get(requestId) !== site) return;
203 $.ui.invalidate("ui.render");
204 // Keep the completed revision so a redraw cannot restart the pulse.
205 if (time - site.startedAt < TERMINAL_PULSE_DURATION_MS) schedule();
206 } catch {
207 if (terminalPulses.get(requestId) === site)
208 stopTerminalPulse(requestId);
209 }
210 })();
211 });
212 site.cancel = () => timer.cancel();
213 };
214 schedule();
215 }
216 const elapsed = now - terminalPulses.get(requestId)!.startedAt;
217 return (
218 elapsed < TERMINAL_PULSE_DURATION_MS &&
219 Math.floor(elapsed / TERMINAL_PULSE_HALF_MS) % 2 === 1
220 );
221}
222
223function stopTerminalFlame(requestId: string): void {
224 terminalFlames.get(requestId)?.cancel();
225 terminalFlames.delete(requestId);
226}
227
228function resetTerminalFlames(): void {
229 for (const requestId of terminalPulses.keys()) stopTerminalPulse(requestId);
230 for (const requestId of terminalFlames.keys()) stopTerminalFlame(requestId);
231}
232
233function animateTerminalFlame(
234 $: EngineInterface,
235 e: RenderInput<"AbovePrompt">,
236 state: LiveBurnState,
237 now: number,
238 active: boolean,
239): string {
240 if (!BURN_ANIMATION_ENABLED) return terminalBrandCells(0, 0);
241 const until = active
242 ? state.previewUntil > now
243 ? state.previewUntil
244 : burnLeaseAt + 8000
245 : state.coolingAt === null
246 ? now
247 : state.coolingAt + TERMINAL_FLAME.fadeMs;
248 const revision = `${e.props.bodyColumns}:${state.startedAt}:${until}:${state.reduced}`;
249 const cellsAt = (time: number) =>
250 terminalBrandCells(
251 Math.max(0, time - state.startedAt),
252 state.reduced
253 ? time < until
254 ? 1
255 : 0
256 : Math.max(0, Math.min(1, (until - time) / TERMINAL_FLAME.fadeMs)),
257 state.reduced,
258 );
259 if (now >= until) {
260 stopTerminalFlame(e.requestId);
261 return terminalBrandCells(0, 0);
262 }
263 if (terminalFlames.get(e.requestId)?.revision !== revision) {
264 stopTerminalFlame(e.requestId);
265 const site: TerminalFlameSite = { revision, cancel: () => {} };
266 terminalFlames.set(e.requestId, site);
267 const current = () => terminalFlames.get(e.requestId) === site;
268 const paint = async (): Promise<void> => {
269 if (!current()) return;
270 try {
271 const time = await $.clock.now();
272 if (!current()) return;
273 const result = await $.ui.blit({
274 requestId: e.requestId,
275 key: "usage-terminal-flame",
276 columns: 10,
277 rows: 1,
278 cells: cellsAt(time).slice(0, 160),
279 });
280 if (!current()) return;
281 $.ui.invalidate("ui.render");
282 if (result.deny || time >= until) {
283 stopTerminalFlame(e.requestId);
284 return;
285 }
286 schedule(state.reduced ? until - time : TERMINAL_FLAME.frameMs);
287 } catch {
288 if (current()) stopTerminalFlame(e.requestId);
289 }
290 };
291 const schedule = (ms: number) => {
292 const timer = $.clock.after(Math.max(1, ms), () => {
293 void paint();
294 });
295 site.cancel = () => timer.cancel();
296 };
297 schedule(state.reduced ? until - now : TERMINAL_FLAME.frameMs);
298 }
299 return cellsAt(now);
300}
301
302function resetBurn(): void {
303 resetTerminalFlames();
304 burnGeneration += 1;
305 burnPreviewRevision += 1;
306 burnSites.clear();
307 burnActivity.reset();
308 burnPending.clear();
309 burnPublish = Promise.resolve();
310 burnObservation = undefined;
311 burnVisible = false;
312 burnLeaseAt = 0;
313 burnPlaybackUntil = 0;
314 burnCoolingStrength = 0;
315 burnSvgKey = "";
316 resetUsageBurnControls();
317}
318
319async function resetBurnState($: EngineInterface): Promise<void> {
320 await update($, burn, (current) => ({
321 ...current,
322 active: false,
323 previewUntil: 0,
324 reduced: false,
325 coolingAt: null,
326 estimatedTps: 0,
327 measuredTps: null,
328 source: "idle",
329 revision: current.revision + 1,
330 }));
331}
332
333async function tickBurn(
334 $: EngineInterface,
335 generation: number,
336 force = false,
337): Promise<void> {
338 const publish = async (): Promise<void> => {
339 if (generation !== burnGeneration) return;
340 const previewRevision = burnPreviewRevision;
341 const now = await $.clock.now();
342 if (generation !== burnGeneration) return;
343 for (const [id, count] of burnPending) {
344 if (count > 0) burnActivity.chunk(id, count, now);
345 burnPending.set(id, 0);
346 }
347 const snapshot = burnActivity.snapshot(now);
348 const prior = await read($, burn);
349 if (
350 generation !== burnGeneration ||
351 previewRevision !== burnPreviewRevision
352 )
353 return;
354 const previewUntil = prior.previewUntil > now ? prior.previewUntil : 0;
355 const active = burnVisible && (previewUntil > 0 || snapshot.active);
356 const changed = active !== prior.active;
357 const renew = active && previewUntil === 0 && now - burnLeaseAt >= 4000;
358 if (changed || renew || previewUntil !== prior.previewUntil || force) {
359 const reenter = active && (!prior.active || now - burnLeaseAt >= 8000);
360 if (changed && !active) {
361 burnCoolingStrength = Math.max(
362 0,
363 Math.min(
364 1,
365 (burnPlaybackUntil - now) / 800,
366 (now - prior.startedAt) / 220,
367 ),
368 );
369 }
370 if (changed || renew) burnLeaseAt = now;
371 await update($, burn, (current) =>
372 generation !== burnGeneration || previewRevision !== burnPreviewRevision
373 ? current
374 : {
375 ...current,
376 active,
377 previewUntil,
378 reduced: active
379 ? previewUntil > 0 && current.reduced
380 : current.reduced,
381 startedAt: reenter ? now : current.startedAt,
382 coolingAt:
383 !active && prior.active
384 ? now
385 : active
386 ? null
387 : current.coolingAt,
388 revision:
389 changed || renew || previewUntil !== prior.previewUntil
390 ? current.revision + 1
391 : current.revision,
392 estimatedTps: snapshot.estimatedTps,
393 measuredTps: snapshot.measuredTps,
394 source: snapshot.source,
395 },
396 );
397 }
398 };
399 const pending = burnPublish.then(publish, publish);
400 burnPublish = pending.catch(() => {});
401 await pending;
402}
403
404function observeBurn($: EngineInterface, generation: number): void {
405 if (generation !== burnGeneration || burnObservation) return;
406 const pending = tickBurn($, generation);
407 burnObservation = pending;
408 void pending
409 .catch(() => {})
410 .finally(() => {
411 if (burnObservation === pending) burnObservation = undefined;
412 });
413}
414
415async function previewBurn(
416 $: EngineInterface,
417 reduced = false,
418 duration = 15000,
419 sequenceGeneration?: number,
420): Promise<void> {
421 if (!BURN_ANIMATION_ENABLED) return;
422 const generation = burnGeneration;
423 const revision = ++burnPreviewRevision;
424 const now = await $.clock.now();
425 if (
426 generation !== burnGeneration ||
427 revision !== burnPreviewRevision ||
428 (sequenceGeneration !== undefined &&
429 sequenceGeneration !== usageSequenceGeneration)
430 )
431 return;
432 burnLeaseAt = now;
433 await update($, burn, (current) =>
434 generation !== burnGeneration ||
435 revision !== burnPreviewRevision ||
436 (sequenceGeneration !== undefined &&
437 sequenceGeneration !== usageSequenceGeneration)
438 ? current
439 : {
440 ...current,
441 active: true,
442 previewUntil: now + duration,
443 reduced,
444 startedAt: now,
445 coolingAt: null,
446 revision: current.revision + 1,
447 },
448 );
449}
450
451async function stopBurnPreview($: EngineInterface): Promise<void> {
452 if (!BURN_ANIMATION_ENABLED) return;
453 burnPreviewRevision += 1;
454 await update($, burn, (current) => ({ ...current, previewUntil: 0 }));
455 await tickBurn($, burnGeneration);
456}
457
458async function refreshBurnStats($: EngineInterface): Promise<void> {
459 if (!BURN_ANIMATION_ENABLED) return;
460 const generation = burnGeneration;
461 const now = await $.clock.now();
462 if (generation !== burnGeneration) return;
463 const snapshot = burnActivity.snapshot(now);
464 await update($, burn, (current) =>
465 generation !== burnGeneration
466 ? current
467 : {
468 ...current,
469 estimatedTps: snapshot.estimatedTps,
470 measuredTps: snapshot.measuredTps,
471 source: snapshot.source,
472 },
473 );
474}
475
476let mode: LiveMode = "usage";
477const usageOrder = new UsageOrder();
478const DEBUG_PANE = "debug";
479let needsUsageReconciliation = false;
480let usageUiEnabled = true;
481let releaseOwnsUsage = false;
482let releaseOwnershipResolved = false;
483let startupNoticeEnabled = false;
484let startupNoticeSessionId: string | undefined;
485const usageEntrance = new EntranceOwner();
486let usageMount: string | undefined;
487let usageRenderKey: string | undefined;
488let usageSources: string[] = [];
489let usageMark = "";
490let usageMarkEntranceAt: number | null | undefined;
491let usageMarkReplayRevision = -1;
492let usagePreviewRevision = 0;
493let usageReplayRevision = 0;
494let usageSequenceGeneration = 0;
495let usageSequenceTimer: { cancel: () => void } | undefined;
496let usageSequenceEmber = false;
497let usageMeterWidth = 0;
498let usageLayoutVariant: "inline" | "current" = "inline";
499let usagePreviewFill:
500 | { from: number[]; to: number[]; startedAt: number }
501 | undefined;
502let previousUsage: UsageSnapshot | undefined;
503let usageRoll: { previous: UsageSnapshot; startedAt: number } | undefined;
504
505function cancelUsagePresentation(): void {
506 if (usageEntrance.state.phase === "running") {
507 usageMark = BRAND_MARK_SVG;
508 usageMarkEntranceAt = null;
509 }
510 usageEntrance.cancel();
511 usagePreviewFill = undefined;
512 usageRoll = undefined;
513 usageRenderKey = undefined;
514}
515
516// CSS settles the visuals. Host events release expired bookkeeping without timers.
517function settleUsagePresentation(now: number): void {
518 const startedAt = usageEntrance.state.startedAt;
519 if (startedAt !== null && now - startedAt >= 960) usageEntrance.cancel();
520 if (usageRoll !== undefined && now - usageRoll.startedAt >= 284)
521 usageRoll = undefined;
522}
523
524function checksEnabled(): boolean {
525 return mode === "checks";
526}
527
528async function clearDisabledChecks($: EngineInterface): Promise<void> {
529 if (checksEnabled()) return;
530 const previous = await read($, view);
531 if (previous.checks.length > 0 || previous.resolvedLine.length > 0) {
532 await update($, view, () => ({
533 checks: [],
534 resolvedLine: [],
535 entered: [],
536 }));
537 await update($, strip, () => ({
538 isDismissed: false,
539 newCount: 0,
540 armedId: null,
541 }));
542 await update($, pane, () => ({
543 selectedId: null,
544 openGroups: [],
545 turned: null,
546 fullPromptId: null,
547 }));
548 await $.ui.status(undefined);
549 }
550 const panes = await $.ui.panes();
551 if (panes.some((open) => open.id === PANE)) await $.ui.close({ id: PANE });
552}
553
554async function commitUsage(
555 $: EngineInterface,
556 snapshot: UsageSnapshot,
557 ticket: { generation: number; revision: number },
558) {
559 if (!usageOrder.isCurrent(ticket)) return;
560 if ((await read($, usage)).preview) {
561 previousUsage = snapshot;
562 await update($, usage, (current) =>
563 usageOrder.isCurrent(ticket) ? { ...current, snapshot } : current,
564 );
565 return;
566 }
567 if (
568 previousUsage?.status === snapshot.status &&
569 JSON.stringify(previousUsage.windows) ===
570 JSON.stringify(snapshot.windows) &&
571 PRIMARY_WINDOWS.every((kind) => {
572 const window = primaryWindow(snapshot, kind);
573 return (
574 resetText(window, previousUsage!.capturedAt) ===
575 resetText(window, snapshot.capturedAt) &&
576 pace(window, previousUsage!.capturedAt) ===
577 pace(window, snapshot.capturedAt)
578 );
579 })
580 ) {
581 await update($, usage, (current) =>
582 usageOrder.isCurrent(ticket)
583 ? {
584 ...current,
585 snapshot: { ...snapshot, capturedAt: current.snapshot.capturedAt },
586 }
587 : current,
588 );
589 return;
590 }
591 settleUsagePresentation(snapshot.capturedAt);
592 const wasEntering = usageEntrance.state.phase === "running";
593 if (wasEntering) cancelUsagePresentation();
594 else if (usageEntrance.state.phase === "consumed") {
595 cancelUsagePresentation();
596 if (
597 previousUsage !== undefined &&
598 snapshot.status === "ready" &&
599 previousUsage.status === "ready" &&
600 PRIMARY_WINDOWS.some(
601 (kind) =>
602 percentage(primaryWindow(previousUsage!, kind)) !==
603 percentage(primaryWindow(snapshot, kind)),
604 )
605 ) {
606 usageRoll = { previous: previousUsage, startedAt: snapshot.capturedAt };
607 }
608 }
609 previousUsage = snapshot;
610 usageRenderKey = undefined;
611 await update($, usage, (current) =>
612 usageOrder.isCurrent(ticket) ? { ...current, snapshot } : current,
613 );
614}
615
616async function readUsage($: EngineInterface, explicit = false): Promise<void> {
617 if (!usageUiEnabled && !explicit) return;
618 const ticket = usageOrder.beginRead();
619 try {
620 const [reading, now] = await Promise.all([
621 $.session.usage(),
622 $.clock.now(),
623 ]);
624 await commitUsage($, normalizeUsage(reading.rateLimits, now), ticket);
625 } catch {
626 if (!usageOrder.isCurrent(ticket)) return;
627 if (!(await read($, usage)).preview) cancelUsagePresentation();
628 await update($, usage, (current) =>
629 usageOrder.isCurrent(ticket)
630 ? {
631 ...current,
632 snapshot: usageFailure(
633 current.snapshot,
634 current.snapshot.capturedAt,
635 ),
636 }
637 : current,
638 );
639 }
640}
641
642async function revealUsage(
643 $: EngineInterface,
644 kind: string | null,
645): Promise<void> {
646 await update($, usage, (current) => {
647 const dismissed = current.activated === kind;
648 return {
649 ...current,
650 revealed: dismissed ? null : kind,
651 activated: !dismissed ? kind : null,
652 };
653 });
654}
655
656async function reconcileUsage($: EngineInterface): Promise<void> {
657 if (!usageUiEnabled || releaseOwnsUsage || !needsUsageReconciliation) return;
658 needsUsageReconciliation = false;
659 await readUsage($);
660 await publish($);
661}
662
663function cancelUsageSequence(): void {
664 usageSequenceGeneration += 1;
665 usageSequenceTimer?.cancel();
666 usageSequenceTimer = undefined;
667}
668
669async function stopUsageSequence($: EngineInterface): Promise<void> {
670 const ember = usageSequenceEmber;
671 usageSequenceEmber = false;
672 cancelUsageSequence();
673 usagePreviewRevision += 1;
674 await update($, usage, (current) => ({ ...current, sequence: undefined }));
675 if (ember) await stopBurnPreview($);
676}
677
678async function startUsageSequence($: EngineInterface): Promise<void> {
679 cancelUsageSequence();
680 usagePreviewRevision += 1;
681 const generation = usageSequenceGeneration;
682 const steps = [
683 ...USAGE_FIXTURES.filter(
684 (fixture) =>
685 !["nine", "ten", "ninety-nine", "hundred"].includes(fixture.id),
686 ).map((fixture, index) => ({
687 label: index === 0 ? "Entrance · " + fixture.label : fixture.label,
688 id: fixture.id,
689 action: index === 0 ? ("entrance" as const) : undefined,
690 hold: fixture.id === "near-limit" ? 10000 : 1800,
691 })),
692 { label: "Reset reveal", action: "show" as const, hold: 1400 },
693 { label: "Reset hide", action: "hide" as const, hold: 500 },
694 { label: "Pace reveal", action: "pace-show" as const, hold: 1400 },
695 { label: "Pace hide", action: "pace-hide" as const, hold: 500 },
696 ...(BURN_ANIMATION_ENABLED
697 ? [
698 {
699 label: "Ember catch · High usage · 88% / 82%",
700 id: "high",
701 ember: true,
702 hold: 4000,
703 },
704 ]
705 : []),
706 ];
707 const advance = async (index: number): Promise<void> => {
708 if (generation !== usageSequenceGeneration) return;
709 const step = steps[index];
710 if (!step) {
711 if (generation === usageSequenceGeneration) await stopUsageSequence($);
712 return;
713 }
714 if ("id" in step && step.id) await selectUsagePreview($, step.id, true);
715 if (generation !== usageSequenceGeneration) return;
716 if ("action" in step && step.action)
717 await inspectUsageAnimation($, step.action, true);
718 if (generation !== usageSequenceGeneration) return;
719 if ("ember" in step) {
720 usageSequenceEmber = true;
721 await previewBurn($, false, step.hold, generation);
722 }
723 if (generation !== usageSequenceGeneration) return;
724 await update($, usage, (current) =>
725 generation === usageSequenceGeneration
726 ? {
727 ...current,
728 sequence: `${index + 1}/${steps.length} · ${step.label}`,
729 }
730 : current,
731 );
732 if (generation !== usageSequenceGeneration) return;
733 usageSequenceTimer = $.clock.after(step.hold, () => {
734 void advance(index + 1).catch(async () => {
735 if (generation === usageSequenceGeneration) await stopUsageSequence($);
736 });
737 });
738 };
739 await advance(0).catch(async () => {
740 if (generation === usageSequenceGeneration) await stopUsageSequence($);
741 });
742}
743
744async function selectUsagePreview(
745 $: EngineInterface,
746 id: string | null,
747 sequence = false,
748): Promise<void> {
749 if (!sequence) await stopUsageSequence($);
750 const revision = ++usagePreviewRevision;
751 const now = await $.clock.now();
752 if (id !== null && usageFixture(id, now) === undefined) return;
753 const prior = await read($, usage);
754 if (revision !== usagePreviewRevision) return;
755 const before = prior.preview
756 ? usageFixture(prior.preview.id, prior.preview.capturedAt)?.snapshot
757 : prior.snapshot;
758 const target = id === null ? undefined : usageFixture(id, now)?.snapshot;
759 const inFlight = usagePreviewFill;
760 const progress = inFlight
761 ? bezier((now - inFlight.startedAt) / 360, 0.23, 1, 0.32, 1)
762 : 1;
763 const from = inFlight
764 ? inFlight.from.map(
765 (start, index) => start + (inFlight.to[index]! - start) * progress,
766 )
767 : PRIMARY_WINDOWS.map(
768 (kind) =>
769 meterGeometry(
770 primaryWindow(before!, kind),
771 usageMeterWidth,
772 now,
773 usageLayoutVariant,
774 ).fillEnd,
775 );
776 cancelUsagePresentation();
777 if (target?.status === "ready" && usageMeterWidth > 0)
778 usagePreviewFill = {
779 from,
780 to: PRIMARY_WINDOWS.map(
781 (kind) =>
782 meterGeometry(
783 primaryWindow(target, kind),
784 usageMeterWidth,
785 now,
786 usageLayoutVariant,
787 ).fillEnd,
788 ),
789 startedAt: now,
790 };
791 if (
792 before?.status === "ready" &&
793 id !== null &&
794 usageFixture(id, now)?.snapshot.status === "ready"
795 )
796 usageRoll = { previous: before, startedAt: now };
797 await update($, usage, (current) =>
798 revision !== usagePreviewRevision
799 ? current
800 : {
801 ...current,
802 sequence: sequence ? current.sequence : undefined,
803 previewReveal: undefined,
804 preview: id === null ? null : { id, capturedAt: now },
805 revealed: null,
806 activated: null,
807 },
808 );
809}
810
811async function replayUsageEntrance($: EngineInterface): Promise<void> {
812 const current = await read($, usage);
813 const snapshot = current.preview
814 ? usageFixture(current.preview.id, current.preview.capturedAt)?.snapshot
815 : current.snapshot;
816 if (
817 snapshot?.status !== "ready" ||
818 !snapshot.windows.some((window) => window.percent !== null)
819 )
820 await selectUsagePreview($, "low");
821 await inspectUsageAnimation($, "entrance");
822}
823
824async function inspectUsageAnimation(
825 $: EngineInterface,
826 action: "entrance" | "show" | "hide" | "pace-show" | "pace-hide",
827 sequence = false,
828): Promise<void> {
829 if (!sequence) await stopUsageSequence($);
830 const revision = ++usagePreviewRevision;
831 const current = await read($, usage);
832 if (!current.preview && action !== "entrance") return;
833 const now = await $.clock.now();
834 if (revision !== usagePreviewRevision) return;
835 cancelUsagePresentation();
836 if (action === "entrance") {
837 usageReplayRevision += 1;
838 usageEntrance.load(true);
839 }
840 await update($, usage, (state) =>
841 revision !== usagePreviewRevision
842 ? state
843 : {
844 ...state,
845 sequence: sequence ? state.sequence : undefined,
846 revealed:
847 action === "show"
848 ? "five_hour"
849 : action === "pace-show"
850 ? "pace-five_hour"
851 : null,
852 activated: null,
853 preview: current.preview
854 ? { ...current.preview, capturedAt: now }
855 : current.preview,
856 previewReveal: action === "entrance" ? undefined : action,
857 },
858 );
859}
860
861async function renderUsageDetails($: EngineInterface, e: PaneEvent) {
862 const state = await read($, usage);
863 const preview = state.preview;
864 const fixture = preview
865 ? usageFixture(preview.id, preview.capturedAt)
866 : undefined;
867 const snapshot = fixture?.snapshot ?? state.snapshot;
868 const now = fixture ? preview!.capturedAt : await $.clock.now();
869 return renderUsageDetailsView(
870 $.ui.resolve(e),
871 snapshot,
872 now,
873 fixture !== undefined,
874 () => readUsage($, true),
875 );
876}
877
878async function showCheckDemo($: EngineInterface): Promise<void> {
879 if (!checksEnabled()) return;
880 invalidateSessionCheckReads();
881 isDemo = true;
882 tour = null;
883 const checks = demoView();
884 await update($, view, () => ({
885 checks,
886 resolvedLine: [],
887 entered: [...new Set(checks.map((check) => check.stage))],
888 }));
889 await showStatus($, checks);
890 await openPane($);
891}
892
893async function setDebugMode(
894 $: EngineInterface,
895 nextMode: LiveMode,
896): Promise<void> {
897 if (mode === nextMode) return;
898 invalidateSessionCheckReads();
899 mode = nextMode;
900 const generation = sessionCheckGeneration;
901 const isCurrent = () =>
902 sessionCheckGeneration === generation && mode === nextMode && checksEnabled();
903 resetTracker();
904 await clearDisabledChecks($);
905 if (checksEnabled()) {
906 await restoreSessionChecks($, isCurrent);
907 if (!isCurrent()) return;
908 await showStatus($, (await read($, view)).checks, isCurrent);
909 if (!isCurrent()) return;
910 let sessionId: string | undefined;
911 try {
912 sessionId = await $.session.id();
913 } catch {
914 sessionId = undefined;
915 }
916 await refreshSessionChecks($, sessionId);
917 }
918 else await $.ui.status(undefined);
919 const registered = await $.command.register({
920 name: "antiburn-dev",
921 description: checksEnabled()
922 ? "Show live checks"
923 : "Usage limits are shown above the prompt",
924 argumentHint: checksEnabled()
925 ? "[checks | demo | demo tour | demo off]"
926 : undefined,
927 });
928 commandName = registered.command;
929 await update($, usage, (current) => ({ ...current }));
930}
931
932async function selectUsageLayout(
933 $: EngineInterface,
934 layout: "inline" | "current",
935): Promise<void> {
936 resetTerminalFlames();
937 await stopUsageSequence($);
938 usagePreviewRevision += 1;
939 cancelUsagePresentation();
940 usageMount = undefined;
941 usageMeterWidth = 0;
942 usageLayoutVariant = layout;
943 await update($, usage, (current) => ({
944 ...current,
945 layout,
946 sequence: undefined,
947 previewReveal: undefined,
948 revealed: null,
949 activated: null,
950 }));
951}
952
953async function renderUsageDebug($: EngineInterface, e: PaneEvent) {
954 const ui = $.ui.resolve(e);
955 const state = await read($, usage);
956 const preview = state.preview;
957 const fixture = preview
958 ? usageFixture(preview.id, preview.capturedAt)
959 : undefined;
960 const burnState = await read($, burn);
961 const usageDetails = await renderUsageDetails($, e);
962 return renderUsageDebugView(
963 ui,
964 e,
965 state,
966 preview,
967 fixture,
968 burnState,
969 checksEnabled(),
970 usageDetails,
971 {
972 setDebugMode: (mode) => setDebugMode($, mode),
973 openPane: async () => {
974 await openPane($);
975 },
976 showCheckDemo: () => showCheckDemo($),
977 startTour: () => startTour($),
978 endDemo: () => endDemo($),
979 previewBurn: (reduced) => previewBurn($, reduced),
980 stopBurnPreview: () => stopBurnPreview($),
981 refreshBurnStats: () => refreshBurnStats($),
982 selectUsageLayout: (layout) => selectUsageLayout($, layout),
983 selectUsagePreview: (id) => selectUsagePreview($, id),
984 selectUsageState: (state) => selectUsagePreview($, `state-${state}`),
985 replayUsageEntrance: () => replayUsageEntrance($),
986 startUsageSequence: () => startUsageSequence($),
987 stopUsageSequence: () => stopUsageSequence($),
988 toggleSyntheticLabels: async () => {
989 await update($, usage, (current) => ({
990 ...current,
991 showSyntheticLabels: !current.showSyntheticLabels,
992 }));
993 },
994 inspectUsageAnimation: (action) => inspectUsageAnimation($, action),
995 },
996 );
997}
998
999async function openTerminalWebsite($: EngineInterface): Promise<void> {
1000 const windows = (await $.env.get("OS")) === "Windows_NT";
1001 const commands = windows
1002 ? [["cmd.exe", "/c", "start", "", APP_URL]]
1003 : [
1004 ["open", APP_URL],
1005 ["xdg-open", APP_URL],
1006 ];
1007 for (const argv of commands) {
1008 try {
1009 const result = await $.process.run(argv, { timeoutMs: 5000 });
1010 if (result.exitCode === 0) return;
1011 } catch {
1012 continue;
1013 }
1014 }
1015 await $.ui.status(`Open ${APP_URL} in your browser`);
1016}
1017
1018function renderTerminalMark(
1019 $: EngineInterface,
1020 e: RenderInput<"AbovePrompt">,
1021 state: LiveBurnState,
1022 now: number,
1023 active: boolean,
1024): RenderNode {
1025 const ui = $.ui.resolve(e);
1026 /* Braille reference: keep inactive because terminal line spacing splits the mark.
1027 if ('Raster' in ui) {
1028 // Adjust only the empty padding to center the grid beside one or two meter rows.
1029 const inlineCells = warm ? 'gCgAAGKu/wAAAAABwCgAAGKu/wAAAAABIAAAAGKu/wAAAAABlCgAAGKu/wAAAAABUigAAGKu/wAAAAABRygAAGKu/wAAAAABIAAAAGKu/wAAAAABIAAAAGKu/wAAAAABIAAAAGKu/wAAAAAB' : 'gCgAABRZ/wAAAAABwCgAABRZ/wAAAAABIAAAABRZ/wAAAAABlCgAABRZ/wAAAAABUigAABRZ/wAAAAABRygAABRZ/wAAAAABIAAAABRZ/wAAAAABIAAAABRZ/wAAAAABIAAAABRZ/wAAAAAB'
1030 const stackedCells = warm ? 'IAAAAGKu/wAAAAABIAAAAGKu/wAAAAABIAAAAGKu/wAAAAABkCgAAGKu/wAAAAAB0igAAGKu/wAAAAABRCgAAGKu/wAAAAABESgAAGKu/wAAAAABAigAAGKu/wAAAAABAygAAGKu/wAAAAABIAAAAGKu/wAAAAABIAAAAGKu/wAAAAABIAAAAGKu/wAAAAAB' : 'IAAAABRZ/wAAAAABIAAAABRZ/wAAAAABIAAAABRZ/wAAAAABkCgAABRZ/wAAAAAB0igAABRZ/wAAAAABRCgAABRZ/wAAAAABESgAABRZ/wAAAAABAigAABRZ/wAAAAABAygAABRZ/wAAAAABIAAAABRZ/wAAAAABIAAAABRZ/wAAAAABIAAAABRZ/wAAAAAB'
1031 return <ui.Raster key="usage-brand-raster" columns={3} rows={stacked ? 4 : 3} cells={stacked ? stackedCells : inlineCells} />
1032 }
1033 */
1034 const cells =
1035 "Raster" in ui ? animateTerminalFlame($, e, state, now, active) : undefined;
1036 return renderTerminalMarkView(ui, cells, () => openTerminalWebsite($));
1037}
1038
1039function renderDesktopBrandLink(
1040 $: EngineInterface,
1041 e: RenderInput<"AbovePrompt">,
1042): RenderNode {
1043 return renderDesktopBrandLinkView($.ui.resolve(e));
1044}
1045
1046function renderUsageMark(
1047 $: EngineInterface,
1048 e: RenderInput<"AbovePrompt">,
1049 resting: string,
1050 alt: string,
1051): RenderNode | null {
1052 const ui = $.ui.resolve(e);
1053 if (!("Svg" in ui)) return null;
1054 return renderUsageMarkView(ui, resting, alt, burnSvgSource, BURN_HEIGHT, () =>
1055 renderDesktopBrandLink($, e),
1056 );
1057}
1058
1059function renderUsage($: EngineInterface, e: RenderInput<"AbovePrompt">) {
1060 return renderUsageView(e, {
1061 load: async (event) => {
1062 const ui = $.ui.resolve(event);
1063 const state = await read($, usage);
1064 usageLayoutVariant = state.layout === "current" ? "current" : "inline";
1065 const burnState = await read($, burn);
1066 const liveNow = await $.clock.now();
1067 return { ui, state, burnState, liveNow };
1068 },
1069 settle: settleUsagePresentation,
1070 setVariant: (variant) => {
1071 usageLayoutVariant = variant;
1072 },
1073 prepareBurn: (burnState, liveNow) => {
1074 const previewActive =
1075 BURN_ANIMATION_ENABLED && burnState.previewUntil > liveNow;
1076 const burnActive =
1077 BURN_ANIMATION_ENABLED &&
1078 burnVisible &&
1079 burnState.active &&
1080 (previewActive ||
1081 (burnState.previewUntil === 0 && liveNow - burnLeaseAt < 8000));
1082 const burnCooling =
1083 BURN_ANIMATION_ENABLED &&
1084 burnCoolingStrength > 0 &&
1085 burnState.coolingAt !== null &&
1086 liveNow - burnState.coolingAt < 800;
1087 const burnKey = `${burnState.revision}:${burnState.reduced}:${burnActive}:${burnCooling}`;
1088 if (burnSvgKey !== burnKey) {
1089 if (!burnActive && burnSvgSource) usageRenderKey = undefined;
1090 const lease = previewActive
1091 ? Math.max(800, burnState.previewUntil - liveNow)
1092 : 8000;
1093 if (burnActive) burnPlaybackUntil = liveNow + lease;
1094 burnSvgSource =
1095 burnActive || burnCooling
1096 ? burnAnimationSvg(
1097 Math.max(0, liveNow - burnState.startedAt),
1098 !burnActive,
1099 lease,
1100 burnCoolingStrength,
1101 )
1102 : "";
1103 if (burnState.reduced) {
1104 burnSvgSource = burnSvgSource.replace(
1105 "<style>",
1106 "<style>.motion{display:none!important}.reduced{display:inline!important}",
1107 );
1108 }
1109 burnSvgKey = burnKey;
1110 }
1111 return {
1112 active: burnActive,
1113 revealExpires: previewActive
1114 ? burnState.previewUntil
1115 : burnLeaseAt + 8000,
1116 };
1117 },
1118 prepareDesktop: ({
1119 event,
1120 state,
1121 snapshot,
1122 preview,
1123 fixture,
1124 windows,
1125 now,
1126 liveNow,
1127 variant,
1128 }) => {
1129 const layout = usageLayout(event.props.bodyColumns * 8, variant);
1130 usageMeterWidth = layout.meterWidth;
1131 const mount = `${event.requestId}:${event.props.bodyColumns}:${event.props.maxRows}:${variant}`;
1132 if (usageMount !== undefined && usageMount !== mount)
1133 cancelUsagePresentation();
1134 usageMount = mount;
1135 usageEntrance.claim(
1136 liveNow,
1137 event.props.maxRows > 0,
1138 snapshot.status === "ready" &&
1139 windows.some((window) => window.percent !== null),
1140 );
1141 const cache: DesktopAssetCache = {
1142 key: usageRenderKey,
1143 sources: usageSources,
1144 mark: usageMark,
1145 markEntranceAt: usageMarkEntranceAt,
1146 markReplayRevision: usageMarkReplayRevision,
1147 };
1148 const assets = buildDesktopUsageAssets({
1149 mount,
1150 snapshot,
1151 preview,
1152 state,
1153 fixture,
1154 windows,
1155 now,
1156 liveNow,
1157 layout,
1158 variant,
1159 entranceStartedAt: usageEntrance.state.startedAt,
1160 roll: usageRoll,
1161 previewFill: usagePreviewFill,
1162 replayRevision: usageReplayRevision,
1163 cache,
1164 });
1165 usageSources = assets.sources;
1166 usageMark = assets.mark;
1167 usageMarkEntranceAt = assets.markEntranceAt;
1168 usageMarkReplayRevision = assets.markReplayRevision;
1169 usageRenderKey = assets.key;
1170 return { layout, sources: usageSources, mark: usageMark };
1171 },
1172 empty: (event) => {
1173 if (event.surface === "terminal")
1174 terminalHoverBinding(event.requestId, "empty");
1175 stopTerminalPulse(event.requestId);
1176 },
1177 cancel: () => {
1178 if (usageEntrance.state.phase !== "consumed") cancelUsagePresentation();
1179 },
1180 binding: (event, shape) => terminalHoverBinding(event.requestId, shape),
1181 pulse: (
1182 event,
1183 isDesktop,
1184 preview,
1185 snapshot,
1186 windows,
1187 burnState,
1188 now,
1189 warning,
1190 ) =>
1191 terminalPulse(
1192 $,
1193 event.requestId,
1194 JSON.stringify([preview, snapshot.windows, burnState.reduced]),
1195 now,
1196 !isDesktop &&
1197 !burnState.reduced &&
1198 warning !== undefined &&
1199 windows.some((window) => {
1200 const value = pace(window, now);hooks/mode.ts 6 lines1export type LiveMode = 'usage' | 'checks'
2
3export function liveMode(value: string | undefined): LiveMode {
4 return value === 'checks' ? 'checks' : 'usage'
5}
6hooks/register-pane-view.tsx 117 lines1import type {
2 EngineInterface,
3 RenderElement,
4 RenderInput,
5 RenderNode,
6} from "claude-code";
7import type { LiveCheckId, LiveCheckView, LiveStage } from "../types";
8import {
9 GROUPS,
10 markSize,
11 renderListView,
12 renderPaneView,
13} from "./register-visuals";
14import { TOUR } from "./tour";
15
16type PaneEvent = RenderInput<"Pane">;
17type PaneUi = ReturnType<EngineInterface["ui"]["resolve"]>;
18
19export function needsPublish(
20 hasChanged: boolean,
21 hasResolvedLine: boolean,
22): boolean {
23 return hasChanged || hasResolvedLine;
24}
25
26export function enteredStages(
27 before: readonly LiveCheckView[],
28 after: readonly LiveCheckView[],
29): LiveStage[] {
30 const isNew = (check: LiveCheckView) =>
31 !before.some((old) => old.id === check.id && old.stage === check.stage);
32 return [
33 ...new Set(
34 after
35 .filter((check) => !check.isHidden && isNew(check))
36 .map((check) => check.stage),
37 ),
38 ];
39}
40
41type PaneActions = {
42 centredLine: (event: PaneEvent, key: string, line: string) => RenderNode;
43 toggleGroup: (title: string) => Promise<void>;
44 toggleCard: (id: LiveCheckId) => Promise<void>;
45 toggleRow: (id: LiveCheckId) => Promise<void>;
46 act: (id: LiveCheckId, from: "card") => Promise<void>;
47 hide: (id: LiveCheckId) => Promise<void>;
48 toggleFullPrompt: (id: LiveCheckId) => Promise<void>;
49 nextTourStep: () => Promise<string | null>;
50 endTour: () => Promise<void>;
51};
52
53type PaneViewInput = {
54 ui: PaneUi;
55 event: PaneEvent;
56 checks: readonly LiveCheckView[];
57 entered: readonly LiveStage[];
58 selectedId: LiveCheckId | null;
59 openGroups: readonly string[];
60 turned: string | null;
61 fullPromptId: LiveCheckId | null;
62 armedId: LiveCheckId | null;
63 tourBeat: number | null;
64 connectionText?: string;
65 actions: PaneActions;
66};
67
68export function renderChecksPaneView({
69 ui,
70 event,
71 checks,
72 entered,
73 selectedId,
74 openGroups,
75 turned,
76 fullPromptId,
77 armedId,
78 tourBeat,
79 connectionText,
80 actions,
81}: PaneViewInput): RenderElement {
82 const shown = checks.filter((check) => !check.isHidden);
83 const selected = shown.find((check) => check.id === selectedId);
84 const hasCard = GROUPS.some(
85 (group) =>
86 (!group.isFolded || openGroups.includes(group.title)) &&
87 shown.some((check) => group.stages.includes(check.stage)),
88 );
89 const body = renderListView(
90 ui,
91 event,
92 shown,
93 shown.length < checks.length,
94 openGroups,
95 armedId,
96 selected?.id ?? null,
97 turned,
98 entered,
99 connectionText,
100 fullPromptId,
101 actions,
102 );
103 return renderPaneView(
104 ui,
105 event,
106 body,
107 hasCard,
108 markSize(event.props.bodyColumns),
109 tourBeat,
110 TOUR.length,
111 {
112 nextTourStep: actions.nextTourStep,
113 endTour: actions.endTour,
114 },
115 );
116}
117hooks/register-usage-details.tsx 61 lines1import type { EngineInterface, RenderNode } from 'claude-code';
2import type { UsageSnapshot } from './usage';
3import {
4 pace,
5 paceLabel,
6 percentage,
7 primaryWindow,
8 resetText,
9 PRIMARY_WINDOWS,
10} from './usage';
11
12type DetailsUi = ReturnType<EngineInterface['ui']['resolve']>;
13
14export function renderUsageDetailsView(
15 ui: DetailsUi,
16 snapshot: UsageSnapshot,
17 now: number,
18 fixture: boolean,
19 refresh: () => Promise<void>,
20): RenderNode {
21 const { Box, Button, Text } = ui;
22 const primary = PRIMARY_WINDOWS.map((kind) => primaryWindow(snapshot, kind));
23 const extra = snapshot.windows.filter(
24 (window) => !PRIMARY_WINDOWS.some((kind) => kind === window.kind),
25 );
26 return (
27 <Box flexDirection="column" gap={1}>
28 <Text dimColor>
29 {fixture
30 ? 'Synthetic data · native readings continue in the background'
31 : 'Last reported by Claude Code'}
32 </Text>
33 {snapshot.status !== 'ready' && (
34 <Text>
35 {snapshot.status === 'empty' ? 'Usage not available yet' : 'Usage unavailable'}
36 </Text>
37 )}
38 {snapshot.status === 'empty' && !fixture && (
39 <Text dimColor>
40 Claude has not reported usage for this session yet. Continue with your next message
41 normally; the meters update when Claude reports limits. If they stay unavailable, try
42 Refresh or check Usage in Claude. Refresh reads existing data and does not send a
43 message.
44 </Text>
45 )}
46 {snapshot.status === 'error' && snapshot.windows.length > 0 && (
47 <Text dimColor>Last known</Text>
48 )}
49 {[...primary, ...extra].map((window) => (
50 <Box key={window.kind} flexDirection="column">
51 <Text>{`${window.label}: ${percentage(window)}`}</Text>
52 <Text dimColor>{resetText(window, now)}</Text>
53 {window.resetsAt !== null && <Text dimColor>{`Reset: ${window.resetsAt}`}</Text>}
54 {pace(window, now) !== null && <Text dimColor>{paceLabel(pace(window, now)!)}</Text>}
55 </Box>
56 ))}
57 <Button key="usage-refresh" label="Refresh" onPress={refresh} />
58 </Box>
59 );
60}
61hooks/register-usage-view.tsx 398 lines1import type {
2 EngineInterface,
3 RenderElement,
4 RenderInput,
5 RenderNode,
6} from "claude-code";
7import type { LiveBurnState, LiveUsageState } from "../types";
8import { BRAND_MARK_SVG } from "./register-visuals";
9import { renderUsageMeter } from "./register-usage-meter";
10import {
11 renderCompactUsageBand,
12 renderDesktopUsageBand,
13 renderEmptyUsageBand,
14 usageBurnControls,
15} from "./register-usage-bands";
16import {
17 pace,
18 percentage,
19 primaryWindow,
20 terminalDotCount,
21 PRIMARY_WINDOWS,
22 type UsageSnapshot,
23 type UsageWindow,
24} from "./usage";
25import { markSvg, meterSvg, usageStatusSvg } from "./usage-graphics";
26import { desktopPercentage, usageLayout } from "./usage-presentation";
27import { usageFixture, previewRetryText } from "./usage-fixtures";
28
29type AbovePromptEvent = RenderInput<"AbovePrompt">;
30type UsageUi = ReturnType<EngineInterface["ui"]["resolve"]>;
31type Fixture = NonNullable<ReturnType<typeof usageFixture>>;
32type DesktopLayout = ReturnType<typeof usageLayout>;
33
34export const hoverSites = new Map<string, { shape: string; binding: string }>();
35const hoverMount = Math.random().toString(36).slice(2);
36let hoverRevision = 0;
37
38export function terminalHoverBinding(requestId: string, shape: string): string {
39 const previous = hoverSites.get(requestId);
40 if (previous?.shape === shape) return previous.binding;
41 const binding = `${hoverMount}:${++hoverRevision}`;
42 if (hoverSites.size >= 8 && !hoverSites.has(requestId))
43 hoverSites.delete(hoverSites.keys().next().value!);
44 hoverSites.set(requestId, { shape, binding });
45 return binding;
46}
47
48export type DesktopAssetCache = {
49 key: string | undefined;
50 sources: string[];
51 mark: string;
52 markEntranceAt: number | null | undefined;
53 markReplayRevision: number;
54};
55
56export function buildDesktopUsageAssets(input: {
57 mount: string;
58 snapshot: UsageSnapshot;
59 preview?: LiveUsageState["preview"];
60 state: LiveUsageState;
61 fixture?: Fixture;
62 windows: UsageWindow[];
63 now: number;
64 liveNow: number;
65 layout: DesktopLayout;
66 variant: "inline" | "current";
67 entranceStartedAt: number | null;
68 roll: { previous: UsageSnapshot; startedAt: number } | undefined;
69 previewFill: { from: number[]; to: number[]; startedAt: number } | undefined;
70 replayRevision: number;
71 cache: DesktopAssetCache;
72}): DesktopAssetCache {
73 const {
74 mount,
75 snapshot,
76 preview,
77 state,
78 fixture,
79 windows,
80 now,
81 liveNow,
82 layout,
83 variant,
84 entranceStartedAt,
85 roll,
86 previewFill,
87 replayRevision,
88 cache,
89 } = input;
90 const key = JSON.stringify([
91 mount,
92 snapshot,
93 preview?.id,
94 state.previewReveal,
95 replayRevision,
96 ]);
97 if (cache.key === key) return cache;
98 const elapsed =
99 entranceStartedAt === null
100 ? undefined
101 : Math.max(0, liveNow - entranceStartedAt);
102 const rollElapsed =
103 roll === undefined ? undefined : Math.max(0, liveNow - roll.startedAt);
104 const sources = windows.map((window, index) =>
105 meterSvg(
106 window,
107 layout.meterWidth,
108 now,
109 fixture !== undefined && state.previewReveal === "show",
110 true,
111 {
112 layout: variant,
113 resetPreview: fixture ? state.previewReveal : undefined,
114 warning: fixture?.warning,
115 previousFillEnd:
116 fixture && previewFill ? previewFill.from[index] : undefined,
117 fillElapsed:
118 fixture && previewFill ? liveNow - previewFill.startedAt : undefined,
119 entranceElapsed: elapsed,
120 meterIndex: index as 0 | 1,
121 previousPercent:
122 roll === undefined
123 ? undefined
124 : desktopPercentage(
125 primaryWindow(
126 roll.previous,
127 window.kind as "five_hour" | "seven_day",
128 ),
129 ),
130 rollElapsed,
131 },
132 ).replace("<svg ", `<svg data-entrance="${replayRevision}" `),
133 );
134 let mark = cache.mark;
135 let markEntranceAt = cache.markEntranceAt;
136 let markReplayRevision = cache.markReplayRevision;
137 if (
138 !mark ||
139 (elapsed !== undefined &&
140 (markEntranceAt !== entranceStartedAt ||
141 markReplayRevision !== replayRevision))
142 ) {
143 mark = markSvg(BRAND_MARK_SVG, elapsed).replace(
144 "<svg ",
145 `<svg data-entrance="${replayRevision}" `,
146 );
147 markEntranceAt = entranceStartedAt;
148 markReplayRevision = replayRevision;
149 }
150 return { key, sources, mark, markEntranceAt, markReplayRevision };
151}
152
153type UsageFrame = {
154 ui: UsageUi;
155 state: LiveUsageState;
156 burnState: LiveBurnState;
157 liveNow: number;
158};
159
160type DesktopUsageInput = {
161 event: AbovePromptEvent;
162 state: LiveUsageState;
163 snapshot: UsageSnapshot;
164 preview?: LiveUsageState["preview"];
165 fixture?: Fixture;
166 windows: UsageWindow[];
167 now: number;
168 liveNow: number;
169 variant: "inline" | "current";
170 burnActive: boolean;
171 burnRevealExpires: number;
172};
173
174type UsageRuntime = {
175 load: (event: AbovePromptEvent) => Promise<UsageFrame>;
176 settle: (now: number) => void;
177 setVariant: (variant: "inline" | "current") => void;
178 prepareBurn: (
179 state: LiveBurnState,
180 now: number,
181 ) => {
182 active: boolean;
183 revealExpires: number;
184 };
185 prepareDesktop: (input: DesktopUsageInput) => {
186 layout: DesktopLayout;
187 sources: string[];
188 mark: string;
189 };
190 empty: (event: AbovePromptEvent) => void;
191 cancel: () => void;
192 binding: (event: AbovePromptEvent, shape: string) => string;
193 pulse: (
194 event: AbovePromptEvent,
195 isDesktop: boolean,
196 preview: LiveUsageState["preview"],
197 snapshot: UsageSnapshot,
198 windows: UsageWindow[],
199 state: LiveBurnState,
200 now: number,
201 warning: Fixture["warning"] | undefined,
202 ) => boolean;
203 renderMark: (
204 event: AbovePromptEvent,
205 source: string,
206 alt: string,
207 ) => RenderNode | null;
208 renderTerminalMark: (
209 event: AbovePromptEvent,
210 state: LiveBurnState,
211 now: number,
212 active: boolean,
213 ) => RenderNode;
214 refreshPreview: (id: string) => void;
215 reveal: (kind: string) => Promise<void>;
216};
217
218export async function renderUsageView(
219 event: AbovePromptEvent,
220 runtime: UsageRuntime,
221): Promise<RenderElement> {
222 const { ui, state, burnState, liveNow } = await runtime.load(event);
223 const variant = state.layout === "current" ? "current" : "inline";
224 runtime.setVariant(variant);
225 const preview = state.preview;
226 const fixture = preview
227 ? usageFixture(preview.id, preview.capturedAt)
228 : undefined;
229 const snapshot = fixture?.snapshot ?? state.snapshot;
230 const now = fixture ? preview!.capturedAt : liveNow;
231 runtime.settle(liveNow);
232 const windows = PRIMARY_WINDOWS.map((kind) => primaryWindow(snapshot, kind));
233 const isDesktop = event.surface === "desktop" && "Svg" in ui;
234 const { active: burnActive, revealExpires: burnRevealExpires } =
235 runtime.prepareBurn(burnState, liveNow);
236 const hasReading = windows.some((window) => window.percent !== null);
237 const pending = fixture?.state === "checking";
238 const status = fixture?.state
239 ? pending
240 ? "Checking usage..."
241 : "Usage unavailable"
242 : snapshot.status === "error"
243 ? "Usage unavailable"
244 : "Usage not available yet";
245 if (!hasReading && snapshot.status !== "ready") {
246 runtime.empty(event);
247 const hint =
248 !fixture?.state && snapshot.status === "empty"
249 ? "Try send a message first"
250 : undefined;
251 const retryHint =
252 fixture?.retryAt === undefined
253 ? undefined
254 : `${previewRetryText(fixture.retryAt, now)} · Refresh checks session.`;
255 const canRefresh =
256 fixture?.state !== undefined && !pending && event.props.bodyColumns >= 24;
257 return renderEmptyUsageBand({
258 ui,
259 event,
260 isDesktop,
261 status,
262 hint,
263 retryHint,
264 canRefresh,
265 statusGraphic: usageStatusSvg(status, hint, event.props.bodyColumns < 60),
266 renderMark: () => runtime.renderMark(event, BRAND_MARK_SVG, "antiburn"),
267 renderTerminalMark: () =>
268 runtime.renderTerminalMark(event, burnState, liveNow, burnActive),
269 onRefresh: () => runtime.refreshPreview("state-checking"),
270 });
271 }
272 const columns = event.props.bodyColumns;
273 if (isDesktop && columns >= 24 && "Svg" in ui) {
274 const { layout, sources, mark } = runtime.prepareDesktop({
275 event,
276 state,
277 snapshot,
278 preview,
279 fixture,
280 windows,
281 now,
282 liveNow,
283 variant,
284 burnActive,
285 burnRevealExpires,
286 });
287 const markAlt =
288 burnState.previewUntil > liveNow
289 ? "antiburn · output activity preview"
290 : burnActive
291 ? "antiburn · high recent output activity"
292 : "antiburn";
293 return renderDesktopUsageBand({
294 ui,
295 windows,
296 layout,
297 variant,
298 usageSources: sources,
299 now,
300 status: snapshot.status,
301 burnActive,
302 burnRevealExpires,
303 usageMark: mark,
304 markAlt,
305 renderMark: (source, alt) => runtime.renderMark(event, source, alt),
306 usageBurnControls,
307 });
308 }
309 runtime.cancel();
310 const content = Math.max(1, columns - (isDesktop ? 3 : 12));
311 const inlineColumns = Math.max(1, Math.floor((content - 3) / 2));
312 const isInline =
313 columns >= 80 &&
314 windows.every(
315 (window) =>
316 window.label.length + percentage(window).length + 2 <= inlineColumns,
317 );
318 const meterColumns = isInline ? inlineColumns : content;
319 const compact = variant === "inline";
320 const geometry = (window: UsageWindow) => {
321 const trackColumns = compact
322 ? Math.max(
323 0,
324 meterColumns - window.label.length - percentage(window).length - 2,
325 )
326 : meterColumns;
327 const count = isDesktop ? 0 : terminalDotCount(trackColumns);
328 const value = pace(window, now);
329 const markerCell =
330 value === null || count < 2
331 ? null
332 : Math.max(
333 1,
334 Math.min(count * 2 - 3, Math.round(value * (count - 1)) * 2 - 1),
335 );
336 return { count, value, markerCell };
337 };
338 const hoverBinding =
339 event.surface === "terminal"
340 ? runtime.binding(
341 event,
342 JSON.stringify([
343 columns,
344 variant,
345 snapshot.status,
346 windows.map((window) => [
347 window.resetsAt,
348 percentage(window).length,
349 geometry(window).markerCell,
350 ]),
351 ]),
352 )
353 : "";
354 const revealed =
355 event.surface === "terminal" &&
356 state.hovered?.requestId === event.requestId &&
357 state.hovered.binding === hoverBinding
358 ? state.hovered.target
359 : state.revealed;
360 const pulse = runtime.pulse(
361 event,
362 isDesktop,
363 preview,
364 snapshot,
365 windows,
366 burnState,
367 liveNow,
368 fixture?.warning,
369 );
370 const renderMeter = (window: UsageWindow) =>
371 renderUsageMeter({
372 ui,
373 window,
374 now,
375 isDesktop,
376 revealed,
377 geometry,
378 fixtureWarning: fixture?.warning,
379 pulse,
380 meterColumns,
381 compact,
382 hoverBinding,
383 reveal: runtime.reveal,
384 });
385 return renderCompactUsageBand({
386 ui,
387 isDesktop,
388 isInline,
389 snapshotStatus: snapshot.status,
390 windows,
391 renderMark: () =>
392 isDesktop
393 ? runtime.renderMark(event, BRAND_MARK_SVG, "antiburn")
394 : runtime.renderTerminalMark(event, burnState, liveNow, burnActive),
395 renderMeter,
396 });
397}
398hooks/register-debug-view.tsx 286 lines1import type { EngineInterface, RenderInput, RenderNode } from "claude-code";
2import type { LiveBurnState, LiveUsageState } from "../types";
3import type { LiveMode } from "./mode";
4import { BURN_ANIMATION_ENABLED, BURN_POLICY } from "./burn";
5import { CARD_BACKGROUND } from "./register-visuals";
6import { USAGE_FIXTURES, type usageFixture } from "./usage-fixtures";
7
8type DebugUi = ReturnType<EngineInterface["ui"]["resolve"]>;
9type DebugEvent = RenderInput<"Pane">;
10type DebugFixture = NonNullable<ReturnType<typeof usageFixture>>;
11type DebugActions = {
12 setDebugMode: (mode: LiveMode) => Promise<void>;
13 openPane: () => Promise<void>;
14 showCheckDemo: () => Promise<void>;
15 startTour: () => Promise<void>;
16 endDemo: () => Promise<void>;
17 previewBurn: (reduced?: boolean) => Promise<void>;
18 stopBurnPreview: () => Promise<void>;
19 refreshBurnStats: () => Promise<void>;
20 selectUsageLayout: (layout: "inline" | "current") => Promise<void>;
21 selectUsagePreview: (id: string | null) => Promise<void>;
22 selectUsageState: (
23 state: "idle" | "checking" | "retry" | "success" | "stale",
24 ) => Promise<void>;
25 replayUsageEntrance: () => Promise<void>;
26 startUsageSequence: () => Promise<void>;
27 stopUsageSequence: () => Promise<void>;
28 toggleSyntheticLabels: () => Promise<void>;
29 inspectUsageAnimation: (action: "show" | "hide") => Promise<void>;
30};
31
32export function renderUsageDebugView(
33 ui: DebugUi,
34 e: DebugEvent,
35 state: LiveUsageState,
36 preview: LiveUsageState["preview"],
37 fixture: DebugFixture | undefined,
38 burnState: LiveBurnState,
39 checksEnabled: boolean,
40 usageDetails: RenderNode,
41 actions: DebugActions,
42) {
43 const { Box, Button, Text } = ui;
44 const cardProps = {
45 flexDirection: "column",
46 gap: 1,
47 padding: 1,
48 borderStyle: "round",
49 backgroundColor: e.surface === "desktop" ? CARD_BACKGROUND : undefined,
50 borderColor: e.surface === "desktop" ? CARD_BACKGROUND : "subtle",
51 } as const;
52 return (
53 <Box flexDirection="column" gap={1}>
54 <Text bold>Developer debug</Text>
55 <Text>
56 {checksEnabled
57 ? "Mode 1 · Usage limits + live checks"
58 : "Mode 0 · Usage limits"}
59 </Text>
60 <Box key="debug-checks-card" {...cardProps}>
61 <Text bold>Checks</Text>
62 <Text dimColor>
63 Mode changes apply to this session. Reload restores
64 ANTIBURN_LIVE_MODE. Checks start fresh after each change.
65 </Text>
66 <Button
67 key="usage-debug-mode"
68 label={
69 checksEnabled
70 ? "Switch to Mode 0 · Usage limits"
71 : "Switch to Mode 1 · Usage limits + live checks"
72 }
73 onPress={() =>
74 actions.setDebugMode(checksEnabled ? "usage" : "checks")
75 }
76 />
77 {checksEnabled && (
78 <Button
79 key="debug-checks"
80 label="View live checks"
81 onPress={() => actions.openPane()}
82 />
83 )}
84 {checksEnabled && (
85 <Button
86 key="debug-checks-demo"
87 label="Show sample checks"
88 onPress={() => actions.showCheckDemo()}
89 />
90 )}
91 {checksEnabled && (
92 <Button
93 key="debug-checks-tour"
94 label="Start checks tour"
95 onPress={() => actions.startTour()}
96 />
97 )}
98 {checksEnabled && (
99 <Button
100 key="debug-checks-live"
101 label="Return to live checks"
102 onPress={() => actions.endDemo()}
103 />
104 )}
105 </Box>
106 <Box key="debug-burn-card" {...cardProps}>
107 <Text bold>Rooted flame · output activity</Text>
108 {BURN_ANIMATION_ENABLED ? (
109 <Box flexDirection="column" gap={1}>
110 <Text dimColor>
111 Recent streamed text, thinking and tool arguments estimate output
112 activity. Completed responses provide measured request averages.
113 Subscription quota does not trigger this effect.
114 </Text>
115 <Text>{`Signal: ${burnState.source} · estimate ${Math.round(burnState.estimatedTps)} tokens/s · completed response ${burnState.measuredTps === null ? "unavailable" : Math.round(burnState.measuredTps) + " tokens/s"}`}</Text>
116 <Text
117 dimColor
118 >{`Experimental heuristic: ${BURN_POLICY.entryTps} estimated tokens/s. Native terminal shows shaded blocks and a heat ripple behind the wordmark. Preview stops after 15 seconds.`}</Text>
119 <Button
120 key="burn-preview"
121 label="Preview rooted flame · 15s"
122 onPress={() => actions.previewBurn()}
123 />
124 <Button
125 key="burn-preview-reduced"
126 label="Preview reduced motion · 15s"
127 onPress={() => actions.previewBurn(true)}
128 />
129 <Button
130 key="burn-preview-stop"
131 label="Return to measured activity"
132 onPress={() => actions.stopBurnPreview()}
133 />
134 <Button
135 key="burn-stats"
136 label="Refresh activity measurements"
137 onPress={() => actions.refreshBurnStats()}
138 />
139 </Box>
140 ) : (
141 <Text key="burn-unavailable" dimColor>
142 Burn previews and activity tracking are unavailable.
143 </Text>
144 )}
145 </Box>
146 <Box key="debug-usage-card" {...cardProps}>
147 <Text bold>Usage</Text>
148 <Text>
149 {state.layout === "current"
150 ? "Layout: Current · captions above meters"
151 : "Layout: Label reveal · inline (default)"}
152 </Text>
153 <Button
154 key="usage-layout-inline"
155 label="inline (default)"
156 onPress={() => actions.selectUsageLayout("inline")}
157 />
158 <Button
159 key="usage-layout-current"
160 label="labels above meters"
161 onPress={() => actions.selectUsageLayout("current")}
162 />
163 {usageDetails}
164 <Text dimColor>
165 {fixture
166 ? "Synthetic data · native readings continue in the background"
167 : "Live usage data"}
168 </Text>
169 {fixture && <Text>{`Preview: ${preview!.id}`}</Text>}
170 {fixture && (
171 <Button
172 key="usage-preview-live"
173 label="Return to live data"
174 onPress={() => actions.selectUsagePreview(null)}
175 />
176 )}
177 <Text bold>Usage examples</Text>
178 <Text dimColor>Preview account usage states</Text>
179 <Box flexDirection="row" gap={1}>
180 {(["idle", "checking", "retry", "success", "stale"] as const).map(
181 (status) => (
182 <Button
183 key={`usage-state-${status}`}
184 label={status[0]!.toUpperCase() + status.slice(1)}
185 onPress={() => actions.selectUsageState(status)}
186 />
187 ),
188 )}
189 </Box>
190 <Button
191 key="usage-preview-entrance"
192 label="Replay entrance"
193 onPress={() => actions.replayUsageEntrance()}
194 />
195 <Text dimColor>
196 Replays the current reading; uses Low usage when no usable reading
197 exists.
198 </Text>
199 <Box flexDirection="row" gap={1}>
200 <Button
201 key="usage-sequence-start"
202 label="Play all usage examples"
203 onPress={() => actions.startUsageSequence()}
204 />
205 <Button
206 key="usage-synthetic-labels"
207 plain
208 label={`${state.showSyntheticLabels ? "☑" : "☐"} Show synthetic labels`}
209 onPress={actions.toggleSyntheticLabels}
210 />
211 </Box>
212 {state.sequence && <Text>{`Playing: ${state.sequence}`}</Text>}
213 {state.sequence && (
214 <Button
215 key="usage-sequence-stop"
216 label="Stop sequence"
217 onPress={() => actions.stopUsageSequence()}
218 />
219 )}
220 <Text dimColor>
221 {BURN_ANIMATION_ENABLED
222 ? "Runs once: entrance, all examples, reset and pace reveal/hide, then Ember catch at high usage. Near limit holds for six pulse cycles."
223 : "Runs once: entrance, all examples, and reset and pace reveal/hide. Near limit holds for six pulse cycles. Burn previews are unavailable."}
224 </Text>
225 <Text dimColor>
226 Warning styles are previews only. Pulse runs six cycles; reduced
227 motion stays static.
228 </Text>
229 {USAGE_FIXTURES.map((item) => (
230 <Button
231 key={`usage-preview-${item.id}`}
232 label={item.label}
233 onPress={() => actions.selectUsagePreview(item.id)}
234 />
235 ))}
236 {fixture && (
237 <Box flexDirection="column">
238 <Text bold>Animation inspector</Text>
239 <Text dimColor>
240 Desktop uses SVG motion; terminal red dots pulse in place. Hover a
241 label to test delayed reveal. Keyboard/tap reveal stays immediate.
242 </Text>
243 <Text>
244 Mark: 0–240ms · meters: 100–460ms / 240–600ms · fill: 600–960ms
245 </Text>
246 <Text>
247 Example fill: 360ms · cubic-bezier(.23,1,.32,1) · retargets from
248 the current frame
249 </Text>
250 <Text>
251 Digits: 260ms + 12ms per column, capped at 24ms · choose 9 → 10 or
252 99 → 100
253 </Text>
254 <Text>
255 Reset: 300ms hover dwell · 280ms reveal / 180ms hide ·
256 cubic-bezier(.32,.72,0,1)
257 </Text>
258 <Text>
259 Pace: 300ms hover dwell · 125ms contrast ·
260 cubic-bezier(.23,1,.32,1)
261 </Text>
262 <Text>
263 Warning: 1600ms × 6 cycles · opacity 65–100% · subtle glow ·
264 reduced motion static
265 </Text>
266 <Text>
267 Reduced motion: 80ms entrance/reveal opacity · no rise, fill sweep
268 or digit roll
269 </Text>
270 <Button
271 key="usage-preview-reveal"
272 label="Replay reset reveal"
273 onPress={() => actions.inspectUsageAnimation("show")}
274 />
275 <Button
276 key="usage-preview-hide"
277 label="Replay reset hide"
278 onPress={() => actions.inspectUsageAnimation("hide")}
279 />
280 </Box>
281 )}
282 </Box>
283 </Box>
284 );
285}
286hooks/register-usage-bands.tsx 437 lines1import type {
2 EngineInterface,
3 RenderElement,
4 RenderInput,
5 RenderNode,
6} from "claude-code";
7import type { UsageWindow } from "./usage";
8import {
9 meterDescription,
10 meterLayer,
11 secondaryTextSvg,
12} from "./usage-graphics";
13import { PRESENTATION, usageLayout } from "./usage-presentation";
14import { burnControlsSvg } from "./burn-controls";
15
16let burnControlsKey = "";
17let burnControlsSource = "";
18
19export function resetUsageBurnControls(): void {
20 burnControlsKey = "";
21 burnControlsSource = "";
22}
23
24export function usageBurnControls(
25 layout: ReturnType<typeof usageLayout>,
26 sources: readonly string[],
27 expiresAt: number,
28 now: number,
29): string {
30 const key = JSON.stringify([layout, sources, expiresAt]);
31 if (key !== burnControlsKey) {
32 burnControlsSource = burnControlsSvg(
33 layout,
34 sources,
35 Math.max(0, expiresAt - now),
36 );
37 burnControlsKey = key;
38 }
39 return burnControlsSource;
40}
41
42type UsageUi = ReturnType<EngineInterface["ui"]["resolve"]>;
43type AbovePromptEvent = RenderInput<"AbovePrompt">;
44
45type EmptyBandInput = {
46 ui: UsageUi;
47 event: AbovePromptEvent;
48 isDesktop: boolean;
49 status: string;
50 hint?: string;
51 retryHint?: string;
52 canRefresh: boolean;
53 statusGraphic: {
54 source: string;
55 description: string;
56 width: number;
57 height: number;
58 };
59 renderMark: () => RenderNode | null;
60 renderTerminalMark: () => RenderNode;
61 onRefresh: () => void;
62};
63
64export function renderEmptyUsageBand({
65 ui,
66 event,
67 isDesktop,
68 status,
69 hint,
70 retryHint,
71 canRefresh,
72 statusGraphic,
73 renderMark,
74 renderTerminalMark,
75 onRefresh,
76}: EmptyBandInput): RenderElement {
77 const { Box, Button, Text } = ui;
78 const band = (
79 <Box
80 key="usage-band"
81 position="relative"
82 flexDirection="row"
83 alignItems={isDesktop ? "center" : "flex-end"}
84 gap={isDesktop ? 0 : 1}
85 height={
86 isDesktop && event.props.bodyColumns >= 24
87 ? statusGraphic.height / 16
88 : undefined
89 }
90 >
91 {isDesktop && <Box width={PRESENTATION.brandInset / 8} flexShrink={0} />}
92 {isDesktop ? renderMark() : renderTerminalMark()}
93 {isDesktop && <Box width={PRESENTATION.brandGap / 8} flexShrink={0} />}
94 {isDesktop && "Svg" in ui ? (
95 <ui.Svg
96 key="usage-status"
97 source={statusGraphic.source}
98 alt={statusGraphic.description}
99 width={Math.min(
100 statusGraphic.width,
101 Math.max(
102 1,
103 event.props.bodyColumns * 8 -
104 PRESENTATION.brandInset -
105 14 -
106 PRESENTATION.brandGap -
107 (canRefresh ? 32 : 0),
108 ),
109 )}
110 height={statusGraphic.height}
111 />
112 ) : (
113 <Box
114 width={Math.max(1, event.props.bodyColumns - (canRefresh ? 14 : 12))}
115 flexDirection="row"
116 gap={1}
117 >
118 {event.surface === "terminal" &&
119 "Client" in ui &&
120 status === "Checking usage..." ? (
121 <ui.Client
122 key="usage-checking"
123 module="./checking-status.ts"
124 props={null}
125 />
126 ) : (
127 <Text key="usage-status">{status}</Text>
128 )}
129 {hint && <Text dimColor>{hint}</Text>}
130 </Box>
131 )}
132 {canRefresh && (
133 <Button
134 key="usage-state-refresh"
135 plain
136 dimColor
137 label={isDesktop ? "↻" : "[↻]"}
138 onPress={onRefresh}
139 />
140 )}
141 </Box>
142 );
143 if (!retryHint) return band;
144 const rows = Math.max(2, Math.floor(event.props.maxRows - 2));
145 const secondary = secondaryTextSvg(
146 [retryHint],
147 event.props.bodyColumns * 8,
148 rows,
149 );
150 return (
151 <Box flexDirection="column" width={event.props.bodyColumns}>
152 {band}
153 {isDesktop && "Svg" in ui ? (
154 <ui.Svg
155 source={secondary.source}
156 alt={secondary.description}
157 width={secondary.width}
158 height={secondary.height}
159 />
160 ) : (
161 <Box
162 key="usage-retry-copy"
163 width={event.props.bodyColumns}
164 overflow="hidden"
165 >
166 <Text dimColor wrap="wrap">
167 {retryHint}
168 </Text>
169 </Box>
170 )}
171 </Box>
172 );
173}
174
175type DesktopBandInput = {
176 ui: Extract<UsageUi, { Svg: unknown }>;
177 windows: readonly UsageWindow[];
178 layout: ReturnType<typeof usageLayout>;
179 variant: "inline" | "current";
180 usageSources: readonly string[];
181 now: number;
182 status: "ready" | "error" | "empty";
183 burnActive: boolean;
184 burnRevealExpires: number;
185 usageMark: string;
186 markAlt: string;
187 renderMark: (source: string, alt: string) => RenderNode | null;
188 usageBurnControls: (
189 layout: ReturnType<typeof usageLayout>,
190 sources: readonly string[],
191 expiresAt: number,
192 now: number,
193 ) => string;
194};
195
196export function renderDesktopUsageBand({
197 ui,
198 windows,
199 layout,
200 variant,
201 usageSources,
202 now,
203 status,
204 burnActive,
205 burnRevealExpires,
206 usageMark,
207 markAlt,
208 renderMark,
209 usageBurnControls,
210}: DesktopBandInput): RenderElement {
211 const { Box } = ui;
212 return (
213 <Box
214 key="usage-band"
215 position="relative"
216 flexDirection="row"
217 alignItems="center"
218 >
219 <Box width={PRESENTATION.brandInset / 8} flexShrink={0} />
220 {renderMark(usageMark, markAlt)}
221 <Box width={PRESENTATION.brandGap / 8} flexShrink={0} />
222 <Box
223 flexDirection={layout.inline ? "row" : "column"}
224 gap={layout.inline ? 3 : layout.stackedGap / 16}
225 >
226 {windows.map((window, index) => (
227 <Box
228 key={`meter-${window.kind}`}
229 position="relative"
230 height={variant === "inline" ? layout.meterHeight / 16 : 1.25}
231 alignItems="center"
232 >
233 <ui.Svg
234 key={`meter-base-${window.kind}`}
235 source={meterLayer(usageSources[index]!, "base")}
236 alt={`${meterDescription(window, now, true)}${status === "error" ? ". Usage unavailable · Last known" : ""}`}
237 width={layout.meterWidth}
238 height={layout.meterHeight}
239 />
240 {!burnActive && (
241 <Box
242 position="absolute"
243 top={0}
244 bottom={0}
245 left={0}
246 alignItems="center"
247 >
248 <ui.Svg
249 key={`meter-interaction-${window.kind}`}
250 source={meterLayer(usageSources[index]!, "interaction")}
251 alt={`${window.label} reset and pace controls`}
252 width={layout.meterWidth}
253 height={layout.meterHeight}
254 isInteractive
255 />
256 </Box>
257 )}
258 </Box>
259 ))}
260 </Box>
261 <Box width={PRESENTATION.brandInset / 8} flexShrink={0} />
262 {burnActive && (
263 <Box position="absolute" left={layout.meters[0]!.x / 8} top={0}>
264 <ui.Svg
265 key="burn-controls"
266 source={usageBurnControls(
267 layout,
268 usageSources,
269 burnRevealExpires,
270 now,
271 )}
272 alt="Usage reset and pace controls"
273 width={layout.width - layout.meters[0]!.x}
274 height={layout.height}
275 isInteractive
276 />
277 </Box>
278 )}
279 </Box>
280 );
281}
282
283type CompactBandInput = {
284 ui: UsageUi;
285 isDesktop: boolean;
286 isInline: boolean;
287 snapshotStatus: "ready" | "error" | "empty";
288 renderMark: () => RenderNode | null;
289 windows: readonly UsageWindow[];
290 renderMeter: (window: UsageWindow) => RenderNode;
291};
292
293export function renderCompactUsageBand({
294 ui,
295 isDesktop,
296 isInline,
297 snapshotStatus,
298 windows,
299 renderMark,
300 renderMeter,
301}: CompactBandInput): RenderElement {
302 const { Box, Text } = ui;
303 const mark = renderMark();
304 const meters = (
305 <Box
306 key="usage-meters"
307 flexDirection={isInline ? "row" : "column"}
308 gap={isInline ? 3 : 0}
309 >
310 {windows.map(renderMeter)}
311 </Box>
312 );
313 const brandGap = isDesktop && (
314 <Box width={PRESENTATION.brandGap / 8} flexShrink={0} />
315 );
316 if (snapshotStatus === "error") {
317 return (
318 <Box key="usage-band" flexDirection="column">
319 <Box
320 flexDirection="row"
321 alignItems={isDesktop ? "center" : "flex-end"}
322 gap={isDesktop ? 0 : 1}
323 >
324 {isDesktop && (
325 <Box width={PRESENTATION.brandInset / 8} flexShrink={0} />
326 )}
327 {mark}
328 {brandGap}
329 <Text key="usage-status">Usage unavailable · Last known</Text>
330 </Box>
331 <Box marginLeft={isDesktop ? 4 : 3}>{meters}</Box>
332 </Box>
333 );
334 }
335 return (
336 <Box
337 key="usage-band"
338 position="relative"
339 flexDirection="row"
340 alignItems={isDesktop ? "center" : "flex-end"}
341 gap={isDesktop ? 0 : 1}
342 >
343 {isDesktop && <Box width={PRESENTATION.brandInset / 8} flexShrink={0} />}
344 {mark}
345 {brandGap}
346 {meters}
347 </Box>
348 );
349}
350
351type UsageCompositionInput = {
352 ui: UsageUi;
353 event: AbovePromptEvent;
354 checkStrip: RenderNode | null;
355 syntheticLabel?: string;
356 usage: RenderElement;
357};
358
359export function renderUsageComposition({
360 ui,
361 event,
362 checkStrip,
363 syntheticLabel,
364 usage,
365}: UsageCompositionInput): RenderElement {
366 const { Box, Text } = ui;
367 const separatorWidth = Math.max(1, event.props.bodyColumns * 8 - 16);
368 return (
369 <Box
370 key="usage-spacing"
371 flexDirection="column"
372 marginTop={
373 event.props.maxRows >= 8 && event.props.bodyColumns >= 24
374 ? event.surface === "desktop"
375 ? 0.25
376 : 1
377 : 0
378 }
379 gap={checkStrip && event.surface === "desktop" ? 0.5 : 1}
380 >
381 {checkStrip}
382 {checkStrip && (
383 <Box key="checks-usage-separator" marginX={1}>
384 {event.surface === "desktop" && "Svg" in ui ? (
385 <ui.Svg
386 source={`<svg xmlns="http://www.w3.org/2000/svg" width="${separatorWidth}" height="16" viewBox="0 0 ${separatorWidth} 16"><style>path{stroke:#000;stroke-opacity:.06}@media(prefers-color-scheme:dark){path{stroke:#fff;stroke-opacity:.08}}</style><path d="M0 8H${separatorWidth}" stroke-width="1"/></svg>`}
387 width={separatorWidth}
388 height={16}
389 alt="Checks and usage separator"
390 />
391 ) : (
392 <Text dimColor>
393 {"─".repeat(Math.max(1, event.props.bodyColumns - 4))}
394 </Text>
395 )}
396 </Box>
397 )}
398 {syntheticLabel && <Text dimColor>{syntheticLabel}</Text>}
399 {usage}
400 </Box>
401 );
402}
403
404type SessionModeInput = {
405 ui: UsageUi;
406 event: RenderInput<"SessionMode">;
407 label: string;
408 mark?: { color: string; glyph: string };
409 openPane: () => Promise<void>;
410};
411
412export function renderSessionModeView({
413 ui,
414 event,
415 label,
416 mark: chipMark,
417 openPane,
418}: SessionModeInput): RenderElement {
419 const { Box, Button, Text } = ui;
420 const mark = chipMark !== undefined && (
421 <Text key="chip-mark" color={chipMark.color}>
422 {chipMark.glyph}
423 </Text>
424 );
425 const isTerminal = event.surface === "terminal";
426 return (
427 <Box flexDirection="row" gap={1} alignItems="center">
428 {event.props.modes.length > 0 && (
429 <Text dimColor>{event.props.modes.join(" & ")}</Text>
430 )}
431 {!isTerminal && mark}
432 <Button key="chip" plain dimColor label={label} onPress={openPane} />
433 {isTerminal && mark}
434 </Box>
435 );
436}
437hooks/register-visuals.tsx 1864 lines1import type { EngineInterface, RenderInput, RenderNode } from 'claude-code';
2import type { LiveCheckId, LiveCheckView, LiveStage } from '../types';
3type PaneEvent = RenderInput<'Pane'>;
4import { liveChecks } from './checks';
5import { APP_URL } from './brand';
6import { PRESENTATION } from './usage-presentation';
7import { terminalWordmark } from './terminal-flame';
8
9// A theme has no muted warning background, and the warning key fills the band
10// too brightly to read. Each band sets its background and its text color as one
11// pair, so the contrast stays the same in a light or a dark theme.
12// The antiburn brand token (apps/desktop/design.md), dark value. A mod cannot
13// read the terminal theme, so the terminal uses one value.
14export const BRAND = '#FF5914';
15// The antiburn app's check language (apps/desktop/design.md): failure wording takes
16// burn-check-failure-text, light hsl(18 100% 36.4%) and dark hsl(18 100% 68%), in semibold monospace.
17// A tint takes burn-check-failure-fill, which is the brand value.
18// A Text color cannot follow the theme, so the desktop draws this wording as SVG.
19export const FAILURE_TEXT_DARK = '#FF8D5C';
20export const FAILURE_THEMED =
21 '<style>.f{fill:#BA3800}.t{fill:#FF5914;fill-opacity:.08}' +
22 `@media (prefers-color-scheme:dark){.f{fill:${FAILURE_TEXT_DARK}}.t{fill-opacity:.12}}</style>`;
23export const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace';
24// A resolved outcome takes burn-check-pass-fill, the cyan of the Resolved icon:
25// light hsl(191.5 83% 36.8%), dark hsl(192 63% 47.6%). Passed counts use it at regular weight.
26// The terminal cannot read the theme, so it uses the dark value, as it does for the brand.
27export const PASS_FILL_DARK = '#2DA7C6';
28// Terminal text has no band behind it and the theme can be light, so it takes the light value,
29// 3.8:1 on white and 4.9:1 on a dark terminal. A box keeps the dark value, with its own ink.
30export const PASS_TEXT_TERMINAL = '#108EAC';
31// The count tint: 12% (light) or 16% (dark) of the brand, mixed onto the strip surface as one solid value.
32// Light #9A3412 on #F2DCCD is 5.5:1. Dark #FF8D5C on #51372A is 4.8:1. The primary button stays the strongest mark.
33export const COUNT_TINT_TERMINAL = '#4D2F23';
34export const PASS_THEMED = `<style>.p{fill:#108EAC}@media (prefers-color-scheme:dark){.p{fill:${PASS_FILL_DARK}}}</style>`;
35
36// The warning band is a neutral grey, so the brand and the check orange are its only colours.
37// The name takes the brand in bold, 4.8:1 on the band. The count sits in a quiet box: the dark failure
38// text on an 18% brand tint mixed onto the band, 5.3:1. A terminal cannot blend, so the tint is a solid value.
39// A mod cannot read the terminal theme, so the band is the same on a light theme.
40// A 256-colour terminal rounds a tinted colour to its colour cube, which turns a dark warm band into
41// #5F5F5F. The band greys are on the 256-colour grey ramp, so they stay the same there.
42export const BAND = {
43 warning: {
44 background: '#262626',
45 text: '#E4E4E4',
46 // The metric is one step quieter than the check name, 6.4:1 on the band.
47 metric: '#A8A8A8',
48 mark: FAILURE_TEXT_DARK,
49 label: BRAND,
50 box: COUNT_TINT_TERMINAL,
51 ink: FAILURE_TEXT_DARK,
52 },
53 // The resolved band is the same grey. Its outcome takes the pass cyan of the Resolved icon, 5.4:1 on the band.
54 resolved: {
55 background: '#262626',
56 text: '#E4E4E4',
57 mark: PASS_FILL_DARK,
58 label: BRAND,
59 },
60} as const;
61
62// The desktop draws the strip on its own surface, so a fill looks like an alert
63// there. The desktop band colors only the status dot and its label, as the
64// sidebar status rows do, and uses theme keys so a light theme stays readable.
65export function bandStyle(surface: string, kind: 'warning' | 'resolved') {
66 if (surface !== 'terminal') {
67 const mark = kind === 'warning' ? 'warning' : 'success';
68 return {
69 box: {},
70 text: undefined,
71 mark,
72 label: mark,
73 count: { color: mark },
74 metric: undefined,
75 };
76 }
77 const band = BAND[kind];
78 const label = band.label;
79 const count =
80 'box' in band
81 ? { backgroundColor: band.box, color: band.ink }
82 : { color: band.mark };
83 const metric = 'metric' in band ? band.metric : band.text;
84 return {
85 box: { backgroundColor: band.background },
86 text: band.text,
87 mark: band.mark,
88 label,
89 count,
90 metric,
91 };
92}
93
94// The wordmark is a logo, so it keeps the brand value on a light and a dark theme.
95// The antiburn dot-matrix wordmark, as the installer draws it (install.sh, `core`): 40 columns, 7 rows.
96// Rows 0 and 1 hold only the ascenders, so the letter bodies centre on row 4.5. Two empty rows
97// below move the box centre to row 4.5, so the letters align with the text beside them.
98export const WORDMARK_ROWS = [
99 '.................O......................',
100 '.............O.....O....................',
101 '.OOO..O.OO..OOOO.O.O.OO..O...O.O.O.O.OO.',
102 '....O.OO..O..O...O.OO..O.O...O.OO..OO..O',
103 '.OOOO.O...O..O...O.O...O.O...O.O...O...O',
104 'O...O.O...O..O...O.O...O.O..OO.O...O...O',
105 '.OO.O.O...O...OO.O.OOOO...OO.O.O...O...O',
106] as const;
107export const WORDMARK_BOX_ROWS = 9;
108export const WORDMARK_HEIGHT = 17;
109export const WORDMARK_WIDTH = Math.round((WORDMARK_HEIGHT * 40) / WORDMARK_BOX_ROWS);
110export const WORDMARK_SVG =
111 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 ${WORDMARK_BOX_ROWS}">` +
112 WORDMARK_ROWS.flatMap((row, y) =>
113 [...row].flatMap((cell, x) =>
114 cell === 'O'
115 ? [`<circle cx="${x + 0.5}" cy="${y + 0.5}" r="0.45" fill="${BRAND}"/>`]
116 : [],
117 ),
118 ).join('') +
119 '</svg>';
120
121// The count pill is a tint, as the terminal count box is. One pair cannot pass on a light and a dark
122// theme, so the SVG reads the theme and changes both the fill and the ink.
123export const PILL_THEMED =
124 '<style>.r{fill:#F2DCCD}.i{fill:#9A3412}' +
125 `@media (prefers-color-scheme:dark){.r{fill:#51372A}.i{fill:${FAILURE_TEXT_DARK}}}</style>`;
126// A monospace character is about 0.6 of the font size wide, so the pill width follows the text.
127export const COUNT_PILL_HEIGHT = 18;
128
129export function countPillWidth(text: string): number {
130 return Math.ceil(text.length * 12 * 0.6) + 16;
131}
132
133export function countPillSvg(text: string): string {
134 const width = countPillWidth(text);
135 return (
136 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${COUNT_PILL_HEIGHT}" viewBox="0 0 ${width} ${COUNT_PILL_HEIGHT}">` +
137 PILL_THEMED +
138 `<rect class="r" width="${width}" height="${COUNT_PILL_HEIGHT}" rx="${COUNT_PILL_HEIGHT / 2}"/>` +
139 `<text class="i" x="${width / 2}" y="13" text-anchor="middle" font-family="${MONO}" font-size="12" font-weight="600">${escapeXml(text)}</text>` +
140 '</svg>'
141 );
142}
143
144// design.md: group headings take a 14px icon with a 2.5px stroke,
145// CircleAlert in burn-check-failure-fill, CircleCheck in burn-check-pass-fill (dark value).
146export const GROUP_ICON: Readonly<Record<string, string>> = {
147 Signals:
148 '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FF5914" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>',
149 // Lucide hourglass in the app's system orange, as the app's Awaiting verification badge.
150 'Awaiting verification':
151 '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#FF9F0A" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 22h14"/><path d="M5 2h14"/><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/></svg>',
152 '✓ Resolved':
153 '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#2DA7C6" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>',
154};
155
156// A section icon draws in once when its section takes a new check. Reduced motion keeps the fade only.
157export const ICON_ENTER =
158 '<style>svg{animation:enter 200ms cubic-bezier(.23,1,.32,1) both}' +
159 '@keyframes enter{from{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}' +
160 '@media (prefers-reduced-motion:reduce){svg{animation:fade 120ms ease-out both}@keyframes fade{from{opacity:0}to{opacity:1}}}</style>';
161
162export function sectionIcon(source: string, isEntering: boolean): string {
163 return isEntering ? source.replace(/(<svg[^>]*>)/, `$1${ICON_ENTER}`) : source;
164}
165
166// The desktop heading names: the app says "Failed checks" and "Passed checks".
167export const DESKTOP_TITLE: Readonly<Record<string, string>> = {
168 Signals: 'Active checks',
169 '✓ Resolved': 'Resolved',
170};
171
172export type ChipView = {
173 readonly label: string;
174 readonly mark?: { readonly glyph: string; readonly color: string };
175};
176
177// The chip puts the state mark in its own tinted Text, because a dimmed
178// Button greys every glyph in its label.
179export function chipView(checks: readonly LiveCheckView[]): ChipView {
180 const shown = checks.filter((check) => !check.isHidden);
181 const active = shown.filter((check) => check.stage === 'active').length;
182 const watching = shown.filter((check) => check.stage === 'watching').length;
183 // A building check is not in the pane, so the chip does not count it.
184 if (active > 0)
185 return {
186 label: `antiburn · ${active}`,
187 mark: { glyph: '●', color: METRIC },
188 };
189 if (watching > 0)
190 return {
191 label: `antiburn · ${watching}`,
192 mark: { glyph: '⧗', color: METRIC },
193 };
194 return { label: 'antiburn', mark: { glyph: '✓', color: PASS_TEXT_TERMINAL } };
195}
196
197export function chipLabel(checks: readonly LiveCheckView[]): string {
198 const shown = checks.filter((check) => !check.isHidden);
199 const active = shown.filter((check) => check.stage === 'active').length;
200 const watching = shown.filter((check) => check.stage === 'watching').length;
201 // The count matches the strip: only the checks that still need a fix.
202 if (active > 0) return `antiburn · ${active} ●`;
203 if (watching > 0) return `antiburn · ${watching} ⧗`;
204 return 'antiburn ✓';
205}
206
207// A click on the strip gives the strip the keys, so the host refuses the pane's own focus request.
208// The press then moves the focus ring to the first row in the pane.
209
210export const GROUPS: readonly {
211 title: string;
212 stages: readonly LiveStage[];
213 isFolded: boolean;
214}[] = [
215 { title: 'Signals', stages: ['active'], isFolded: false },
216 { title: 'Awaiting verification', stages: ['watching'], isFolded: false },
217 { title: '✓ Resolved', stages: ['resolved'], isFolded: true },
218];
219
220// A theme key. Claude Code 2.1.289 does not export its Color type.
221export const AREAS: Readonly<Record<string, { label: string; color: string }>> = {
222 tools: { label: 'Tool calls', color: 'warning' },
223 context: { label: 'Context', color: 'planMode' },
224 model: { label: 'Model', color: 'merged' },
225 skills: { label: 'Skills', color: 'warning' },
226 scope: { label: 'Scope', color: 'planMode' },
227};
228
229export const METRIC = 'warning';
230
231// The desktop draws each check as a card with a tinted icon tile, as the
232// antiburn app's checks list does. An Svg draws as an image, so each tile
233// holds the dark value of its design.md check token (check-tools,
234// check-depth, check-old-model, check-overthinking) and its lucide paths.
235export const TILE: Readonly<
236 Record<LiveCheckId, { tint: string; paths: readonly string[] }>
237> = {
238 X1: {
239 tint: '#FF922E',
240 paths: [
241 'm17 2 4 4-4 4',
242 'M3 11v-1a4 4 0 0 1 4-4h14',
243 'm7 22-4-4 4-4',
244 'M21 13v1a4 4 0 0 1-4 4H3',
245 ],
246 },
247 D: {
248 tint: '#1ED38A',
249 paths: [
250 'm12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z',
251 'm22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65',
252 'm22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65',
253 ],
254 },
255 // E1 has no check token of its own yet, so it borrows the check-mcp dark value.
256 E1: {
257 tint: '#22B8EF',
258 paths: ['M3 11a8 8 0 1 0 16 0a8 8 0 1 0-16 0', 'm21 21-4.3-4.3'],
259 },
260 // K1 and K2 use the check-skills dark value. C1 has no check token of its own yet, so it borrows check-subagents.
261 K1: {
262 tint: '#FFD32A',
263 paths: [
264 'M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z',
265 ],
266 },
267 C1: {
268 tint: '#FF4D80',
269 paths: [
270 'M2 12a10 10 0 1 0 20 0a10 10 0 1 0-20 0',
271 'M6 12a6 6 0 1 0 12 0a6 6 0 1 0-12 0',
272 'M10 12a2 2 0 1 0 4 0a2 2 0 1 0-4 0',
273 ],
274 },
275 K2: {
276 tint: '#FFD32A',
277 paths: [
278 'M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8',
279 'M21 3v5h-5',
280 'M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16',
281 'M8 16H3v5',
282 ],
283 },
284 O: {
285 tint: '#FFB32C',
286 paths: [
287 'M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8',
288 'M3 3v5h5',
289 'M12 7v5l4 2',
290 ],
291 },
292 T: {
293 tint: '#A477FF',
294 paths: [
295 'M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z',
296 'M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z',
297 'M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4',
298 'M17.599 6.5a3 3 0 0 0 .399-1.375',
299 'M6.003 5.125A3 3 0 0 0 6.401 6.5',
300 'M3.477 10.896a4 4 0 0 1 .585-.396',
301 'M19.938 10.5a4 4 0 0 1 .585.396',
302 'M6 18a4 4 0 0 1-1.967-.516',
303 'M19.967 17.484A4 4 0 0 1 18 18',
304 ],
305 },
306};
307
308// The burn-check-neutral dark value, for checks that are not live.
309// The antiburn desktop app, which runs the checks across sessions.
310
311export const TILE_NEUTRAL = '#7D8185';
312
313// A K1 metric starts with the skill name, such as `/verify named · not run`.
314export function leadingSkill(metric: string): { name: string; rest: string } | null {
315 const match = /^(\/\S+) (.+)$/.exec(metric);
316 return match === null ? null : { name: match[1] ?? '', rest: match[2] ?? '' };
317}
318
319// A monospace character is about 0.6 of the font size wide, so the pill width follows the name.
320export const SKILL_PILL_FONT = 12;
321export const SKILL_PILL_HEIGHT = 20;
322export const SKILL_PILL_PAD = 6;
323
324export function skillPillWidth(name: string): number {
325 return Math.ceil(name.length * SKILL_PILL_FONT * 0.6) + SKILL_PILL_PAD * 2;
326}
327
328export function escapeXml(text: string): string {
329 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
330}
331
332// The skill name is a quiet badge: failure text on a light failure tint, in a rounded rectangle,
333// so it names the skill without the weight of the count pill. Contrast: about 5.3:1 on light, 5.9:1 on dark.
334export const SKILL_BADGE_RADIUS = 4;
335export function skillPillSvg(name: string): string {
336 const width = skillPillWidth(name);
337 const h = SKILL_PILL_HEIGHT;
338 return (
339 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}">` +
340 FAILURE_THEMED +
341 `<rect class="t" width="${width}" height="${h}" rx="${SKILL_BADGE_RADIUS}" fill="${BRAND}" fill-opacity="0.1"/>` +
342 `<text class="f" x="${width / 2}" y="${(h / 2 + (SKILL_PILL_FONT * CAP_HEIGHT) / 2).toFixed(2)}" text-anchor="middle" fill="#D9541A" ` +
343 `font-family="${MONO}" font-size="${SKILL_PILL_FONT}" font-weight="500">${escapeXml(name)}</text>` +
344 '</svg>'
345 );
346}
347
348// A live metric reads as the app's failed count: semibold monospace failure text.
349// Each part between the dots is one SVG, so a narrow card wraps the metric between parts.
350export const METRIC_FONT = 12;
351export const METRIC_HEIGHT = 16;
352
353export function metricParts(metric: string): string[] {
354 const parts = metric.split(' · ');
355 return parts.map((part, index) => (index < parts.length - 1 ? `${part} ·` : part));
356}
357
358export function metricPartWidth(part: string): number {
359 return Math.ceil(part.length * METRIC_FONT * 0.6) + 1;
360}
361
362export function metricPartSvg(
363 part: string,
364 tone: 'failure' | 'pass' = 'failure',
365): string {
366 const width = metricPartWidth(part);
367 const isPass = tone === 'pass';
368 return (
369 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${METRIC_HEIGHT}" viewBox="0 0 ${width} ${METRIC_HEIGHT}">` +
370 (isPass ? PASS_THEMED : FAILURE_THEMED) +
371 `<text class="${isPass ? 'p' : 'f'}" x="0" y="12" fill="${isPass ? PASS_FILL_DARK : '#D9541A'}" font-family="${MONO}" ` +
372 `font-size="${METRIC_FONT}" font-weight="${isPass ? 400 : 600}" xml:space="preserve">${escapeXml(part)}</text>` +
373 '</svg>'
374 );
375}
376
377// The empty pane icon: the lucide activity line in a muted round tile. The SVG reads the theme itself.
378export const EMPTY_ICON =
379 '<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">' +
380 '<style>circle{fill:#000;fill-opacity:.05}path{stroke:#000;stroke-opacity:.35}' +
381 '@media (prefers-color-scheme:dark){circle{fill:#fff;fill-opacity:.06}path{stroke:#fff;stroke-opacity:.4}}</style>' +
382 '<circle cx="20" cy="20" r="20"/>' +
383 '<g transform="translate(10 10) scale(0.8333)"><path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></g>' +
384 '</svg>';
385
386// A light grey with alpha reads as a subtle card on a light or a dark pane. A mid grey
387// darkens a white pane more than it lightens a dark one, so the grey is lighter than the middle.
388export const CARD_BACKGROUND = '#AAAAAA12';
389export const CARD_OPEN_BACKGROUND = '#AAAAAA21';
390// One step stronger than the card fill, as the app's hovered card.
391
392// The antiburn mark from apps/desktop/src/assets/antiburn-mark.svg. The app fills it
393// with the label colour at 2% to 7.5%. The mod cannot read the theme, so a mid grey
394// with alpha lightens a dark pane and darkens a light pane by about the same amount.
395// Each dot gets its own opacity along the diagonal, as the app's gradient does, so
396// the markup needs no gradient reference. A radius of 3.2 (not 3.4) keeps the dots
397// from overlapping, because overlapping alpha draws darker seams.
398export const MARK_DOTS: readonly (readonly [number, number])[] = [
399 [16, 9.3],
400 [22.4, 9.3],
401 [28.8, 9.3],
402 [35.2, 15.7],
403 [16, 22.1],
404 [22.4, 22.1],
405 [28.8, 22.1],
406 [35.2, 22.1],
407 [9.6, 28.5],
408 [35.2, 28.5],
409 [16, 34.9],
410 [22.4, 34.9],
411 [35.2, 34.9],
412];
413// About 45% of the pane width, from 160 to 280 CSS pixels (280 is the app's mark).
414// A desktop column is about 8 pixels wide (an assumption, not an engine value).
415export function markSize(columns: number): number {
416 return Math.round(Math.min(280, Math.max(160, columns * 8 * 0.45)));
417}
418
419// The stops copy the app's .burn-check-background-mark: clear to 10% of the
420// diagonal, 2% at 45%, 7.5% at the corner, in the label colour. The mark is
421// smaller than the app's, so the low stops are raised to keep the top left visible.
422export function markOpacity(cx: number, cy: number): number {
423 const t = (cx - 6.2 + (cy - 5.9)) / 64.8;
424 if (t <= 0.05) return 0;
425 if (t <= 0.45) return (0.03 * (t - 0.05)) / 0.4;
426 return 0.03 + (0.045 * (t - 0.45)) / 0.55;
427}
428
429// The mod cannot read the theme, so the SVG reads it: black dots on a light pane,
430// white dots at half strength on a dark one. If a host drops the style element,
431// the mid-grey fill attribute still draws the mark at about half strength.
432export const WATERMARK_SVG =
433 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="6.2 5.9 32.4 32.4">' +
434 '<style>circle{fill:#000}@media (prefers-color-scheme:dark){circle{fill:#fff}g{opacity:.5}}</style><g>' +
435 MARK_DOTS.map(
436 ([cx, cy]) =>
437 `<circle cx="${cx}" cy="${cy}" r="3.2" fill="#808080" fill-opacity="${markOpacity(cx, cy).toFixed(4)}"/>`,
438 ).join('') +
439 '</g></svg>';
440
441// The antiburn mark at full strength in the brand orange, as a logo beside the footer link.
442// The shape marks it as the brand, so the orange does not read as an alert.
443export const BRAND_MARK_SVG =
444 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="6.2 5.9 32.4 32.4">' +
445 MARK_DOTS.map(
446 ([cx, cy]) => `<circle cx="${cx}" cy="${cy}" r="3.2" fill="${BRAND}"/>`,
447 ).join('') +
448 '</svg>';
449
450// The cap height of the system font, as a part of the font size. Digits are as tall as capitals.
451export const CAP_HEIGHT = 0.7;
452
453// A round count badge. The SVG reads the theme itself, as the watermark does.
454export function badgeSvg(count: number): string {
455 const label = count > 99 ? '99+' : String(count);
456 const size = label.length > 1 ? 9 : 11;
457 return (
458 '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">' +
459 '<style>text{fill:#000;fill-opacity:.6}@media (prefers-color-scheme:dark){text{fill:#fff;fill-opacity:.7}}</style>' +
460 '<circle cx="10" cy="10" r="10" fill="#808080" fill-opacity="0.2"/>' +
461 // "central" centres the em box, not the digits, so the digits sit low. The baseline drops by half the cap height instead.
462 `<text x="10" y="${(10 + (size * CAP_HEIGHT) / 2).toFixed(2)}" text-anchor="middle" font-family="-apple-system, system-ui, sans-serif" font-size="${size}" style="font-variant-numeric:tabular-nums">${label}</text>` +
463 '</svg>'
464 );
465}
466
467// A watched check names its next step.
468export const WATCHING_STATUS = 'Checking the next turns.';
469
470// A prompt block shows about three lines of a card. Longer text cuts at a word, and Show all opens the rest.
471export const PROMPT_PREVIEW_CHARS = 180;
472// A quiet well inside the card. A round border in the same colour gives it round corners.
473export const PROMPT_BLOCK_BACKGROUND = '#80808017';
474
475export function promptPreview(text: string): { text: string; isCut: boolean } {
476 if (text.length <= PROMPT_PREVIEW_CHARS) return { text, isCut: false };
477 const cut = text.lastIndexOf(' ', PROMPT_PREVIEW_CHARS);
478 return {
479 text: `${text.slice(0, cut > 0 ? cut : PROMPT_PREVIEW_CHARS).trimEnd()}…`,
480 isCut: true,
481 };
482}
483
484/**
485 * The short form of a fix that starts with a command. The prompt box shows the full text
486 * before the person sends it, so the card names only the command.
487 */
488export function commandSummary(
489 check: Pick<LiveCheckView, 'id' | 'prompt'>,
490): { name: string } | null {
491 const match = /^(\/\S+)\s*(.*)$/s.exec(check.prompt);
492 if (match === null) return null;
493 const command = match[1] ?? '';
494 const rest = match[2] ?? '';
495 if (check.id !== 'K1') return { name: rest === '' ? command : `${command} ${rest}` };
496 // A plugin skill runs by its full name; the card shows the short name after the colon.
497 return {
498 name: `/${command.slice(command.lastIndexOf(':') + 1).replace(/^\//, '')}`,
499 };
500}
501
502// The label of a fix button. An armed button asks for the second press itself, so no note is necessary.
503export function fixLabel(check: LiveCheckView, armedId: LiveCheckId | null): string {
504 if (armedId !== check.id) return check.actionLabel;
505 return check.actionKind === 'compact' ? 'Confirm compact' : 'Confirm send';
506}
507
508// The blank label of the press target over a chevron.
509export const CHEVRON_HIT = '\u2003';
510
511// The strip's close glyph on the desktop: the lucide x. A side-by-side capture of the host's own
512// close control set its thinner stroke, its dimmer ink and its position. The Svg is as wide as the
513// hover, and the viewBox centers the glyph in it. A Box moves in whole cells only, so a negative
514// right margin of one cell puts the glyph center where the host's close control is.
515
516export function tileSvg(id: LiveCheckId, isLive: boolean): string {
517 const tile = TILE[id] ?? { tint: '#A8A8A8', paths: ['M12 3v18', 'M3 12h18'] };
518 const tint = isLive ? tile.tint : TILE_NEUTRAL;
519 const icon = tile.paths.map((d) => `<path d="${d}"/>`).join('');
520 return (
521 '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">' +
522 `<circle cx="16" cy="16" r="16" fill="${tint}" fill-opacity="0.16"/>` +
523 `<g transform="translate(7 7) scale(0.75)" fill="none" stroke="${tint}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${icon}</g>` +
524 '</svg>'
525 );
526}
527
528// Each check has its own dot colour, the tint of its desktop tile, so two checks
529// in one area do not look the same in the terminal.
530export function dotColor(check: LiveCheckView): string {
531 return check.stage === 'active' || check.stage === 'watching'
532 ? (TILE[check.id]?.tint ?? 'subtle')
533 : 'subtle';
534}
535
536export function areaOf(check: LiveCheckView): { label: string; color: string } {
537 return AREAS[check.area] ?? { label: check.area, color: 'subtle' };
538}
539
540export function metricText(check: LiveCheckView): string {
541 if (check.source === 'session') return check.metric;
542 if (check.stage === 'building') return check.progress ?? '';
543 // A resolved row names the outcome in place of the metric. The pane shows this session only, so it does not say so.
544 if (check.stage === 'resolved')
545 return check.isStoppedAfterFix ? '✓ Fixed' : '✓ Stopped by itself';
546 return check.metric;
547}
548
549// The rows the pane draws, in order: the checks of each open group.
550export function visibleRowIds(
551 shown: readonly LiveCheckView[],
552 openGroups: readonly string[],
553): LiveCheckId[] {
554 return GROUPS.flatMap((group) =>
555 !group.isFolded || openGroups.includes(group.title)
556 ? shown.filter((check) => group.stages.includes(check.stage))
557 : [],
558 ).map((check) => check.id);
559}
560
561export function toggleGroup(openGroups: readonly string[], title: string): string[] {
562 return openGroups.includes(title)
563 ? openGroups.filter((open) => open !== title)
564 : [...openGroups, title];
565}
566
567export function renderStripView(
568 ui: PaneUi,
569 e: RenderInput<'AbovePrompt'>,
570 checks: readonly LiveCheckView[],
571 resolvedLine: readonly LiveCheckId[],
572 isDismissed: boolean,
573 newCount: number,
574 armedId: LiveCheckId | null,
575 actions: StripActions,
576 withBrand = true,
577) {
578 const { Box, Button, Text } = ui;
579 // The strip shows only the checks that still need a fix. A watched check has its fix in flight,
580 // so the footer chip counts it and the strip goes away when only watched checks remain.
581 const active = liveChecks({
582 checks: [...checks],
583 newIds: [],
584 resolvedNow: [],
585 }).filter((check) => check.stage === 'active');
586 const columns = e.props.bodyColumns;
587 const top = active[0];
588
589 if (top === undefined) {
590 const [firstId] = resolvedLine;
591 if (firstId === undefined) return null;
592 // The strip names the first check that resolved, and counts the others.
593 const firstLabel = checks.find((check) => check.id === firstId)?.label ?? 'Check';
594 const others = resolvedLine.length > 1 ? ` +${resolvedLine.length - 1}` : '';
595 const resolved = `${firstLabel}${others} resolved`;
596 const style = bandStyle(e.surface, 'resolved');
597 // The resolved strip is quieter than the warning strip: the antiburn name, then the outcome
598 // in the pass cyan of the Resolved icon, as text and not in a box.
599 if (e.surface !== 'terminal' && 'Svg' in ui) {
600 return (
601 <Box flexDirection="row" alignItems="center" gap={1} paddingX={1}>
602 {withBrand &&
603 (columns < 80 ? (
604 <ui.Svg
605 key="brand-mark"
606 source={BRAND_MARK_SVG}
607 alt="antiburn"
608 width={14}
609 height={14}
610 />
611 ) : (
612 <ui.Svg
613 key="brand"
614 source={WORDMARK_SVG}
615 alt="antiburn"
616 width={WORDMARK_WIDTH}
617 height={WORDMARK_HEIGHT}
618 />
619 ))}
620 <ui.Svg
621 key="tick"
622 source={GROUP_ICON['✓ Resolved'] ?? ''}
623 alt="Resolved"
624 width={14}
625 height={14}
626 />
627 <ui.Svg
628 key="resolved"
629 source={metricPartSvg(resolved, 'pass')}
630 alt={resolved}
631 width={metricPartWidth(resolved)}
632 height={METRIC_HEIGHT}
633 />
634 </Box>
635 );
636 }
637 return (
638 <Box {...style.box} paddingX={1}>
639 <Text color={style.text}>
640 {withBrand && (
641 <Text bold color={style.label}>
642 antiburn{' '}
643 </Text>
644 )}
645 <Text color={style.mark}>✓ {resolved}</Text>
646 </Text>
647 </Box>
648 );
649 }
650
651 const count = `${active.length}${top.source === 'session' ? ' checks' : ' to fix'}`;
652 // A hotkey presses a strip Button only from the terminal: from a focused band, or as a bare
653 // digit in an empty prompt. The desktop prompt sends a digit to Claude, so the desktop gets no hotkeys.
654 const hotkeyFor = (digit: string) => (e.surface === 'terminal' ? digit : undefined);
655 // A dismissed strip stays away until a new check arrives. The footer chip still counts the open checks.
656 if (isDismissed) return null;
657
658 const fresh = newCount > 0 ? ` (${newCount} new)` : '';
659 const isTerminal = e.surface === 'terminal';
660 // The terminal does not draw a Button's hotkey, so the label shows the digit.
661 const keyed = (digit: string, label: string) =>
662 isTerminal ? `${digit} ${label}` : label;
663 const viewLabel = columns < 80 ? 'View' : 'View checks';
664 const buttons = `${top.actionLabel} ${viewLabel} x`.length + 22;
665 // The strip has four forms, the same on each surface. Each form keeps the antiburn name and the
666 // top check name, because the fix button acts on that check.
667 // 100 columns and more: the wordmark, the count with "(N new)", the check name and its metric.
668 // 80 to 99 columns: the metric goes, because the desktop wraps a Text that does not fit.
669 // "(N new)" stays from 80 columns, and on the terminal while it fits beside the check name.
670 // 56 to 79 columns: the desktop shows the mark in place of the wordmark, and the buttons get shorter.
671 // Less than 56 columns: the buttons move to a second row, so the check name keeps the full width.
672 // The terminal counts its cells, so it also stacks when the check name does not fit beside the buttons.
673 // The check name is bold and its metric is quieter: the fix acts on the check, and the metric only sizes it.
674 const hasMetric = columns >= 100;
675 const isStacked =
676 columns < 56 ||
677 (isTerminal &&
678 columns - buttons - (withBrand ? 'antiburn'.length : 0) < top.label.length + 3);
679 const metric = ` · ${metricText(top)}`;
680 const total = columns >= 80 ? `${count}${fresh}` : count;
681 const style = bandStyle(e.surface, 'warning');
682 const stack = (head: RenderNode) =>
683 isStacked ? (
684 <Box flexDirection="column" gap={isTerminal ? 0 : 1} {...style.box} paddingX={1}>
685 {head}
686 <Box key="actions" flexDirection="row" justifyContent="flex-end">
687 {stripButtons(top)}
688 </Box>
689 </Box>
690 ) : (
691 <Box
692 flexDirection="row"
693 justifyContent="space-between"
694 alignItems="center"
695 gap={1}
696 {...style.box}
697 paddingX={1}
698 >
699 {head}
700 {stripButtons(top)}
701 </Box>
702 );
703 if (!isTerminal) {
704 // A Text that holds other Texts wraps on the desktop, but a plain Text truncates in a shrinking Box.
705 // So the check name and the metric are two plain Texts.
706 const Svg = 'Svg' in ui ? ui.Svg : undefined;
707 return stack(
708 <Box
709 key="brand-head"
710 flexDirection="row"
711 alignItems="center"
712 gap={1}
713 flexShrink={1}
714 minWidth={0}
715 >
716 {withBrand &&
717 Svg !== undefined &&
718 (columns < 80 ? (
719 <Svg
720 key="brand-mark"
721 source={BRAND_MARK_SVG}
722 alt="antiburn"
723 width={14}
724 height={14}
725 />
726 ) : (
727 <Svg
728 key="brand"
729 source={WORDMARK_SVG}
730 alt="antiburn"
731 width={WORDMARK_WIDTH}
732 height={WORDMARK_HEIGHT}
733 />
734 ))}
735 {Svg !== undefined && (
736 <Svg
737 key="count"
738 source={countPillSvg(total)}
739 alt={total}
740 width={countPillWidth(total)}
741 height={COUNT_PILL_HEIGHT}
742 />
743 )}
744 <Box key="headline" flexDirection="row" flexShrink={1} minWidth={0}>
745 <Text bold wrap="truncate-end">
746 {top.label}
747 </Text>
748 {hasMetric && (
749 <Text dimColor wrap="truncate-end">
750 {metric}
751 </Text>
752 )}
753 </Box>
754 </Box>,
755 );
756 }
757 // The count sits in a box, as the desktop pill does. A space on each side gives the box its margin.
758 // The box shows only when the check name still fits beside it.
759 const room =
760 (isStacked ? columns - 2 : columns - buttons) - (withBrand ? 'antiburn'.length : 0);
761 const rest = hasMetric ? `${top.label}${metric}` : top.label;
762 const fits = (text: string) => room - text.length - 3 >= top.label.length + 1;
763 const boxText = [`${count}${fresh}`, count].find(fits);
764 const bracket = boxText === undefined ? '' : ` ${boxText} `;
765 const lead = bracket === '' ? ' · ' : ' ';
766 const tail = truncate(
767 `${lead}${rest}`,
768 room - (bracket === '' ? 0 : bracket.length + 1),
769 );
770 const labelEnd = lead.length + top.label.length;
771 return stack(
772 <Box key="brand-head" flexShrink={1} minWidth={0}>
773 <Text wrap="truncate-end" color={style.text}>
774 {withBrand && (
775 <Text bold color={style.label}>
776 antiburn
777 </Text>
778 )}
779 {bracket !== '' && ' '}
780 {bracket !== '' && <Text {...style.count}>{bracket}</Text>}
781 {tail.slice(0, lead.length)}
782 <Text bold>{tail.slice(lead.length, labelEnd)}</Text>
783 {tail.length > labelEnd && (
784 <Text color={style.metric}>{tail.slice(labelEnd)}</Text>
785 )}
786 </Text>
787 </Box>,
788 );
789
790 function stripButtons(top: LiveCheckView) {
791 return (
792 <Box flexDirection="row" gap={1} flexShrink={0}>
793 {top.source !== 'session' && (
794 <Button
795 key="fix"
796 hotkey={hotkeyFor('1')}
797 variant="primary"
798 label={keyed('1', fixLabel(top, armedId))}
799 onPress={() => actions.act(top.id)}
800 />
801 )}
802 <Button
803 key="view"
804 hotkey={hotkeyFor('2')}
805 label={keyed('2', viewLabel)}
806 onPress={() => actions.view()}
807 />
808 {renderDismiss()}
809 </Box>
810 );
811 }
812
813 // The desktop does not draw its native close control for role="dismiss" yet (2.1.296), and it draws
814 // a Button label as text. So the desktop draws the close glyph as an Svg, and a blank plain Button
815 // lies on top of it to take the press, as on a card chevron. The terminal draws a dim plain ×.
816 function renderDismiss() {
817 const onPress = () => actions.dismiss();
818 if (isTerminal || !('Svg' in ui))
819 return <Button key="dismiss" plain dimColor label="×" onPress={onPress} />;
820 const { Svg } = ui;
821 return (
822 <Box
823 key="dismiss-box"
824 position="relative"
825 flexDirection="row"
826 alignItems="center"
827 justifyContent="center"
828 marginRight={-1}
829 >
830 <Svg
831 source={DISMISS_SVG}
832 alt="Dismiss"
833 width={DISMISS_WIDTH}
834 height={DISMISS_SIZE}
835 />
836 {renderOverlayButton(ui, 'dismiss', DISMISS_HIT, onPress)}
837 </Box>
838 );
839 }
840}
841
842type PaneUi = ReturnType<EngineInterface['ui']['resolve']>;
843type StripActions = {
844 act: (id: LiveCheckId) => Promise<void>;
845 view: () => Promise<void>;
846 dismiss: () => Promise<void>;
847};
848type PaneActions = {
849 centredLine: (e: PaneEvent, key: string, line: string) => RenderNode;
850 toggleGroup: (title: string) => Promise<void>;
851 toggleCard: (id: LiveCheckId) => Promise<void>;
852 toggleRow: (id: LiveCheckId) => Promise<void>;
853 act: (id: LiveCheckId, from: 'card') => Promise<void>;
854 hide: (id: LiveCheckId) => Promise<void>;
855 toggleFullPrompt: (id: LiveCheckId) => Promise<void>;
856};
857
858function renderCardDetailView(
859 ui: PaneUi,
860 e: PaneEvent,
861 check: LiveCheckView,
862 fullPromptId: LiveCheckId | null,
863 toggleFullPrompt: (id: LiveCheckId) => Promise<void>,
864) {
865 const { Box, Text } = ui;
866 const isLive = check.stage === 'active' || check.stage === 'watching';
867 return (
868 <Box key={`more-${check.id}`} flexDirection="column" gap={1}>
869 {check.cause !== null && (
870 <Text>
871 <Text bold>Cause</Text>
872 <Text dimColor> · </Text>
873 {check.cause}
874 </Text>
875 )}
876 {isLive &&
877 renderFixView(ui, e, check, fullPromptId === check.id, toggleFullPrompt)}
878 </Box>
879 );
880}
881
882function renderFixView(
883 ui: PaneUi,
884 e: PaneEvent,
885 check: LiveCheckView,
886 isFull: boolean,
887 toggleFullPrompt: (id: LiveCheckId) => Promise<void>,
888) {
889 const { Box, Button, Text } = ui;
890 const Svg = e.surface === 'desktop' && 'Svg' in ui ? ui.Svg : undefined;
891 const isFill = check.actionKind === 'fill';
892 const isAdvice = check.actionKind === 'advise';
893 const summary = isFill || isAdvice ? commandSummary(check) : null;
894 if (summary !== null) {
895 const lead = isAdvice ? 'Next time, start with' : 'Fills';
896 const end = isAdvice ? 'It may take fewer steps.' : 'You send it.';
897 if (Svg === undefined)
898 return (
899 <Text key={`fix-${check.id}`}>
900 {lead} <Text bold>{summary.name}</Text>. <Text dimColor>{end}</Text>
901 </Text>
902 );
903 return (
904 <Box
905 key={`fix-${check.id}`}
906 flexDirection="row"
907 alignItems="center"
908 columnGap={1}
909 flexWrap="wrap"
910 >
911 <Text>{lead}</Text>
912 <Svg
913 key={`command-pill-${check.id}-${summary.name}`}
914 source={skillPillSvg(summary.name)}
915 alt={check.prompt.split(/\s/)[0] ?? summary.name}
916 width={skillPillWidth(summary.name)}
917 height={SKILL_PILL_HEIGHT}
918 />
919 <Text dimColor>{end}</Text>
920 </Box>
921 );
922 }
923 const label =
924 check.actionKind === 'compact'
925 ? 'Compact instructions'
926 : isFill
927 ? 'Prompt'
928 : 'Fix prompt';
929 const preview = promptPreview(check.prompt);
930 const shown = isFull ? check.prompt : preview.text;
931 const isDesktop = e.surface !== 'terminal';
932 return (
933 <Box key={`fix-${check.id}`} flexDirection="column" gap={isDesktop ? 1 : 0}>
934 <Text bold>{label}</Text>
935 {isDesktop ? (
936 <Box
937 paddingX={1}
938 backgroundColor={PROMPT_BLOCK_BACKGROUND}
939 borderStyle="round"
940 borderColor={PROMPT_BLOCK_BACKGROUND}
941 >
942 <Text>{shown}</Text>
943 </Box>
944 ) : (
945 <Box paddingLeft={2}>
946 <Text>{shown}</Text>
947 </Box>
948 )}
949 {preview.isCut && (
950 <Button
951 key={`full-${check.id}`}
952 plain
953 label={isFull ? 'Show less' : 'Show all'}
954 onPress={() => toggleFullPrompt(check.id)}
955 />
956 )}
957 {isFill && <Text dimColor>This fills the prompt box. You send it.</Text>}
958 </Box>
959 );
960}
961
962function renderOverlayButton(
963 ui: PaneUi,
964 key: string,
965 label: string,
966 onPress: () => Promise<void>,
967) {
968 const { Box, Button } = ui;
969 return (
970 <Box
971 position="absolute"
972 top={0}
973 left={0}
974 right={0}
975 bottom={0}
976 flexDirection="row"
977 alignItems="center"
978 justifyContent="center"
979 >
980 <Button key={key} plain label={label} onPress={onPress} />
981 </Box>
982 );
983}
984
985function renderChevronView(
986 ui: PaneUi,
987 e: PaneEvent,
988 key: string,
989 alt: string,
990 isOpen: boolean,
991 isTurning: boolean,
992 onPress: () => Promise<void>,
993 size = 20,
994) {
995 const { Box } = ui;
996 if (!('Svg' in ui)) return null;
997 const { Svg } = ui;
998 return (
999 <Box
1000 key={`chevron-${key}`}
1001 position="relative"
1002 flexDirection="row"
1003 alignItems="center"
1004 justifyContent="center"
1005 >
1006 <Svg
1007 key={`chevron-svg-${key}-${isOpen ? 'open' : 'closed'}`}
1008 source={chevronSvg(isOpen, isTurning, size)}
1009 alt={alt}
1010 width={size}
1011 height={size}
1012 />
1013 {renderOverlayButton(ui, key, CHEVRON_HIT, onPress)}
1014 </Box>
1015 );
1016}
1017
1018function terminalMetricView(ui: PaneUi, check: LiveCheckView): RenderNode {
1019 const { Text } = ui;
1020 const text = metricText(check);
1021 const skill = check.stage === 'resolved' ? null : leadingSkill(text);
1022 return skill === null ? (
1023 text
1024 ) : (
1025 <Text>
1026 <Text bold>{skill.name}</Text> {skill.rest}
1027 </Text>
1028 );
1029}
1030
1031export function renderListView(
1032 ui: PaneUi,
1033 e: PaneEvent,
1034 shown: readonly LiveCheckView[],
1035 hasHidden: boolean,
1036 openGroups: readonly string[],
1037 armedId: LiveCheckId | null,
1038 openId: LiveCheckId | null,
1039 turned: string | null,
1040 entered: readonly LiveStage[],
1041 connectionText: string | undefined,
1042 fullPromptId: LiveCheckId | null,
1043 actions: PaneActions,
1044) {
1045 const { Box, Button, Text } = ui;
1046 // Only the desktop draws an Svg, so only the desktop draws cards.
1047 const Svg = e.surface === 'desktop' && 'Svg' in ui ? ui.Svg : undefined;
1048
1049 // The Checks heading counts the active checks. Watched checks have their own section and count.
1050 const live = shown.filter((check) => check.stage === 'active');
1051 // design.md: counts sit in muted badges beside section headings.
1052 const badge = (count: number) =>
1053 Svg !== undefined ? (
1054 <Svg
1055 key={`badge-${count}`}
1056 source={badgeSvg(count)}
1057 alt={`${count}`}
1058 width={20}
1059 height={20}
1060 />
1061 ) : (
1062 <Text dimColor>{count}</Text>
1063 );
1064 // The focus ring starts on the first row, so Enter opens it without a move first.
1065 const firstId = visibleRowIds(shown, openGroups)[0];
1066 // The terminal mock is a bare list: no Signals title and no area before the metric.
1067 const isTerminal = e.surface === 'terminal';
1068 return (
1069 <Box flexDirection="column" gap={1} flexGrow={1}>
1070 {Svg !== undefined ? (
1071 // The heading and its count badge. The pane shows this session only, so it names no period, as the terminal does not.
1072 <Box key="checks-title" flexDirection="row" gap={1} alignItems="center">
1073 {live.length > 0 && (
1074 <Svg
1075 key="checks-icon"
1076 source={GROUP_ICON.Signals ?? ''}
1077 alt="Active checks"
1078 width={14}
1079 height={14}
1080 />
1081 )}
1082 <Text bold>Checks</Text>
1083 {(shown.length > 0 || hasHidden) && badge(live.length)}
1084 </Box>
1085 ) : (
1086 // One heading style for each section: a glyph, a bold title, then a dim count.
1087 // As on the desktop, the Checks glyph shows only while a check is active.
1088 <Text>
1089 {live.length > 0 && <Text color={METRIC}>● </Text>}
1090 <Text bold>Checks</Text>
1091 {(shown.length > 0 || hasHidden) && <Text dimColor> {live.length}</Text>}
1092 </Text>
1093 )}
1094 {connectionText && <Text key="session-checks-connection" dimColor>{connectionText}</Text>}
1095 {shown.length === 0 && !hasHidden ? (
1096 // The empty pane says what the pane is for, a little above the middle of the free space:
1097 // a block at the exact middle looks low. Text has no alignment, so each line is its own centred Text.
1098 <Box
1099 key="empty"
1100 flexDirection="column"
1101 flexGrow={1}
1102 alignItems="center"
1103 justifyContent="center"
1104 gap={2}
1105 marginBottom={emptyLift(e)}
1106 >
1107 {Svg !== undefined && (
1108 <Svg
1109 key="empty-icon"
1110 source={EMPTY_ICON}
1111 alt="No checks yet"
1112 width={40}
1113 height={40}
1114 />
1115 )}
1116 <Box flexDirection="column" alignItems="center">
1117 <Text bold>No checks yet</Text>
1118 {actions.centredLine(e, 'watching', 'antiburn is watching this session.')}
1119 {actions.centredLine(
1120 e,
1121 'appear',
1122 "Checks appear when there's a better way to work.",
1123 )}
1124 </Box>
1125 </Box>
1126 ) : (
1127 live.length === 0 && (
1128 <Text key="empty" dimColor>
1129 Nothing to fix right now.
1130 </Text>
1131 )
1132 )}
1133 {GROUPS.map((group) => {
1134 const rows = shown.filter((check) => group.stages.includes(check.stage));
1135 if (rows.length === 0) return null;
1136 const isOpen = !group.isFolded || openGroups.includes(group.title);
1137 const baseIcon = Svg !== undefined ? GROUP_ICON[group.title] : undefined;
1138 const isEntering = group.stages.some((stage) => entered.includes(stage));
1139 const icon =
1140 baseIcon === undefined ? undefined : sectionIcon(baseIcon, isEntering);
1141 const title =
1142 Svg !== undefined ? (DESKTOP_TITLE[group.title] ?? group.title) : group.title;
1143 return (
1144 // A later section starts one row lower than the gap between cards, so it reads as a new section.
1145 // On the terminal a heading sits on its cards: the gap goes between the cards only.
1146 <Box
1147 key={group.title}
1148 flexDirection="column"
1149 gap={isTerminal ? 0 : 1}
1150 marginTop={group.title === 'Signals' ? undefined : 1}
1151 >
1152 {Svg !== undefined && group.title === 'Signals' ? null : Svg !==
1153 undefined ? (
1154 <Box
1155 key={`heading-${group.stages[0] ?? ''}`}
1156 flexDirection="row"
1157 justifyContent="space-between"
1158 alignItems="center"
1159 >
1160 <Box flexDirection="row" gap={1} alignItems="center">
1161 {icon !== undefined && (
1162 <Svg
1163 key={`icon-${group.stages[0] ?? ''}`}
1164 source={icon}
1165 alt={title}
1166 width={14}
1167 height={14}
1168 />
1169 )}
1170 {/* The title is Text, as the Checks title is: a native button adds padding and moves the baseline.
1171 The chevron opens and closes the section. */}
1172 <Text bold>{title}</Text>
1173 {badge(rows.length)}
1174 {/* The chevron sits beside the title it opens, not at the far edge. */}
1175 {group.isFolded &&
1176 renderChevronView(
1177 ui,
1178 e,
1179 `group-chevron-${group.stages[0] ?? ''}`,
1180 isOpen ? `Hide ${title}` : `Show ${title}`,
1181 isOpen,
1182 turned === group.title,
1183 () => actions.toggleGroup(group.title),
1184 )}
1185 </Box>
1186 </Box>
1187 ) : group.isFolded ? (
1188 <Button
1189 key={`group-${group.stages[0] ?? ''}`}
1190 plain
1191 label={`${group.title} ${rows.length} ${isOpen ? '▾' : '▸'}`}
1192 onPress={() => actions.toggleGroup(group.title)}
1193 />
1194 ) : (
1195 // The terminal names the watched section in the chip's symbol. The Checks title heads the active checks.
1196 group.title !== 'Signals' && (
1197 <Text key={`heading-${group.stages[0] ?? ''}`}>
1198 <Text color="warning">⧗</Text> <Text bold>{group.title}</Text>
1199 <Text dimColor> {rows.length}</Text>
1200 </Text>../../packages/antiburn-plugin-core/product.mjs 114 lines1export const VERSION = '0.2.1'
2export const CONTRACT_VERSION = 1
3export const HOSTS = ['claude-code', 'codex', 'cursor', 'opencode', 'pi']
4
5/** @typedef {'plain' | 'ascii' | 'flame'} Style */
6/** @typedef {'help'|'status'|'diagnostics'|'usage'|'details'|'refresh'|'invalid'} Command */
7
8/** @param {string} input @returns {{command: Command, style: Style}} */
9export function parseCommand(input = '') {
10 if (typeof input !== 'string' || input.length > 512)
11 return { command: 'invalid', style: 'plain' }
12
13 const parts = input.trim().split(/\s+/).filter(Boolean)
14 const [command = 'help', arg] = parts
15 const style = arg === '--ascii' ? 'ascii' : arg === '--flame' ? 'flame' : 'plain'
16 if (parts.length > 2) return { command: 'invalid', style }
17 if (command === 'help' || command === 'status') {
18 if (arg && arg !== '--ascii' && arg !== '--flame') return { command: 'invalid', style }
19 return { command, style }
20 }
21 if (arg) {
22 if (command === 'usage' && ['details', 'refresh'].includes(arg))
23 return { command: /** @type {'details'|'refresh'} */ (arg), style }
24 return { command: 'invalid', style }
25 }
26 if (['diagnostics', 'usage', 'details', 'refresh'].includes(command))
27 return { command: /** @type {Command} */ (command), style }
28 return { command: 'invalid', style }
29}
30
31/** @param {string} host @param {string} invocation @param {'native'|'model-mediated'} mode @param {string} surface @param {string} [hostVersion] */
32export function describe(host, invocation, mode, surface = 'unknown', hostVersion = 'unknown') {
33 return {
34 id: 'antiburn',
35 version: VERSION,
36 contractVersion: CONTRACT_VERSION,
37 host: {
38 id: HOSTS.includes(host) ? host : 'unknown',
39 version: clean(hostVersion),
40 surface: clean(surface),
41 },
42 capabilities: {
43 invocationMode: mode,
44 invocation: clean(invocation),
45 output: clean(surface),
46 composition: host === 'claude-code',
47 usage: host === 'claude-code',
48 },
49 certification: 'unverified',
50 }
51}
52
53/** @param {string} text */
54function clean(text) {
55 return String(text)
56 .replace(/[\x00-\x1f\x7f-\x9f]/g, '')
57 .slice(0, 128)
58}
59
60/** @param {{markdown?: boolean, unicode?: boolean}} capability @returns {Style} */
61export function presentation(capability) {
62 return capability.unicode === false ? 'ascii' : 'plain'
63}
64
65/** @param {Style} style */
66function brand(style) {
67 if (style === 'ascii') return '`antiburn`'
68 return '🔥 `antiburn`'
69}
70
71/** @param {ReturnType<typeof describe>} meta @param {Style} [style] @param {boolean} [usage] */
72export function renderHelp(meta, style = 'plain', usage = false) {
73 return [
74 brand(style),
75 ...(usage ? ['Unknown arguments.'] : []),
76 `Usage: ${meta.capabilities.invocation} [help|status|diagnostics|usage [details|refresh]]`,
77 'Help and status accept --ascii or --flame.',
78 'Aliases: details and refresh show or refresh usage.',
79 'Shows antiburn feature and usage information.',
80 `antiburn ${VERSION} (contract ${CONTRACT_VERSION})`,
81 ].join('\n')
82}
83
84/** @param {ReturnType<typeof describe>} meta @param {Style} [style] */
85export function renderStatus(meta, style = 'plain') {
86 return [
87 brand(style),
88 `antiburn: ${meta.version} (contract ${meta.contractVersion})`,
89 `Usage feature: ${meta.capabilities.usage ? 'Included' : 'Not included'}`,
90 `Host: ${meta.host.id} ${meta.host.version}`,
91 `Surface: ${meta.host.surface}`,
92 `Invocation: ${meta.capabilities.invocation} (${meta.capabilities.invocationMode})`,
93 'Runtime certification: Unverified',
94 ].join('\n')
95}
96
97/** @param {string} input @returns {'details'|'refresh'|'help'|'invalid'} */
98export function parseUsageCommand(input = '') {
99 if (typeof input !== 'string' || input.length > 512) return 'invalid'
100 const args = input.trim().split(/\s+/).filter(Boolean).join(' ')
101 if (['', 'usage', 'details', 'usage details'].includes(args)) return 'details'
102 if (['refresh', 'usage refresh'].includes(args)) return 'refresh'
103 return args === 'help' ? 'help' : 'invalid'
104}
105
106/** @param {string} invocation @param {boolean} [invalid] */
107export function renderUsageHelp(invocation, invalid = false) {
108 return [
109 ...(invalid ? ['Unknown arguments.'] : []),
110 `Run \`${invocation}\` to see your usage.`,
111 `Run \`${invocation} refresh\` to update it.`,
112 ].join('\n')
113}
114../../packages/antiburn-plugin-core/core.mjs 117 lines1export const VERSION = '0.2.0'
2export const CONTRACT_VERSION = 1
3export const HOSTS = ['claude-code', 'codex', 'cursor', 'opencode', 'pi']
4
5/** @typedef {'plain' | 'markdown' | 'ascii' | 'flame'} Style */
6/** @typedef {{state: 'found' | 'not-found' | 'unavailable'}} Availability */
7/** @param {string} input @returns {{command: 'help'|'status'|'usage', style: Style}} */
8export function parseCommand(input = '') {
9 if (typeof input !== 'string' || input.length > 512)
10 return { command: 'usage', style: 'plain' }
11 const [command = 'help', flag, ...rest] = input.trim().split(/\s+/).filter(Boolean)
12 const style = flag === '--ascii' ? 'ascii' : flag === '--flame' ? 'flame' : 'plain'
13 if (
14 rest.length ||
15 (flag && flag !== '--ascii' && flag !== '--flame') ||
16 !['help', 'status'].includes(command)
17 )
18 return { command: 'usage', style }
19 return { command: /** @type {'help'|'status'} */ (command), style }
20}
21
22/** @param {string} host @param {string} invocation @param {'native'|'model-mediated'} mode @param {string} surface @param {string} [hostVersion] */
23export function describe(host, invocation, mode, surface = 'unknown', hostVersion = 'unknown') {
24 return {
25 id: 'antiburn',
26 version: VERSION,
27 contractVersion: CONTRACT_VERSION,
28 host: {
29 id: HOSTS.includes(host) ? host : 'unknown',
30 version: clean(hostVersion),
31 surface: clean(surface),
32 },
33 capabilities: {
34 invocationMode: mode,
35 invocation: clean(invocation),
36 output: clean(surface),
37 composition: host === 'claude-code',
38 },
39 certification: 'unverified',
40 }
41}
42
43/** @param {string} text */
44function clean(text) {
45 return String(text)
46 .replace(/[\x00-\x1f\x7f-\x9f]/g, '')
47 .slice(0, 128)
48}
49
50/** @param {{nativeColor?: boolean, markdown?: boolean, unicode?: boolean}} capability @returns {Style} */
51export function presentation(capability) {
52 if (capability.unicode === false) return 'ascii'
53 return capability.markdown ? 'markdown' : 'plain'
54}
55
56/** @param {Style} style */
57function brand(style) {
58 if (style === 'ascii') return '`antiburn`'
59 return '🔥 `antiburn`'
60}
61
62/** @param {Availability} result @param {Style} [style] */
63export function renderNotice(result, style = 'plain') {
64 const state =
65 result.state === 'found'
66 ? 'found'
67 : result.state === 'not-found'
68 ? 'not found'
69 : 'check unavailable'
70 return `${brand(style)}${style === 'ascii' ? ' ' : ' · '}CLI ${state}`
71}
72
73/** @param {ReturnType<typeof describe>} meta @param {Availability} result @param {Style} [style] */
74export function renderStatus(meta, result, style = 'plain') {
75 const state =
76 result.state === 'found'
77 ? 'Found'
78 : result.state === 'not-found'
79 ? 'Not found'
80 : 'Check unavailable'
81 return [
82 brand(style),
83 `CLI: ${state}`,
84 'App connection: Not checked',
85 `Shell: ${meta.version} (contract ${meta.contractVersion})`,
86 `Host: ${meta.host.id} ${meta.host.version}`,
87 `Surface: ${meta.host.surface}`,
88 `Invocation: ${meta.capabilities.invocation} (${meta.capabilities.invocationMode})`,
89 'Runtime certification: Unverified',
90 ].join('\n')
91}
92
93/** @param {ReturnType<typeof describe>} meta @param {Style} [style] @param {boolean} [usage] */
94export function renderHelp(meta, style = 'plain', usage = false) {
95 return [
96 brand(style),
97 ...(usage ? ['Unknown arguments.'] : []),
98 `Usage: ${meta.capabilities.invocation} [help|status] [--ascii|--flame]`,
99 'Checks executable availability without launching antiburn.',
100 'App connection: Not checked',
101 `Shell: ${VERSION} (contract ${CONTRACT_VERSION})`,
102 ].join('\n')
103}
104
105/** @param {Map<string, boolean>} state @param {string} id @param {() => Promise<unknown>} work */
106export async function sessionOnce(state, id, work) {
107 if (!id || state.has(id)) return false
108 if (state.size >= 128) state.delete(/** @type {string} */ (state.keys().next().value))
109 state.set(id, true)
110 try {
111 await work()
112 } catch {
113 /* Keep the attempt marker when the host refuses output. */
114 }
115 return true
116}
117hooks/brand.ts 4 lines1export const APP_URL = 'https://antiburn.com'
2
3export const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
4hooks/checks.ts 800 lines1import type { LiveCheckId, LiveCheckView, LiveStage } from '../types'
2
3import { CATALOG } from './catalog'
4
5export type CheckId = LiveCheckId
6export type CheckView = LiveCheckView
7
8export type StepUsage = {
9 input_tokens: number
10 cache_read_input_tokens: number
11 cache_creation_input_tokens: number
12}
13
14export type LiveView = {
15 checks: CheckView[]
16 newIds: CheckId[]
17 /** The checks that resolved since the last view, in the order they resolved. */
18 resolvedNow: CheckId[]
19}
20
21type Outcome =
22 | { kind: 'hit'; metric: string; prompt?: string; cause?: string; sentence?: string; count: number }
23 | { kind: 'building'; progress: string }
24 | { kind: 'quiet' }
25 | { kind: 'clear' }
26 | { kind: 'none' }
27
28type Entry = {
29 stage: LiveStage
30 metric: string
31 prompt: string
32 progress: string | null
33 cause: string | null
34 /** A sentence for this hit in place of the catalog sentence, or null. */
35 sentence: string | null
36 step: number
37 watchLeft: number
38 isHidden: boolean
39 isStoppedAfterFix: boolean
40}
41
42const MAIN = 'main'
43// K1 says this when the request names the skill but the name is not at the start.
44const NAMED_SKILL_SENTENCE =
45 'You named this skill, but it did not run. A skill name only runs at the start of a message.'
46const LIVE = CATALOG.live
47const POLICY = CATALOG.policy
48const ABOVE_CAP: ReadonlySet<string> = new Set(POLICY.claude.effortAboveCap)
49const REPLACEMENTS: Readonly<Record<string, string>> = POLICY.modelReplacements
50const DEFINITIONS = [...CATALOG.checks].sort((a, b) => a.order - b.order)
51
52/** Tools that only read files. A Bash call counts when core marks it read-only, or by its program. */
53const READ_TOOLS: ReadonlySet<string> = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead'])
54const EDIT_TOOLS: ReadonlySet<string> = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
55const READ_PROGRAMS: ReadonlySet<string> = new Set([
56 'cat', 'head', 'tail', 'less', 'nl', 'wc', 'ls', 'tree', 'find', 'fd', 'rg', 'grep', 'ag', 'sed',
57])
58/** A rough count of characters per token, for an estimate of the read output in the context. */
59const CHARS_PER_TOKEN = 4
60
61/**
62 * Returns true when each part of a shell command only reads. `sed` counts only with `-n`
63 * and without `-i`. A command with a redirect into a file does not count.
64 */
65export function isReadCommand(command: string): boolean {
66 if (/>(?!&)/.test(command.replace(/2>&1|>\s*\/dev\/null/g, ''))) return false
67 const parts = command.split(/&&|\|\||;|\|/).map(part => part.trim()).filter(part => part !== '')
68 if (parts.length === 0) return false
69 return parts.every(part => {
70 const words = part.split(/\s+/)
71 const program = words[0]?.replace(/^.*\//, '') ?? ''
72 if (!READ_PROGRAMS.has(program)) return false
73 if (program !== 'sed') return true
74 return words.includes('-n') && !words.some(word => word.startsWith('-i'))
75 })
76}
77
78/** One installed skill or command, as `$.command.list()` gives it. */
79export type SkillInfo = { name: string; description: string }
80
81/** Words too common to tell two skills apart. */
82const STOP_WORDS: ReadonlySet<string> = new Set([
83 'about', 'after', 'again', 'agent', 'any', 'before', 'claude', 'code', 'command', 'create', 'every',
84 'files', 'first', 'from', 'into', 'make', 'other', 'should', 'skill', 'their', 'there', 'these',
85 'thing', 'this', 'when', 'where', 'which', 'while', 'with', 'without', 'would', 'your', 'user',
86 'users', 'using', 'asks', 'wants', 'something', 'anything', 'triggers', 'trigger',
87])
88
89function words(text: string): string[] {
90 return text.toLowerCase().split(/[^a-z0-9]+/).filter(word => word.length >= 5 && !STOP_WORDS.has(word))
91}
92
93/**
94 * Gives the installed skill that a request matches, or null. A match needs the skill's
95 * name in the request (`/name`, or a name with a dash in words), or 3 or more of the
96 * keywords in its description. The gates are hypotheses until Phase 0 measures the noise.
97 */
98export function matchSkill(request: string, skills: readonly SkillInfo[]): SkillInfo | null {
99 const lower = request.toLowerCase()
100 const said = new Set(words(request))
101 let best: { skill: SkillInfo; score: number } | null = null
102 for (const skill of skills) {
103 const name = skill.name.toLowerCase()
104 const bare = name.slice(name.lastIndexOf(':') + 1)
105 const isNamed =
106 new RegExp(`(^|\\s)/${bare.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`).test(lower) ||
107 (bare.includes('-') && lower.includes(bare.replace(/-/g, ' ')))
108 if (isNamed) return skill
109 const score = new Set(words(skill.description).filter(word => said.has(word))).size
110 if (score >= LIVE.skillMinKeywords && (best === null || score > best.score)) best = { skill, score }
111 }
112 return best?.skill ?? null
113}
114
115// Claude Code and its hosts add context blocks to a message, such as <system-reminder>.
116// The person did not write them, so K1 does not match skills on them or send them again.
117const HOST_BLOCK =
118 /<(system-reminder|artifact-view-context|command-name|command-message|command-args|local-command-stdout|local-command-caveat)\b[^>]*>[\s\S]*?<\/\1>/g
119
120/** Removes the context blocks that Claude Code and its hosts add to a message. */
121export function withoutHostBlocks(text: string): string {
122 return text.replace(HOST_BLOCK, '').trim()
123}
124
125/** Gives a pattern for `/name` as a word in a request. */
126function slashPattern(name: string): RegExp {
127 const bare = name.toLowerCase().slice(name.lastIndexOf(':') + 1)
128 return new RegExp(`(^|\\s)/${bare.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=\\s|$)`, 'gi')
129}
130
131/**
132 * True when the request names the skill as `/name` after its first word. Claude Code runs
133 * a skill name only at the start of a message, so a later name is plain text.
134 */
135export function namesSkillLate(request: string, name: string): boolean {
136 const trimmed = request.trimStart()
137 return !trimmed.toLowerCase().startsWith(`/${name.toLowerCase()}`) && slashPattern(name).test(trimmed)
138}
139
140/** Gives a short name for a shell command, such as `cargo test` or `npm run build`. */
141export function commandName(command: string): string | null {
142 const first = command.split(/&&|\|\||;|\|/)[0]?.trim() ?? ''
143 const parts = first.split(/\s+/).filter(word => !/^[A-Z_]+=/.test(word))
144 const program = parts[0]?.replace(/^.*\//, '') ?? ''
145 if (program === '' || program === 'cd' || program === 'echo' || READ_PROGRAMS.has(program)) return null
146 const rest = parts.slice(1).filter(word => !word.startsWith('-'))
147 const depth = ['npm', 'pnpm', 'yarn', 'bun'].includes(program) && rest[0] === 'run' ? 2 : 1
148 return [program, ...rest.slice(0, depth)].join(' ')
149}
150
151/** True for a test file, which a plan does not need to name. */
152function isTestPath(path: string): boolean {
153 return /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[a-z]+$/.test(path)
154}
155
156/** Gives the file or folder that a read names, or null when the call names none. */
157function readTarget(tool: string, input: Readonly<Record<string, unknown>>): string | null {
158 for (const field of ['file_path', 'path', 'notebook_path']) {
159 const value = input[field]
160 if (typeof value === 'string' && value !== '') return value
161 }
162 if (tool === 'Grep' || tool === 'Glob' || tool === 'LS') return '.'
163 if (tool !== 'Bash' || typeof input.command !== 'string') return null
164 const words = input.command.split(/\s+/).slice(1)
165 return words.find(word => !word.startsWith('-') && /[/.]/.test(word)) ?? null
166}
167
168/** Gives the same key as `canonical_model_key` in the Rust pricing module. */
169export function canonicalModel(model: string): string {
170 const trimmed = model.trim()
171 const slash = trimmed.indexOf('/')
172 let key = trimmed
173 if (slash >= 0) key = trimmed.slice(slash + 1)
174 else if (trimmed.startsWith('openai.')) key = trimmed.slice('openai.'.length)
175 else if (trimmed.startsWith('antigravity-')) key = trimmed.slice('antigravity-'.length)
176 const dated = /^(.+)-\d{8}$/.exec(key)
177 if (dated?.[1] !== undefined && key.length > 9) key = dated[1]
178 return key.toLowerCase()
179}
180
181export function replacementFor(model: string): string | undefined {
182 return REPLACEMENTS[canonicalModel(model)]
183}
184
185function stepFor(count: number): number {
186 return LIVE.retrySteps.filter(step => count >= step).length
187}
188
189function thousands(tokens: number): string {
190 return `${Math.round(tokens / 1000)}k`
191}
192
193/**
194 * Keeps the live check state for one session. Every method is synchronous
195 * and does no I/O, so a hook can call it on each step at almost no cost.
196 */
197export class LiveChecks {
198 private readonly entries = new Map<CheckId, Entry>()
199 private readonly failures = new Map<string, Map<string, { count: number; tool: string }>>()
200 private retryPeak = { count: 0, tool: '', thread: MAIN }
201 private turns = 0
202 private isTurnRunning = false
203 private maxDepth = 0
204 private lastDepth = 0
205 private highReadings = 0
206 private lastPercent = 0
207 private mainModel = ''
208 private lastEffort: string | null = null
209 private effortRequests = 0
210 private aboveCap = 0
211 private topTier = ''
212 /** E1 counts for the current main turn. */
213 private turnReads = 0
214 private turnEdits = 0
215 private readonly turnPaths = new Set<string>()
216 /** E2: the characters of read output in the main context since the last compact. */
217 private readChars = 0
218 /** K1: the current request, held in memory only, and the skills and commands that ran for it. */
219 private request = ''
220 private isNewRequest = false
221 private turnSteps = 0
222 private readonly skillsRun = new Set<string>()
223 private skillMatch: SkillInfo | null = null
224 private k1Skill = ''
225 /** C1: the approved plan, the text that names allowed files, and the files outside it. */
226 private planText: string | null = null
227 private readonly outsidePlan = new Set<string>()
228 private turnOutside = 0
229 private planCleanTurns = 0
230 /** K2: the commands of the current turn, and how often each set of commands ran. */
231 private readonly turnCommands: string[] = []
232 private readonly workflows = new Map<string, number>()
233 private workflowPeak: { commands: string; count: number } | null = null
234 private hasSkillFile = false
235 private newIds = new Set<CheckId>()
236 /** Fill actions that the person pressed but whose result no request shows yet. */
237 private readonly pendingFills = new Set<CheckId>()
238 private resolvedNow: CheckId[] = []
239 /** Increments each time the visible state changes. */
240 version = 0
241
242 toolCall(thread: string | undefined, tool: string, key: string, isError: boolean): void {
243 const id = thread ?? MAIN
244 let calls = this.failures.get(id)
245 if (calls === undefined) {
246 calls = new Map()
247 this.failures.set(id, calls)
248 }
249 if (!isError) {
250 calls.delete(key)
251 return
252 }
253 const seen = calls.get(key)
254 calls.set(key, { count: (seen?.count ?? 0) + 1, tool })
255 }
256
257 /** Records one finished tool call for E1 and E2. Only the main thread fills the main context. */
258 toolResult(
259 thread: string | undefined,
260 tool: string,
261 input: Readonly<Record<string, unknown>>,
262 isReadOnly: boolean,
263 isError: boolean,
264 resultChars: number,
265 ): void {
266 if (thread !== undefined) return
267 if (tool === 'Skill' && !isError && typeof input.skill === 'string') this.skillsRun.add(input.skill.toLowerCase())
268 if (EDIT_TOOLS.has(tool)) {
269 if (!isError) this.edited(input)
270 return
271 }
272 if (tool === 'Bash' && !isError && typeof input.command === 'string') {
273 const name = commandName(input.command)
274 if (name !== null && !this.turnCommands.includes(name)) this.turnCommands.push(name)
275 }
276 const command = typeof input.command === 'string' ? input.command : ''
277 const isRead = READ_TOOLS.has(tool) || (tool === 'Bash' && (isReadOnly || isReadCommand(command)))
278 if (!isRead) return
279 this.readChars += resultChars
280 if (isError) return
281 this.turnReads += 1
282 const target = readTarget(tool, input)
283 if (target !== null && this.turnPaths.size < 1000) this.turnPaths.add(target)
284 }
285
286 private edited(input: Readonly<Record<string, unknown>>): void {
287 this.turnEdits += 1
288 const path = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : ''
289 if (path === '') return
290 if (/\/skills\/[^/]+\/SKILL\.md$/.test(path)) this.hasSkillFile = true
291 if (this.planText === null || isTestPath(path) || this.outsidePlan.has(path)) return
292 const base = path.slice(path.lastIndexOf('/') + 1)
293 if (this.planText.includes(base)) return
294 this.outsidePlan.add(path)
295 this.turnOutside += 1
296 }
297
298 /** Records an approved plan. Files that the plan or a later request names count as in the plan. */
299 planApproved(plan: string): void {
300 this.planText = `${plan}\n${this.request}`
301 this.outsidePlan.clear()
302 this.planCleanTurns = 0
303 }
304
305 /**
306 * Records a request from the person. A check's own prompt does not count as a new
307 * request. Returns true when the view changed.
308 */
309 prompt(message: string): boolean {
310 const text = withoutHostBlocks(message)
311 const own = DEFINITIONS.some(definition => definition.prompt === text)
312 // K1 asks about the skills of this request only, so a new request starts with none.
313 if (!own) this.skillsRun.clear()
314 const slash = /^\/([^\s]+)/.exec(text)?.[1]?.toLowerCase()
315 if (slash !== undefined) {
316 this.skillsRun.add(slash)
317 if (this.pendingFills.has('K1') && slash === this.k1Skill) this.fillTookEffect('K1')
318 }
319 if (this.pendingFills.has('C1') && text.trim() === (DEFINITIONS.find(check => check.id === 'C1')?.prompt ?? '')) {
320 this.fillTookEffect('C1')
321 return true
322 }
323 // The K2 prompt names the repeated commands, so it matches the check's own prompt, not the template.
324 const workflow = this.entries.get('K2')
325 if (this.pendingFills.has('K2') && workflow !== undefined && text.trim() === workflow.prompt.trim()) {
326 this.fillTookEffect('K2')
327 return true
328 }
329 if (own) return false
330 this.request = text.slice(0, 2000)
331 this.isNewRequest = true
332 if (this.planText !== null) this.planText += `\n${this.request}`
333 const before = this.version
334 // A new request changes the task, so the checks about the last request go quiet.
335 if (this.entries.get('K1')?.stage === 'active') this.apply('K1', { kind: 'quiet' })
336 if (this.entries.get('C1')?.stage === 'active') {
337 this.apply('C1', { kind: 'quiet' })
338 this.outsidePlan.clear()
339 }
340 return this.version !== before
341 }
342
343 /**
344 * True when K1 needs the installed skills: a long turn for a request that names no
345 * skill and ran none. The caller then reads `$.command.list()` once and calls `skills`.
346 */
347 needsSkills(): boolean {
348 return (
349 this.isNewRequest &&
350 this.turnSteps >= LIVE.skillMinSteps &&
351 this.request !== '' &&
352 !this.request.startsWith('/') &&
353 this.skillsRun.size === 0
354 )
355 }
356
357 skills(list: readonly SkillInfo[]): void {
358 this.skillMatch = matchSkill(this.request, list)
359 }
360
361 step(thread: string | undefined, model: string, effort: unknown, usage: StepUsage | null): void {
362 if (thread !== undefined) return
363 this.isTurnRunning = true
364 this.turnSteps += 1
365 this.mainModel = model
366 if (usage !== null) {
367 const depth =
368 usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
369 this.lastDepth = depth
370 this.maxDepth = Math.max(this.maxDepth, depth)
371 }
372 if (this.pendingFills.has('O') && replacementFor(model) === undefined) this.fillTookEffect('O')
373 // A numeric effort has no tier name, so T does not count it.
374 if (typeof effort !== 'string') return
375 if (this.pendingFills.has('T') && !ABOVE_CAP.has(effort)) this.fillTookEffect('T')
376 this.lastEffort = effort
377 this.effortRequests += 1
378 if (ABOVE_CAP.has(effort)) {
379 this.aboveCap += 1
380 this.topTier = effort
381 }
382 }
383
384 /** Records the end of one thread's turn. Returns true when the view changed. */
385 turnEnd(thread: string | undefined): boolean {
386 const id = thread ?? MAIN
387 for (const call of this.failures.get(id)?.values() ?? []) {
388 if (call.count > this.retryPeak.count) this.retryPeak = { ...call, thread: id }
389 }
390 this.failures.delete(id)
391 if (thread !== undefined) return false
392 this.turns += 1
393 this.isTurnRunning = false
394 const before = this.version
395 this.apply('X1', this.retryOutcome())
396 this.apply('E1', this.readingOutcome())
397 this.apply('D', this.contextOutcome())
398 this.apply('O', this.modelOutcome())
399 this.apply('T', this.effortOutcome())
400 this.apply('K1', this.skillOutcome())
401 this.apply('C1', this.planOutcome())
402 this.apply('K2', this.workflowOutcome())
403 this.retryPeak = { count: 0, tool: '', thread: MAIN }
404 this.maxDepth = 0
405 this.turnReads = 0
406 this.turnEdits = 0
407 this.turnPaths.clear()
408 this.turnSteps = 0
409 this.turnOutside = 0
410 this.turnCommands.length = 0
411 this.skillMatch = null
412 this.isNewRequest = false
413 return this.version !== before
414 }
415
416 measure(percent: number | undefined): boolean {
417 if (percent === undefined) return false
418 this.lastPercent = percent
419 this.highReadings = percent >= LIVE.contextPercent ? this.highReadings + 1 : 0
420 if (this.isTurnRunning) return false
421 const before = this.version
422 this.apply('D', this.contextOutcome())
423 return this.version !== before
424 }
425
426 compact(): boolean {
427 this.maxDepth = 0
428 this.lastDepth = 0
429 this.highReadings = 0
430 this.readChars = 0
431 const before = this.version
432 this.apply('D', { kind: 'clear' })
433 return this.version !== before
434 }
435
436 fixSent(id: CheckId): void {
437 const entry = this.entries.get(id)
438 if (entry === undefined || entry.stage !== 'active') return
439 entry.stage = 'watching'
440 entry.watchLeft = LIVE.watchTurns
441 this.version += 1
442 }
443
444 /** Returns a watched check to active when its fix did not run. */
445 unwatch(id: CheckId): void {
446 const entry = this.entries.get(id)
447 if (entry === undefined || entry.stage !== 'watching') return
448 entry.stage = 'active'
449 this.version += 1
450 }
451
452 /**
453 * Records a press on a fill action. The person can still edit or discard the
454 * filled text, so watching starts only when a request shows the change.
455 */
456 fillPressed(id: CheckId): void {
457 if (this.entries.get(id)?.stage === 'active') this.pendingFills.add(id)
458 }
459
460 private fillTookEffect(id: CheckId): void {
461 this.pendingFills.delete(id)
462 this.fixSent(id)
463 }
464
465 /** Sets an advice check quiet after the person reads it. A later match shows it again. */
466 acknowledge(id: CheckId): void {
467 if (this.entries.get(id)?.stage === 'active') this.apply(id, { kind: 'quiet' })
468 }
469
470 hide(id: CheckId): void {
471 const entry = this.entries.get(id)
472 if (entry === undefined || entry.isHidden) return
473 entry.isHidden = true
474 this.version += 1
475 }
476
477 /** Returns the view and clears the one-time `newIds` and `resolvedNow` values. */
478 takeView(): LiveView {
479 const checks: CheckView[] = []
480 for (const definition of DEFINITIONS) {
481 const entry = this.entries.get(definition.id)
482 if (entry === undefined) continue
483 checks.push({
484 id: definition.id,
485 label: definition.label,
486 area: definition.area,
487 sentence: entry.sentence ?? definition.sentence,
488 stage: entry.stage,
489 metric: entry.metric,
490 progress: entry.progress,
491 cause: entry.cause,
492 actionKind: definition.action.kind,
493 actionLabel: definition.action.label,
494 prompt: entry.prompt,
495 isHidden: entry.isHidden,
496 isStoppedAfterFix: entry.isStoppedAfterFix,
497 })
498 }
499 const view = { checks, newIds: [...this.newIds], resolvedNow: [...this.resolvedNow] }
500 this.newIds = new Set()
501 this.resolvedNow = []
502 return view
503 }
504
505 private retryOutcome(): Outcome {
506 const { count, tool, thread } = this.retryPeak
507 if (count < LIVE.retryMinRepeats) return { kind: 'clear' }
508 const where = thread === MAIN ? '' : ' · subagent'
509 return { kind: 'hit', metric: `${count} tries · ${tool}${where}`, count }
510 }
511
512 private skillOutcome(): Outcome {
513 const entry = this.entries.get('K1')
514 if (entry !== undefined && this.skillsRun.has(this.k1Skill)) return { kind: 'clear' }
515 const match = this.skillMatch
516 if (match === null) return { kind: 'none' }
517 this.k1Skill = match.name.toLowerCase()
518 const definition = DEFINITIONS.find(check => check.id === 'K1')
519 const isNamed = namesSkillLate(this.request, match.name)
520 return {
521 kind: 'hit',
522 metric: isNamed ? `/${match.name} named · not run` : `/${match.name} fits · ${this.turnSteps} steps`,
523 prompt: (definition?.prompt ?? '').replace('{skill}', match.name),
524 ...(isNamed ? { sentence: NAMED_SKILL_SENTENCE } : {}),
525 count: 1,
526 }
527 }
528
529 private planOutcome(): Outcome {
530 if (this.planText === null) return { kind: 'none' }
531 const stage = this.entries.get('C1')?.stage
532 if (stage === 'active' || stage === 'watching') {
533 if (this.turnOutside > 0) return this.planHit()
534 this.planCleanTurns += 1
535 return this.planCleanTurns >= LIVE.planCleanTurns ? { kind: 'clear' } : { kind: 'none' }
536 }
537 if (this.outsidePlan.size < LIVE.planMinFiles) return { kind: 'none' }
538 return this.planHit()
539 }
540
541 private planHit(): Outcome {
542 this.planCleanTurns = 0
543 return { kind: 'hit', metric: `${this.outsidePlan.size} files outside the plan`, count: 1 }
544 }
545
546 private workflowOutcome(): Outcome {
547 if (this.hasSkillFile && this.entries.has('K2')) return { kind: 'clear' }
548 if (this.turnCommands.length >= LIVE.workflowMinCommands) {
549 const key = this.turnCommands.join(', ')
550 const count = (this.workflows.get(key) ?? 0) + 1
551 if (this.workflows.size < 200 || this.workflows.has(key)) this.workflows.set(key, count)
552 if (count >= LIVE.workflowMinRepeats && count > (this.workflowPeak?.count ?? 0)) {
553 this.workflowPeak = { commands: key, count }
554 }
555 }
556 const peak = this.workflowPeak
557 if (peak === null) return { kind: 'none' }
558 const definition = DEFINITIONS.find(check => check.id === 'K2')
559 return {
560 kind: 'hit',
561 metric: `same ${peak.commands.split(', ').length} commands × ${peak.count}`,
562 prompt: (definition?.prompt ?? '').replace('{commands}', peak.commands),
563 count: 1,
564 }
565 }
566
567 private readingOutcome(): Outcome {
568 if (this.turnEdits > 0) return { kind: 'clear' }
569 const paths = this.turnPaths.size
570 if (this.turnReads < LIVE.readMinCalls || paths < LIVE.readMinPaths) return { kind: 'none' }
571 return { kind: 'hit', metric: `${this.turnReads} reads · 0 edits · ${paths} files`, count: 1 }
572 }
573
574 private contextOutcome(): Outcome {
575 const cap = POLICY.depthCapTokens
576 const depth = Math.max(this.maxDepth, this.lastDepth)
577 if (depth > cap) {
578 return { kind: 'hit', metric: `${thousands(depth)} tokens per request`, count: 1, ...this.readCause(depth) }
579 }
580 if (this.highReadings >= LIVE.contextReadings) {
581 return { kind: 'hit', metric: `${this.lastPercent}% of context used`, count: 1, ...this.readCause(depth) }
582 }
583 return { kind: 'clear' }
584 }
585
586 /**
587 * E2: names file reads as the cause of a heavy context when their output is most of it.
588 * The share is an estimate from the characters of read output, so the line says "about".
589 */
590 private readCause(depth: number): { cause?: string; prompt?: string } {
591 const tokens = this.readChars / CHARS_PER_TOKEN
592 if (depth <= 0 || tokens / depth < LIVE.readMinShare) return {}
593 const share = Math.min(99, Math.round((tokens / depth) * 100))
594 const definition = DEFINITIONS.find(check => check.id === 'D')
595 const extra = definition !== undefined && 'readsPrompt' in definition ? definition.readsPrompt : ''
596 return {
597 cause: `File reads are about ${share}% of it (${thousands(tokens)} tokens).`,
598 prompt: `${definition?.prompt ?? ''}${extra}`,
599 }
600 }
601
602 private modelOutcome(): Outcome {
603 if (this.mainModel === '') return { kind: 'none' }
604 const replacement = replacementFor(this.mainModel)
605 if (replacement === undefined) return { kind: 'clear' }
606 // A switch is cheap only before the context grows (plan decision 2).
607 if (this.turns > 1) return { kind: 'quiet' }
608 const definition = DEFINITIONS.find(check => check.id === 'O')
609 return {
610 kind: 'hit',
611 metric: `${shortModel(this.mainModel)} → ${shortModel(replacement)}`,
612 prompt: (definition?.prompt ?? '').replace('{replacement}', replacement),
613 count: 1,
614 }
615 }
616
617 private effortOutcome(): Outcome {
618 if (this.effortRequests === 0 || this.aboveCap === 0) return { kind: 'none' }
619 if (this.lastEffort !== null && !ABOVE_CAP.has(this.lastEffort)) return { kind: 'clear' }
620 const share = this.aboveCap / this.effortRequests
621 const need = LIVE.effortMinRequests
622 if (this.turns >= LIVE.graceTurns && this.aboveCap >= need && share >= LIVE.effortMinShare) {
623 return {
624 kind: 'hit',
625 metric: `${this.aboveCap} of ${this.effortRequests} requests at ${this.topTier}`,
626 count: 1,
627 }
628 }
629 // The progress names the first condition that is not met. When only the share is low, more
630 // turns do not help, so the progress shows the share.
631 const percent = (value: number) => `${Math.round(value * 100)}%`
632 const progress =
633 this.aboveCap < need
634 ? `${this.aboveCap} of ${need} requests`
635 : this.turns < LIVE.graceTurns
636 ? `turn ${this.turns} of ${LIVE.graceTurns}`
637 : `${percent(share)} of requests at ${this.topTier}, needs ${percent(LIVE.effortMinShare)}`
638 return { kind: 'building', progress }
639 }
640
641 private apply(id: CheckId, outcome: Outcome): void {
642 const entry = this.entries.get(id)
643 const definition = DEFINITIONS.find(check => check.id === id)
644 const basePrompt = definition?.prompt ?? ''
645 if (outcome.kind === 'none') return
646 if (outcome.kind === 'hit') {
647 const prompt = outcome.prompt ?? basePrompt
648 const cause = outcome.cause ?? null
649 const step = stepFor(outcome.count)
650 if (entry === undefined) {
651 this.entries.set(id, {
652 stage: 'active',
653 metric: outcome.metric,
654 prompt,
655 progress: null,
656 cause,
657 sentence: outcome.sentence ?? null,
658 step,
659 watchLeft: 0,
660 isHidden: false,
661 isStoppedAfterFix: false,
662 })
663 this.mark(id)
664 return
665 }
666 const isStepUp = step > entry.step
667 const wasLive = entry.stage === 'active' || entry.stage === 'watching'
668 const isSame =
669 entry.stage === 'active' &&
670 entry.metric === outcome.metric &&
671 entry.prompt === prompt &&
672 entry.cause === cause &&
673 entry.sentence === (outcome.sentence ?? null)
674 if (isSame && !isStepUp) return
675 entry.metric = outcome.metric
676 entry.prompt = prompt
677 entry.cause = cause
678 entry.sentence = outcome.sentence ?? null
679 entry.progress = null
680 entry.step = Math.max(entry.step, step)
681 if (entry.stage === 'watching') {
682 entry.watchLeft -= 1
683 if (entry.watchLeft <= 0) entry.stage = 'active'
684 } else {
685 entry.stage = 'active'
686 }
687 if (!wasLive || isStepUp) this.mark(id)
688 this.version += 1
689 return
690 }
691 if (outcome.kind === 'building') {
692 if (entry !== undefined && entry.stage !== 'building') return
693 if (entry?.progress === outcome.progress) return
694 this.entries.set(id, {
695 stage: 'building',
696 metric: '',
697 prompt: basePrompt,
698 progress: outcome.progress,
699 cause: null,
700 sentence: null,
701 step: 0,
702 watchLeft: 0,
703 isHidden: entry?.isHidden ?? false,
704 isStoppedAfterFix: false,
705 })
706 this.version += 1
707 return
708 }
709 if (entry === undefined) return
710 if (outcome.kind === 'quiet') {
711 if (entry.stage !== 'active' && entry.stage !== 'watching') return
712 entry.stage = 'quiet'
713 this.version += 1
714 return
715 }
716 if (entry.stage === 'building') {
717 this.entries.delete(id)
718 this.version += 1
719 return
720 }
721 if (entry.stage === 'resolved') return
722 entry.isStoppedAfterFix = entry.stage === 'watching'
723 entry.stage = 'resolved'
724 this.resolvedNow.push(id)
725 this.version += 1
726 }
727
728 private mark(id: CheckId): void {
729 this.newIds.add(id)
730 this.version += 1
731 }
732}
733
734/** The checks that the strip and the chip count (plan §12.5). */
735export function liveChecks(view: LiveView): CheckView[] {
736 return view.checks.filter(
737 check => !check.isHidden && (check.stage === 'active' || check.stage === 'watching'),
738 )
739}
740
741const DEMO_METRICS: Readonly<Record<(typeof DEFINITIONS)[number]['id'], string>> = {
742 X1: '3 tries · Bash',
743 E1: '24 reads · 0 edits · 17 files',
744 D: '182k tokens per request',
745 O: 'sonnet-4 → sonnet-4-5',
746 T: '6 of 6 requests at max',
747 K1: '/review-and-draft-pr fits · 14 steps',
748 C1: '4 files outside the plan',
749 K2: 'same 3 commands × 3',
750}
751
752const DEMO_FILLS: Readonly<Record<string, string>> = {
753 '{replacement}': 'claude-sonnet-4-5',
754 '{skill}': 'review-and-draft-pr',
755 '{commands}': 'cargo test, npm run lint, git commit',
756}
757
758/**
759 * The sample view for a visual test of the pane. It shows each built check once and
760 * each stage that the list shows: a fill, a submit and a compact fix, a watched check,
761 * a check that its fix resolved and a check that resolved by itself. The pane hides building checks, so no card builds.
762 */
763export function demoView(): CheckView[] {
764 const stages: Record<(typeof DEFINITIONS)[number]['id'], LiveStage> = {
765 X1: 'watching',
766 E1: 'active',
767 D: 'active',
768 O: 'resolved',
769 T: 'active',
770 K1: 'active',
771 C1: 'resolved',
772 K2: 'active',
773 }
774 return DEFINITIONS.map(definition => {
775 const stage = stages[definition.id]
776 return {
777 id: definition.id,
778 label: definition.label,
779 area: definition.area,
780 sentence: definition.sentence,
781 stage,
782 metric: DEMO_METRICS[definition.id],
783 progress: null,
784 cause: definition.id === 'D' ? 'File reads are about 61% of it (111k tokens).' : null,
785 actionKind: definition.action.kind,
786 actionLabel: definition.action.label,
787 prompt: Object.entries(DEMO_FILLS).reduce<string>((text, [mark, value]) => text.replace(mark, value), definition.prompt ?? ''),
788 isHidden: false,
789 // O shows a resolve by its fix, C1 a resolve by itself.
790 isStoppedAfterFix: stage === 'resolved' && definition.id === 'O',
791 }
792 })
793}
794
795// The card metric drops the "claude-" prefix, so the model pair fits a narrow pane.
796// The detail and the /model command keep the full ID.
797export function shortModel(model: string): string {
798 return model.replace(/^claude-/, '')
799}
800