SLOPSHOPPER

antiburn-live

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

newpanebandspinnerguardcommand
★ 211v0.1.0MITupdated 2026-10-23antiburn/antiburn/plugins/antiburn-live
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · antiburn-live
│ ┃ debug ✕ › fix the failing auth test and add an audit log call │ ┃ Developer debug │ ┃ ⏺ Read(src/auth.ts) │ ┃ Mode 0 · Usage limits ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ │ ⏺ Bash(bun test) │ ┃ │ Checks ⎿ 3 pass, 1 fail │ ┃ │ │ ┃ │ Mode changes apply to this session. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Reload restores ANTIBURN_LIVE_MODE. │ ┃ │ Checks start fresh after each change. ✻ Worked for 42s · done 4:20 PM │ ┃ │ │ ┃ │ [ Switch to Mode 1 · Usage limits + live c › /antiburn-dev │ ┃ │ ⎿ antiburn-live: Usage limits are shown above the prompt. │ ┃ ╰─────────────────────────────────────────── │ ┃ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ │ │ ┃ │ Rooted flame · output activity │ │ ┃ │ │ │ ┃ │ Burn previews and activity tracking are │ │ ┃ │ unavailable. │ │ ┃ │ │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ 5-hour: 31%. Reset unavailable▣ client module ./usage-target.tsx ● ● ● ● ● ● ● ● ● ● ● ● ● ● Open antiburn website ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5-hour: 31%. Reset unavailable▣ client module ./usage-target.tsx ● ● ● ● ● ● Open antiburn website
Pane · debug
Developer debug Mode 0 · Usage limits ╭──────────────────────────────────────────────────────────╮ │ │ │ Checks │ │ │ │ Mode changes apply to this session. Reload restores │ │ ANTIBURN_LIVE_MODE. Checks start fresh after each │ │ change. │ │ │ │ [ Switch to Mode 1 · Usage limits + live checks ] │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ │ │ Rooted flame · output activity │ │ │ │ Burn previews and activity tracking are unavailable. │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ │ │ Usage │ │ │ │ Layout: Label reveal · inline (default) │ │ │ │ [ inline (default) ] │ │ │ │ [ labels above meters ] │ │ │ │ Last reported by Claude Code │ │ │ │ 5-hour: 31% │ │ Reset unavailable │ │ │ │ Weekly: — │ │ Reset unavailable │ │ │ │ [ Refresh ] │ │ │ │ Live usage data │ │ │ │ Usage examples │ │ │ │ Preview account usage states │ │ │ │ [ Idle ] [ Checking ] [ Retry ] [ Success ] [ Stale ] │ │ │ │ [ Replay entrance ] │ │ │ │ Replays the current reading; uses Low usage when no │ │ usable reading exists. │ │ │ │ [ Play all usage examples ] ☐ Show synthetic labels │ │ │ │ Runs once: entrance, all examples, and reset and pace │ │ reveal/hide. Near limit holds for six pulse cycles. Burn │ │ previews are unavailable. │ │ │ │ Warning styles are previews only. Pulse runs six cycles; │ │ reduced motion stays static. │ │ │ │ [ Inactive session ] │ │ │ │ [ Low usage · 8% / 1% ] │ │ │ │ [ Mid usage · 48% / 36% ] │ │ │ │ [ Beyond pace · 68% / 58% · red ] │ │ │ │ [ High usage · 88% / 82% · red ] │ │ │ │ [ Near limit · 98% / 96% · pulse + glow ] │ │ │ │ [ Numeric roll · 9% ] │ │ │ │ [ Numeric roll · 10% ] │ │ │ │ [ Numeric roll · 99% ] │ │ │ │ [ Numeric roll · 100% ] │ │ │ │ [ No reading yet ] │ │ │ │ [ Read error · last known 48% / 36% ] │ │ │ ╰──────────────────────────────────────────────────────────╯
README

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.

antiburn development source

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.

Install and run

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.

Surfaces

Persistent usage

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.

Optional checks (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.

Checks pane and indicator

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.

Commands

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.

CommandAction
/antiburn-devOpens live checks in checks mode. In usage mode, confirms that limits are shown above the prompt without opening a pane.
/antiburn-dev checksOpens the checks pane in checks mode.
/antiburn-dev demoShows 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 tourStarts a tour of 4 steps on an empty, separate set of checks, and opens the pane.
/antiburn-dev demo nextPlays the next tour step as one turn. The Next step button in the pane does the same.
/antiburn-dev demo offEnds the sample view or the tour, and shows the live checks again.

Any unrecognized argument replies with the usage line.

The tour steps are:

  1. A retry loop is the first warning (X1).
  2. Three more checks arrive (T, E1, K2). The tour dismisses the strip first, so the strip comes back with (N new).
  3. The context gets heavy, and the retry loop stops (D with its cause, X1 resolves).
  4. A skill goes unused, and edits leave the plan (K1, C1).

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.

Runtime checks and development samples

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.

Privacy and resources

  • Evaluation. The app is the sole evaluator. The mod starts no provider evaluation, network request, model turn or idle polling loop for checks.
  • CLI reads. At session start and turn completion, the mod uses the host's sandboxed process API with an argument vector and bounded execution/output. It reads the versioned CLI response, applies UI state, then acknowledges the UI receipt. It never runs a shell or launches the desktop interface.
  • Data. The pane receives authored messages and safe result metadata, not YAML definitions, provider credentials or raw evidence. It validates session identity, main-session scope and protocol before applying results.
  • Persistence. UI and dismissal state belong to the local session. The CLI keeps bounded consumer receipt/fingerprint state separately from its read-only product database. It does not store message text in that state.
  • Independent usage. Native account-usage readings and usage interaction state retain their existing behavior. A check-connection failure does not stop those readings.
  • Failure. Bounded read errors retain last-known cards. They do not block a coding action, run a local detector or claim that a warning cleared.

Development

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.

File layout

PathContents
.claude-plugin/plugin.jsonThe manifest: name, version, description, and the state types.
hooks/hooks.jsonLoads register.tsx.
hooks/register.tsxThe hooks, the /antiburn-dev command, the strip, the chip, the pane, the app-result bridge and the demo.
hooks/checks.tsLiveChecks, retained rules and stages for development samples only. It takes no $ and does no I/O.
hooks/catalog.tsThe 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.tsThe 4 demo tour steps.
hooks/*.test.tsTests for the checks, the surfaces and the tour.
hooks/usage.tsPure usage normalization, pace calculations and read ordering.
hooks/usage-graphics.tsSVG typography, meter drawing and bounded CSS motion.
hooks/usage-presentation.tsShared geometry, timeline calculations and entrance ownership.
hooks/usage-font.tsBundled Bitcount glyph layout; license in assets/bitcount.
types/index.d.tsThe check view and the state types.
types/claude-code.d.tsNames the claude-code module for tools that read only the committed source.
tsconfig.jsonExtends the generated .claude-plugin/types/tsconfig.json.
.gitignoreIgnores .claude-plugin/types/.

Known interaction issues

The first click in the pane only moves the keyboard

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.

Developer usage previews

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.

Source 29 files
hooks/register.tsx 2406 lines
1import { 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 lines
1export type LiveMode = 'usage' | 'checks'
2
3export function liveMode(value: string | undefined): LiveMode {
4  return value === 'checks' ? 'checks' : 'usage'
5}
6
hooks/register-pane-view.tsx 117 lines
1import 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}
117
hooks/register-usage-details.tsx 61 lines
1import 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}
61
hooks/register-usage-view.tsx 398 lines
1import 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}
398
hooks/register-debug-view.tsx 286 lines
1import 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}
286
hooks/register-usage-bands.tsx 437 lines
1import 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}
437
hooks/register-visuals.tsx 1864 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 lines
1export 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 lines
1export 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}
117
hooks/brand.ts 4 lines
1export const APP_URL = 'https://antiburn.com'
2
3export const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
4
hooks/checks.ts 800 lines
1import 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