SLOPSHOPPER

antiburn

● ● ● ○ │ ○ ○ · Stop hitting your limits! Pace your Claude Code usage for five-hour and weekly limits across sessions. See usage and reset times at a glance…

newbandrowscommandstatusprocess
★ 211v0.2.5MITupdated 2026-10-23antiburn/antiburn/plugins/antiburn
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · antiburn
› fix the failing auth test and add an audit log call ● antiburn: antiburn: /antiburn could not be registered. ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /antiburn ⟨Claude Code's own drawing⟩ 5-hour: 31%. Reset unavailable▣ client module ./usage-target.tsx ● ● ● ● ● ● ● ● ● ● ● ● ● ● Open antiburn website ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 5-hour: 31%. Reset unavailable▣ client module ./usage-target.tsx ● ● ● ● ● ● Open antiburn website
README

Antiburn for Claude Code

Stop hitting your limits! Pace your Claude Code usage for five-hour and weekly limits across sessions. See usage and reset times at a glance, above your prompt in both desktop and terminal.

Antiburn shows account usage reported by Claude Code, including five-hour and weekly percentages, reset times and even-pace markers. Pace compares usage with the elapsed part of a limit window; it is a reference, not a forecast or a guarantee that a limit will not be reached. Missing readings stay unavailable.

Version 0.2.5 fills the available Desktop width while preserving glyph and dot size as the window changes. It also preserves other mods above the prompt and retains the existing Terminal rendering. The plugin version is independent of Antiburn desktop app versions.

Supported surfaces

Use Claude Code Terminal or the Code tab in Claude Desktop, with an enabled mod runtime. Ordinary Claude chat and Cowork are outside this integration. Account readings depend on the host version, login type and policy; API-key sessions do not provide the authenticated account fallback.

Historical 0.2.1 native checks covered Claude Code 2.1.304 and Claude Desktop 2.42304.2 on macOS. Those observations retain their original scope. The validation record distinguishes current packaging checks, historical runtime evidence and remaining native acceptance work. Other hosts and platforms need their own checks.

Preview

Desktop Code · usage states

Antiburn usage states above the prompt in Claude Desktop Code

Synthetic usage data illustrates the display states; this is not a current account reading.

Desktop Code · reset and pace details

Antiburn reset and pace details in Claude Desktop Code

Illustrative hover details from the public plugin guide. These presentation captures do not certify account freshness or release acceptance.

Terminal · usage states

Antiburn usage states above the prompt in Claude Code Terminal

Synthetic usage data illustrates the display states. Terminal meters stack when space is limited.

Terminal · reset and pace details

Antiburn reset and pace details in Claude Code Terminal

Illustrative hover details from the public plugin guide; not release acceptance evidence.

Install

Run these commands in your shell:

claude plugin marketplace add https://github.com/antiburn/antiburn.git
claude plugin install antiburn@antiburn --scope user

The equivalent one-step install on Claude Code 2.1.275 or later is:

/plugin install antiburn --marketplace antiburn/antiburn

The permanent marketplace follows main and selects an immutable artifact commit. It does not require a release branch, tag or GitHub Release. The distribution guide records promotion and submission status; a repository install is separate from Claude directory availability.

If you previously installed the owned standalone Antiburn findings skill, retire it in the selected scope before installing the plugin, following the migration instructions.

Open a new session after installing. In an existing session, run /reload-plugins; restart Claude Code if the mod does not appear. In Desktop, initialize a session in the Code tab. The blank new-session landing page has no active mod UI. No Antiburn configuration flag is required.

Usage

The strip starts automatically when the mod loads. It displays native host measurements first, including partial readings. After Desktop attaches, empty or unavailable native data can fall back to an authenticated account request through Claude Code. Terminal-only sessions use native measurements.

Command or controlAction
/antiburnShow usage, reset and pace details as text
/antiburn refreshCheck usage and return a short result
/antiburn helpExplain the usage and refresh commands
A meter's label or pace controlReveal reset or pace details
Refresh / ↻, when shownCheck the available usage sources

In Terminal, press Ctrl+X, then Tab to focus the strip. Use Tab to move between controls and Enter to activate one. Fullscreen terminal rendering supports pointer reveals; classic rendering retains the command and keyboard alternatives. Activate the logo to open https://antiburn.com.

Privacy

The public plugin reads host quota measurements and can request account usage from https://api.anthropic.com/api/oauth/usage through Claude Code's HTTP API. The host keeps the credential and supplies an opaque authorization handle. Usage state stays in memory in the host session; the plugin does not persist readings, read transcripts or credentials files, or send plugin telemetry. The account request is a GET with no body. Its explicit headers are anthropic-beta: oauth-2025-04-20 and accept: application/json. No prompt, conversation, project file, or quota content is included in the request. The plugin does not read credential files, the Keychain, or credential environment variables. Claude Code controls its own storage, logs, networking and retention.

The SVG namespace http://www.w3.org/2000/svg identifies local image markup; it is not a network destination. A separate constant supplies the namespace. Template expressions in the graphics source format local SVG values; they do not read environment variables.

Only activating the Terminal logo starts a program. Windows uses cmd.exe /c start "" https://antiburn.com; other systems try open https://antiburn.com, then xdg-open https://antiburn.com if needed. Each attempt has a five-second timeout. These commands open a fixed website; they do not download or execute code from it. Desktop uses a native link. No fetched value, prompt, or session content becomes a command argument.

The command.run hook handles Antiburn usage/help/refresh and reconciles quota after clear or resume. It forwards other commands without changes. The classic.SessionStart hook resets quota display after clear and forwards the host event unchanged. The engine.create hook adds Antiburn's description call. The AbovePrompt hook calls the downstream rendering chain once and preserves its returned UI before adding the usage strip. Composition requires no plugin identity or capability probe. Surveys and hidden bands pass through unchanged. The host scrolls the combined tree when it exceeds the available row budget.

Successful fallback readings are reused for 60 seconds. Concurrent refreshes share one request, and failures defer further account requests with backoff. Refresh still checks native readings while account requests are deferred. The plugin does not poll usage or submit model prompts. Read the bundled privacy description for destinations, authorization, retention and website activation details.

Troubleshooting

  • No strip: enable the plugin and mod runtime, initialize a supported session, then reload or restart the host. Managed policies can block mods.
  • Checking usage / Usage unavailable: the host may not have supplied data or account access may be unavailable. Try Refresh. Missing data is not zero usage.
  • Account retry at a time: refresh does not bypass account backoff. The displayed time is a retry boundary, not a live countdown.
  • Stale values: a failed request can retain the last reading with an unavailable/stale status. Do not treat those values as a fresh provider reading.
  • After clear or reload: lifecycle resets can remove the strip until the session initializes again. Last readings are not restored from a database.
  • Startup command warning: historical Desktop checks sometimes displayed “isn't a command here” despite a successful initial command. Its cause remains unverified. Reloading plugins or invoking help after initialization can help; neither is a guaranteed fix.

Desktop animations include reduced-motion CSS. The terminal SDK exposes no reduced-motion preference. Historical screenshots and synthetic previews do not certify every light/dark, narrow-layout or accessibility combination.

Update or remove

claude plugin marketplace update antiburn
claude plugin update antiburn@antiburn --scope user

Open a new session after updating. Use the permanent repository URL without a branch fragment. To remove the user-scoped plugin while retaining host-managed plugin data:

claude plugin uninstall antiburn@antiburn --scope user --keep-data

Reload or restart afterward. Removing the plugin does not erase sessions or other data retained independently by Claude Code.

Source, support and license

The public package is generated from an explicit allowlist. Contributors edit plugins/antiburn-live/release in the source repository and regenerate plugins/antiburn; do not edit the generated copy directly. The development guide covers local builds and checks. The local antiburn-dev variant and other agent adapters are outside this public package.

Published by Antiburn, owned by Cadence AI (Vic) Pty Ltd. See antiburn.com/plugin for the current guide, the changelog for versions, and GitHub issues for support. Include host/plugin versions and steps to reproduce; never attach credentials or private session content. For security or privacy concerns, follow the security reporting instructions.

Source is covered by the included MIT license. Bundled Bitcount lettering retains its SIL OFL notice.

Source 13 files
hooks/quota-controller.tsx 1252 lines
1import { atom, read, update } from "claude-code"
2import {
3  parseUsageCommand,
4  renderUsageHelp,
5} from "../lib/product.mjs"
6import type {
7  EngineInterface,
8  On,
9  Register,
10  RenderInput,
11  SessionAuthorization,
12} from "claude-code"
13import type { UsageState } from "../types"
14import { APP_URL } from "./brand"
15import { retryAfterTime, type AccountUsageResult } from "./account-usage"
16import {
17  filledDotCount,
18  normalizeUsage,
19  pace,
20  paceLabel,
21  percentage,
22  primaryWindow,
23  resetText,
24  terminalDotCount,
25  usageFailure,
26  UsageOrder,
27  PRIMARY_WINDOWS,
28  type RateLimitReading,
29  type UsageSnapshot,
30  type UsageWindow,
31} from "./usage"
32import {
33  BRAND_MARK_SVG,
34  meterDescription,
35  responsiveMeterSvg,
36  RESPONSIVE_FRAME_WIDTH,
37  usageStatusSvg,
38  secondaryTextSvg,
39} from "./usage-graphics"
40import { desktopPercentage, PRESENTATION } from "./usage-presentation"
41
42import {
43  ARGUMENT_HINT,
44  COMMAND,
45  describeCommands,
46} from "./commands"
47
48const BRAND = "#FF5914"
49const usage = atom(
50  { plugin: "antiburn", key: "usage" } as const,
51  {
52    snapshot: { status: "empty", windows: [], capturedAt: 0 },
53    pendingRead: null,
54    accountRetryAt: null,
55    revealed: null,
56    activated: null,
57    hovered: null,
58  } as UsageState,
59)
60const usageOrder = new UsageOrder()
61const hoverSites = new Map<string, { shape: string; binding: string }>()
62const hoverMount = Math.random().toString(36).slice(2)
63let hoverRevision = 0
64let uiEnabled = true
65let uiGeneration = 0
66const visibleSites = new Set<string>()
67let manualUsageRead: Promise<void> | undefined
68let needsUsageReconciliation = false
69let desktopAttached = false
70const newAccountState = () => ({
71  snapshot: null as UsageSnapshot | null,
72  lastResult: null as AccountUsageResult | null,
73  nextAttempt: 0,
74  failures: 0,
75  epoch: 0,
76})
77let accountState = newAccountState()
78let accountRequest:
79  | {
80      state: typeof accountState
81      epoch: number
82      promise: Promise<AccountUsageResult>
83    }
84  | undefined
85
86export async function fetchAccountUsage($: EngineInterface): Promise<AccountUsageResult> {
87  let authorization: SessionAuthorization
88  try {
89    authorization = await $.session.authorize()
90  } catch {
91    return { status: "unsupported" }
92  }
93  if (authorization?.kind !== "bearer") return { status: "unsupported" }
94  try {
95    const response = await $.http.fetch("https://api.anthropic.com/api/oauth/usage", {
96      auth: authorization.handle,
97      headers: { "anthropic-beta": "oauth-2025-04-20", accept: "application/json" },
98    })
99    if (!response.ok)
100      return {
101        status: "error",
102        retryAfter: response.status === 429 ? response.headers["retry-after"] : undefined,
103      }
104    const payload: unknown = JSON.parse(response.text)
105    if (!payload || typeof payload !== "object" || Array.isArray(payload))
106      return { status: "error" }
107    const readings: RateLimitReading[] = []
108    for (const kind of ["five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet"]) {
109      const value: unknown = (payload as Record<string, unknown>)[kind]
110      if (!value || typeof value !== "object" || Array.isArray(value)) continue
111      const window = value as Record<string, unknown>
112      if (typeof window.utilization !== "number") continue
113      readings.push({
114        kind,
115        percentUsed: window.utilization,
116        resetsAt: typeof window.resets_at === "string" ? window.resets_at : undefined,
117      })
118    }
119    const snapshot = normalizeUsage(readings, await $.clock.now())
120    return snapshot.status === "ready" ? { status: "ready", snapshot } : { status: "error" }
121  } catch {
122    return { status: "error" }
123  }
124}
125
126function terminalHoverBinding(requestId: string, shape: string): string {
127  const previous = hoverSites.get(requestId)
128  if (previous?.shape === shape) return previous.binding
129  const binding = `${hoverMount}:${++hoverRevision}`
130  if (hoverSites.size >= 8 && !hoverSites.has(requestId))
131    hoverSites.delete(hoverSites.keys().next().value!)
132  hoverSites.set(requestId, { shape, binding })
133  return binding
134}
135
136async function commitUsage(
137  $: EngineInterface,
138  snapshot: UsageSnapshot,
139  ticket: { generation: number; revision: number },
140): Promise<void> {
141  if (!usageOrder.isCurrent(ticket)) return
142  await update($, usage, (current) =>
143    usageOrder.isCurrent(ticket) ? { ...current, snapshot, accountRetryAt: null } : current,
144  )
145}
146
147async function applyAccountResult(
148  $: EngineInterface,
149  result: AccountUsageResult | null,
150  ticket: { generation: number; revision: number },
151): Promise<void> {
152  if (result?.status === "ready") {
153    await commitUsage($, result.snapshot, ticket)
154  } else if (result?.status === "error") {
155    await update($, usage, (current) =>
156      usageOrder.isCurrent(ticket)
157        ? {
158            ...current,
159            snapshot: usageFailure(current.snapshot, current.snapshot.capturedAt),
160            accountRetryAt: accountState.nextAttempt,
161          }
162        : current,
163    )
164  } else if (result?.status === "unsupported") {
165    await update($, usage, (current) =>
166      usageOrder.isCurrent(ticket)
167        ? {
168            ...current,
169            accountRetryAt: null,
170            snapshot: current.snapshot.windows.some((window) => window.percent !== null)
171              ? usageFailure(current.snapshot, current.snapshot.capturedAt)
172              : { status: "empty" as const, windows: [], capturedAt: 0 },
173          }
174        : current,
175    )
176  }
177}
178
179async function readAccountUsage(
180  $: EngineInterface,
181  now: number,
182  ticket: { generation: number; revision: number },
183): Promise<void> {
184  const state = accountState
185  const epoch = state.epoch
186  while (accountRequest && (accountRequest.state !== state || accountRequest.epoch !== epoch)) {
187    try {
188      await accountRequest.promise
189    } catch {
190      // Continue this event after the previous request releases its slot.
191    }
192    if (state !== accountState || epoch !== state.epoch || !usageOrder.isCurrent(ticket)) return
193  }
194  if (!accountRequest && now < state.nextAttempt) {
195    await applyAccountResult($, state.lastResult, ticket)
196    return
197  }
198  if (!accountRequest) {
199    const promise = (async () => {
200      const result = await fetchAccountUsage($)
201      const observedAt = await $.clock.now()
202      if (state !== accountState || epoch !== state.epoch) return result
203      state.lastResult = result
204      if (result.status === "ready") {
205        state.snapshot = result.snapshot
206        state.failures = 0
207        state.nextAttempt = observedAt + 60_000
208      } else if (result.status === "error") {
209        state.failures = Math.min(5, state.failures + 1)
210        state.nextAttempt = Math.max(
211          observedAt + Math.min(900_000, 60_000 * 2 ** (state.failures - 1)),
212          retryAfterTime(result.retryAfter, observedAt),
213        )
214      } else {
215        state.nextAttempt = observedAt + 60_000
216      }
217      return result
218    })()
219    const request = { state, epoch, promise }
220    accountRequest = request
221    void promise
222      .finally(() => {
223        if (accountRequest === request) accountRequest = undefined
224      })
225      .catch(() => {})
226  }
227  const result = await accountRequest.promise
228  if (state !== accountState || epoch !== state.epoch || !usageOrder.isCurrent(ticket)) return
229  await applyAccountResult($, result, ticket)
230}
231
232async function readUsage($: EngineInterface): Promise<void> {
233  const ticket = usageOrder.beginRead()
234  try {
235    await update($, usage, (current) =>
236      usageOrder.isCurrent(ticket) ? { ...current, pendingRead: ticket } : current,
237    )
238    if (!usageOrder.isCurrent(ticket)) return
239    let native: UsageSnapshot | undefined
240    let failed = false
241    const now = await $.clock.now()
242    try {
243      native = normalizeUsage((await $.session.usage()).rateLimits, now)
244    } catch {
245      failed = true
246    }
247    if (!usageOrder.isCurrent(ticket)) return
248    if (native?.status === "ready") {
249      accountState.epoch++
250      accountState.snapshot = null
251      accountState.lastResult = null
252      await commitUsage($, native, ticket)
253    } else if (desktopAttached) {
254      await readAccountUsage($, now, ticket)
255    } else if (failed) {
256      throw new Error("Usage unavailable")
257    } else if (native) {
258      await commitUsage($, native, ticket)
259    }
260  } catch {
261    if (!usageOrder.isCurrent(ticket)) return
262    await update($, usage, (current) =>
263      usageOrder.isCurrent(ticket)
264        ? {
265            ...current,
266            snapshot: usageFailure(current.snapshot, current.snapshot.capturedAt),
267          }
268        : current,
269    )
270  } finally {
271    await update($, usage, (current) =>
272      current.pendingRead?.generation === ticket.generation &&
273      current.pendingRead.revision === ticket.revision
274        ? { ...current, pendingRead: null }
275        : current,
276    )
277  }
278}
279
280async function refreshUsage($: EngineInterface): Promise<void> {
281  if (manualUsageRead) return manualUsageRead
282  const request = readUsage($)
283  manualUsageRead = request
284  try {
285    await request
286  } finally {
287    if (manualUsageRead === request) manualUsageRead = undefined
288  }
289}
290
291function accountRetryText(retryAt: number | null, now: number): string | null {
292  if (retryAt === null || !Number.isFinite(retryAt) || retryAt <= now) return null
293  const date = new Date(Math.ceil(retryAt / 1000) * 1000)
294  if (!Number.isFinite(date.getTime())) return "Account check is temporarily unavailable"
295  try {
296    const formatter = new Intl.DateTimeFormat("en-US", {
297      hour: "numeric",
298      minute: "2-digit",
299      second: date.getUTCSeconds() === 0 ? undefined : "2-digit",
300      hour12: true,
301      timeZoneName: "short",
302    })
303    const timeZone = formatter.resolvedOptions().timeZone
304    if (!timeZone) throw new Error("Time zone unavailable")
305    const parts = formatter.formatToParts(date)
306    const part = (kind: Intl.DateTimeFormatPartTypes) =>
307      parts.find((value) => value.type === kind)?.value ?? ""
308    const calendar = new Intl.DateTimeFormat("en-CA", {
309      timeZone,
310      year: "numeric",
311      month: "2-digit",
312      day: "2-digit",
313    })
314    const dateKey = (value: number) => {
315      const fields = calendar.formatToParts(value)
316      return ["year", "month", "day"]
317        .map((kind) => fields.find((field) => field.type === kind)?.value ?? "")
318        .join("-")
319    }
320    const day = dateKey(date.getTime()) === dateKey(now) ? "" : `${dateKey(date.getTime())} `
321    const seconds = part("second") ? `:${part("second")}` : ""
322    return `Account retry at ${day}${part("hour")}:${part("minute")}${seconds}${part("dayPeriod").toLowerCase()} ${part("timeZoneName")}`
323  } catch {
324    const timestamp = date.toISOString()
325    const day =
326      timestamp.slice(0, 10) === new Date(now).toISOString().slice(0, 10)
327        ? ""
328        : `${timestamp.slice(0, 10)} `
329    const hour = date.getUTCHours()
330    const seconds = date.getUTCSeconds() === 0 ? "" : `:${timestamp.slice(17, 19)}`
331    return `Account retry at ${day}${hour % 12 || 12}:${timestamp.slice(14, 16)}${seconds}${hour < 12 ? "am" : "pm"} UTC`
332  }
333}
334
335const RETRY_EXPLANATION = "Refresh checks session."
336
337async function resetUsage($: EngineInterface): Promise<void> {
338  uiGeneration++
339  visibleSites.clear()
340  usageOrder.reset()
341  manualUsageRead = undefined
342  accountState = newAccountState()
343  hoverSites.clear()
344  await update($, usage, () => ({
345    snapshot: { status: "empty" as const, windows: [], capturedAt: 0 },
346    pendingRead: null,
347    accountRetryAt: null,
348    revealed: null,
349    activated: null,
350    hovered: null,
351  }))
352}
353
354async function reconcileUsage($: EngineInterface): Promise<void> {
355  if (!needsUsageReconciliation) return
356  needsUsageReconciliation = false
357  await readUsage($)
358}
359
360async function revealUsage($: EngineInterface, kind: string): Promise<void> {
361  await update($, usage, (current) => {
362    const dismissed = current.activated === kind
363    return {
364      ...current,
365      revealed: dismissed ? null : kind,
366      activated: dismissed ? null : kind,
367    }
368  })
369}
370
371async function openTerminalWebsite($: EngineInterface): Promise<void> {
372  try {
373    const windows = (await $.env.get("OS")) === "Windows_NT"
374    if (windows) {
375      const result = await $.process.run(
376        ["cmd.exe", "/c", "start", "", "https://antiburn.com"],
377        { timeoutMs: 5000 },
378      )
379      if (result.exitCode === 0) return
380    } else {
381      try {
382        const result = await $.process.run(["open", "https://antiburn.com"], {
383          timeoutMs: 5000,
384        })
385        if (result.exitCode === 0) return
386      } catch {
387        // Try the Linux opener if the macOS opener fails.
388      }
389      const result = await $.process.run(["xdg-open", "https://antiburn.com"], {
390        timeoutMs: 5000,
391      })
392      if (result.exitCode === 0) return
393    }
394  } catch {
395    // Show the address if the host cannot open the browser.
396  }
397  await $.ui.status(`Open ${APP_URL} in your browser`)
398}
399
400async function renderUsage($: EngineInterface, e: RenderInput<"AbovePrompt">) {
401  const generation = uiGeneration
402  if (visibleSites.size >= 8 && !visibleSites.has(e.requestId))
403    visibleSites.delete(visibleSites.values().next().value!)
404  visibleSites.add(e.requestId)
405  const activate = (action: () => Promise<unknown>) => {
406    if (uiEnabled && generation === uiGeneration && visibleSites.has(e.requestId))
407      return action()
408  }
409  const ui = $.ui.resolve(e)
410  const { Box, Text, Button, Link } = ui
411  const state = await read($, usage)
412  const now = await $.clock.now()
413  const snapshot = state.snapshot
414  const windows = PRIMARY_WINDOWS.map((kind) => primaryWindow(snapshot, kind))
415  const columns = Math.max(1, Math.min(4096, e.props.bodyColumns))
416  const isDesktop = e.surface === "desktop" && "Svg" in ui
417  const mark = isDesktop ? (
418    <Box
419      key="usage-mark-slot"
420      alignItems="center"
421      position="relative"
422      width={PRESENTATION.markSlotSize / 8}
423      flexShrink={0}
424    >
425      <ui.Svg
426        key="usage-mark-size"
427        source={BRAND_MARK_SVG}
428        alt="antiburn"
429        width={PRESENTATION.markSize}
430        height={PRESENTATION.markSize}
431      />
432      <Box
433        key="usage-brand-link"
434        position="absolute"
435        left={0}
436        top={0}
437        width={14 / 8}
438        height={14 / 16}
439        overflow="hidden"
440      >
441        <Link href={APP_URL} label="Open antiburn website">
442          <Text>{"\u2003"}</Text>
443        </Link>
444      </Box>
445    </Box>
446  ) : (
447    <Box
448      key="usage-brand-text-slot"
449      width={11}
450      paddingLeft={1}
451      flexDirection="row"
452      flexShrink={0}
453      height={2}
454      alignItems="center"
455      justifyContent="center"
456    >
457      <Box flexDirection="column" width={10} height={2} flexShrink={0}>
458        <Text> </Text>
459        <Box width={10} height={1} flexShrink={0} overflow="hidden">
460          <Button
461            key="usage-brand-open"
462            plain
463            label="Open antiburn website"
464            onPress={() => activate(() => openTerminalWebsite($))}
465          >
466            <Text color={BRAND} backgroundColor="#4D2F23">
467              {" antiburn "}
468            </Text>
469          </Button>
470        </Box>
471      </Box>
472    </Box>
473  )
474  const hasReading = windows.some((window) => window.percent !== null)
475  if (!hasReading && snapshot.status !== "ready") {
476    terminalHoverBinding(e.requestId, "empty")
477    const pending = state.pendingRead != null && usageOrder.isCurrent(state.pendingRead)
478    const status = pending ? "Checking usage..." : "Usage unavailable"
479    const canRefresh = !pending && columns >= 24
480    const retryAt = state.accountRetryAt
481    const retryHint = accountRetryText(retryAt, now)
482    const graphic = usageStatusSvg(status, undefined, !pending && columns < 60)
483    const band = (
484      <Box
485        key="usage-band"
486        flexDirection="row"
487        alignItems={isDesktop ? "center" : "flex-end"}
488        gap={isDesktop ? 0 : 1}
489        height={isDesktop && columns >= 24 ? graphic.height / 16 : undefined}
490      >
491        {isDesktop && <Box width={PRESENTATION.brandInset / 8} flexShrink={0} />}
492        {mark}
493        {isDesktop && <Box width={PRESENTATION.brandGap / 8} flexShrink={0} />}
494        {isDesktop ? (
495          <ui.Svg
496            key="usage-status"
497            source={graphic.source}
498            alt={graphic.description}
499            width={Math.min(
500              graphic.width,
501              Math.max(
502                1,
503                columns * 8 -
504                  PRESENTATION.brandInset -
505                  PRESENTATION.markSlotSize -
506                  PRESENTATION.brandGap -
507                  (canRefresh ? 32 : 0),
508              ),
509            )}
510            height={graphic.height}
511          />
512        ) : (
513          <Box width={Math.max(1, columns - (canRefresh ? 14 : 12))} flexShrink={1}>
514            {pending && e.surface === "terminal" && "Client" in ui ? (
515              <ui.Client key="usage-checking" module="./checking-status.ts" props={null} />
516            ) : (
517              <Text key="usage-status" wrap="truncate-end">
518                {status}
519              </Text>
520            )}
521          </Box>
522        )}
523        {canRefresh && (
524          <Button
525            key="usage-refresh"
526            plain
527            dimColor
528            label={isDesktop ? "↻" : "[↻]"}
529            onPress={() => activate(() => refreshUsage($))}
530          >
531            <Text dimColor>{isDesktop ? "↻" : "[↻]"}</Text>
532          </Button>
533        )}
534      </Box>
535    )
536    if (!retryHint) return band
537    const retryCopy = `${retryHint} · ${RETRY_EXPLANATION}`
538    const secondary = secondaryTextSvg(
539      [retryCopy],
540      columns * 8,
541      Math.max(2, Math.floor(e.props.maxRows - 2)),
542    )
543    return (
544      <Box key="usage-band-with-retry" flexDirection="column" width={columns}>
545        {band}
546        {isDesktop ? (
547          <ui.Svg
548            source={secondary.source}
549            alt={secondary.description}
550            width={secondary.width}
551            height={secondary.height}
552          />
553        ) : (
554          <Box key="usage-retry-copy" width={columns} overflow="hidden">
555            <Text key="usage-account-retry" dimColor wrap="wrap">
556              {retryCopy}
557            </Text>
558          </Box>
559        )}
560      </Box>
561    )
562  }
563
564  if (isDesktop && columns >= 24) {
565    terminalHoverBinding(e.requestId, "desktop")
566    const inline = columns >= PRESENTATION.inlineWidth / 8
567    return (
568      <Box
569        key="usage-band"
570        position="relative"
571        width="100%"
572        flexDirection="row"
573        alignItems="center"
574      >
575        <Box width={PRESENTATION.brandInset / 8} flexShrink={0} />
576        {mark}
577        <Box width={PRESENTATION.brandGap / 8} flexShrink={0} />
578        <Box
579          key="usage-meters"
580          flexDirection={inline ? "row" : "column"}
581          flexGrow={1}
582          flexShrink={1}
583          minWidth={0}
584          width={0}
585          gap={inline ? 3 : 1 / 16}
586        >
587          {windows.map((window) => (
588            <Box
589              key={`meter-${window.kind}`}
590              width={inline ? 0 : "100%"}
591              minWidth={0}
592              flexGrow={1}
593              flexShrink={1}
594              alignItems="center"
595            >
596              <ui.Svg
597                key={`meter-graphic-${window.kind}`}
598                source={responsiveMeterSvg(window, now)}
599                alt={`${meterDescription(window, now, true)}${snapshot.status === "error" ? ". Usage unavailable · Last known" : ""}`}
600                width={RESPONSIVE_FRAME_WIDTH}
601                height={18}
602                isInteractive
603              />
604            </Box>
605          ))}
606        </Box>
607        <Box width={PRESENTATION.brandInset / 8} flexShrink={0} />
608      </Box>
609    )
610  }
611  const content = Math.max(1, columns - (isDesktop ? 3 : 12))
612  const inlineColumns = Math.max(1, Math.floor((content - 3) / 2))
613  const isInline =
614    columns >= 80 &&
615    windows.every(
616      (window) => window.label.length + percentage(window).length + 2 <= inlineColumns,
617    )
618  const meterColumns = isInline ? inlineColumns : content
619  const geometry = (window: UsageWindow) => {
620    const trackColumns = Math.max(
621      0,
622      meterColumns - window.label.length - percentage(window).length - 2,
623    )
624    const count = isDesktop ? 0 : terminalDotCount(trackColumns)
625    const value = pace(window, now)
626    const markerCell =
627      value === null || count < 2
628        ? null
629        : Math.max(1, Math.min(count * 2 - 3, Math.round(value * (count - 1)) * 2 - 1))
630    return { count, value, markerCell }
631  }
632  const hoverBinding =
633    e.surface === "terminal"
634      ? terminalHoverBinding(
635          e.requestId,
636          JSON.stringify([
637            columns,
638            snapshot.status,
639            windows.map((window) => [
640              window.resetsAt,
641              percentage(window).length,
642              geometry(window).markerCell,
643            ]),
644          ]),
645        )
646      : ""
647  const revealed =
648    e.surface === "terminal" &&
649    state.hovered?.requestId === e.requestId &&
650    state.hovered.binding === hoverBinding
651      ? state.hovered.target
652      : state.revealed
653
654  const meter = (window: UsageWindow) => {
655    const description = meterDescription(window, now, isDesktop)
656    const isRevealed = revealed === window.kind
657    const isPaceRevealed = revealed === `pace-${window.kind}`
658    const { count, value, markerCell } = geometry(window)
659    const isPaceReached =
660      window.percent !== null && value !== null && window.percent / 100 >= value
661    const cells = count * 2 - 1
662    const copy = isRevealed
663      ? resetText(window, now)
664      : isPaceRevealed
665        ? value === null
666          ? "Pace unavailable"
667          : paceLabel(value)
668        : null
669    const copyOnRight = markerCell !== null && cells - markerCell - 1 >= markerCell
670    const copyOnLeft = isRevealed || (isPaceRevealed && !copyOnRight)
671    const segment = (start: number, end: number, showCopy: boolean) => {
672      const copyLength =
673        showCopy && copy !== null ? Math.min(copy.length, Math.max(0, end - start)) : 0
674      const copyStart = copyOnLeft ? end - copyLength : start
675      const dotCells = Array.from({ length: Math.max(0, end - start) }, (_, index) => {
676        const cell = start + index
677        if (copyLength > 0 && cell >= copyStart && cell < copyStart + copyLength) return null
678        if (cell % 2 !== 0) return <Text key={`space-${cell}`}> </Text>
679        const filled = cell / 2 < filledDotCount(window.percent, count)
680        return (
681          <Text key={`dot-${cell}`} color={filled ? BRAND : "subtle"}>
682            {"●"}
683          </Text>
684        )
685      })
686      if (copyLength === 0) return dotCells
687      dotCells.splice(
688        copyStart - start,
689        copyLength,
690        <Text key="usage-segment-copy" dimColor wrap="truncate-end">
691          {copy}
692        </Text>,
693      )
694      return dotCells
695    }
696
697    const target = (
698      detail: "reset" | "pace",
699      text: string,
700      color?: string,
701      dimColor?: boolean,
702    ) =>
703      e.surface === "terminal" && "Client" in ui ? (
704        <ui.Client
705          key={`usage-hover-${detail}-${window.kind}`}
706          module="./usage-target.tsx"
707          width={detail === "reset" ? window.label.length : 1}
708          height={1}
709          props={{
710            binding: hoverBinding,
711            width: detail === "reset" ? window.label.length : 1,
712            text,
713            ...(color === undefined ? {} : { color }),
714            ...(dimColor === undefined ? {} : { dimColor }),
715          }}
716        />
717      ) : null
718    const markerGlyph = isPaceReached ? "│" : "┊"
719    const markerHover = target(
720      "pace",
721      markerGlyph,
722      isPaceReached ? BRAND : "subtle",
723      !isPaceReached && !isPaceRevealed,
724    )
725    const paceMarker =
726      markerCell !== null ? (
727        <Box position="relative" width={1} height={1} flexShrink={0}>
728          <Button
729            key={`usage-pace-${window.kind}`}
730            plain
731            onPress={() => activate(() => revealUsage($, `pace-${window.kind}`))}
732          >
733            <Text
734              key="pace-marker"
735              color={isPaceReached ? BRAND : "subtle"}
736              dimColor={!isPaceReached && !isPaceRevealed}
737            >
738              {isPaceReached ? "│" : "┊"}
739            </Text>
740          </Button>
741          {markerHover && (
742            <Box position="absolute" left={0} top={0} width={1} height={1}>
743              {markerHover}
744            </Box>
745          )}
746        </Box>
747      ) : null
748    const dots =
749      count >= 2 ? (
750        isRevealed && markerCell !== null ? (
751          <Box
752            key="usage-reset-track"
753            position="relative"
754            width={cells}
755            height={1}
756            overflow="hidden"
757          >
758            <Text>{segment(0, cells, false)}</Text>
759            <Box
760              key="usage-pace-marker-slot"
761              position="absolute"
762              left={markerCell}
763              top={0}
764              width={1}
765              height={1}
766            >
767              <Button
768                key={`usage-pace-${window.kind}`}
769                plain
770                onPress={() => activate(() => revealUsage($, `pace-${window.kind}`))}
771              >
772                <Text color={isPaceReached ? BRAND : "subtle"}>{markerGlyph}</Text>
773              </Button>
774            </Box>
775            <Box
776              key="usage-reset-copy-track"
777              position="absolute"
778              left={0}
779              top={0}
780              width={cells}
781              height={1}
782              overflow="hidden"
783            >
784              <Text key="usage-reset-copy" dimColor wrap="truncate-end">
785                {copy}
786              </Text>
787            </Box>
788            {markerHover && (
789              <Box position="absolute" left={markerCell} top={0} width={1} height={1}>
790                {target(
791                  "pace",
792                  copy !== null && markerCell < copy.length ? copy[markerCell]! : markerGlyph,
793                  copy !== null && markerCell < copy.length
794                    ? undefined
795                    : isPaceReached
796                      ? BRAND
797                      : "subtle",
798                  copy !== null && markerCell < copy.length ? true : !isPaceReached,
799                )}
800              </Box>
801            )}
802          </Box>
803        ) : (
804          <Box flexDirection="row" width={cells} height={1} flexShrink={0}>
805            {markerCell === null && <Text>{segment(0, cells, copy !== null)}</Text>}
806            {markerCell !== null && (
807              <Box
808                key={`usage-pace-left-${window.kind}`}
809                width={markerCell}
810                flexShrink={0}
811                justifyContent={isPaceRevealed && copyOnLeft ? "flex-end" : undefined}
812                height={1}
813                flexDirection="row"
814                overflow="hidden"
815              >
816                <Text wrap="truncate-end">
817                  {segment(0, markerCell, copy !== null && copyOnLeft)}
818                </Text>
819              </Box>
820            )}
821            {paceMarker}
822            {markerCell !== null && (
823              <Box
824                key={`usage-pace-right-${window.kind}`}
825                width={cells - markerCell - 1}
826                flexShrink={0}
827                height={1}
828                flexDirection="row"
829                overflow="hidden"
830              >
831                <Text wrap="truncate-end">
832                  {segment(
833                    markerCell + 1,
834                    cells,
835                    copy !== null && isPaceRevealed && copyOnRight,
836                  )}
837                </Text>
838              </Box>
839            )}
840          </Box>
841        )
842      ) : null
843    return (
844      <Box
845        key={`usage-${window.kind}`}
846        position="relative"
847        flexDirection="column"
848        width={meterColumns}
849        height={1}
850      >
851        <Box flexDirection="row" justifyContent="space-between" gap={1}>
852          <Box
853            position="relative"
854            width={window.label.length}
855            height={1}
856            flexShrink={0}
857            overflow="hidden"
858          >
859            <Button
860              key={`usage-detail-${window.kind}`}
861              plain
862              label={description}
863              onPress={() => activate(() => revealUsage($, window.kind))}
864            >
865              <Text>{window.label}</Text>
866            </Button>
867            {e.surface === "terminal" && (
868              <Box position="absolute" left={0} top={0} width={window.label.length} height={1}>
869                {target("reset", window.label)}
870              </Box>
871            )}
872          </Box>
873          <Box flexGrow={1} flexShrink={1} minWidth={0}>
874            {dots}
875          </Box>
876          <Text>{isDesktop ? desktopPercentage(window) : percentage(window)}</Text>
877        </Box>
878      </Box>
879    )
880  }
881
882  const meters = (
883    <Box key="usage-meters" flexDirection={isInline ? "row" : "column"} gap={isInline ? 3 : 0}>
884      {windows.map(meter)}
885    </Box>
886  )
887  if (snapshot.status === "error") {
888    return (
889      <Box key="usage-band" flexDirection="column">
890        <Box
891          flexDirection="row"
892          alignItems={isDesktop ? "center" : "flex-end"}
893          gap={isDesktop ? 0 : 1}
894        >
895          {isDesktop && <Box width={PRESENTATION.brandInset / 8} flexShrink={0} />}
896          {mark}
897          {isDesktop && <Box width={PRESENTATION.brandGap / 8} flexShrink={0} />}
898          <Text key="usage-status">Usage unavailable · Last known</Text>
899        </Box>
900        <Box marginLeft={3}>{meters}</Box>
901      </Box>
902    )
903  }
904  return (
905    <Box
906      key="usage-band"
907      position="relative"
908      flexDirection="row"
909      alignItems={isDesktop ? "center" : "flex-end"}
910      gap={isDesktop ? 0 : 1}
911    >
912      {isDesktop && <Box width={PRESENTATION.brandInset / 8} flexShrink={0} />}
913      {mark}
914      {isDesktop && <Box width={PRESENTATION.brandGap / 8} flexShrink={0} />}
915      {meters}
916    </Box>
917  )
918}
919export async function usageDetails(
920  $: EngineInterface,
921  commandName: string,
922  confirmation?: string,
923): Promise<{ text: string }> {
924  const state = await read($, usage)
925  const now = await $.clock.now()
926  const retryHint = accountRetryText(state.accountRetryAt, now)
927  if (!state.snapshot.windows.some((window) => window.percent !== null)) {
928    return {
929      text:
930        state.pendingRead != null && usageOrder.isCurrent(state.pendingRead)
931          ? "Checking usage…"
932          : `Usage isn’t available right now. Run \`/${commandName} refresh\` to try again.${retryHint ? `\n${retryHint} · ${RETRY_EXPLANATION}` : ""}`,
933    }
934  }
935  if (confirmation && state.snapshot.status === "ready" && state.pendingRead == null)
936    return { text: confirmation }
937  const prefix = state.snapshot.status === "error" ? "Usage unavailable · Last known.\n" : ""
938  return {
939    text:
940      prefix +
941      PRIMARY_WINDOWS.map((kind) =>
942        meterDescription(primaryWindow(state.snapshot, kind), now),
943      ).join("\n") +
944      (retryHint ? `\n${retryHint} · ${RETRY_EXPLANATION}` : ""),
945  }
946}
947
948export async function requestUsage($: EngineInterface): Promise<void> {
949  await readUsage($)
950}
951
952export async function startQuota($: EngineInterface): Promise<void> {
953  desktopAttached = false
954  needsUsageReconciliation = false
955  try {
956    await resetUsage($)
957    if (uiEnabled) await readUsage($)
958  } catch {
959    // Keep the host session usable when quota setup fails.
960  }
961}
962
963export async function endQuota($: EngineInterface, reason: string): Promise<void> {
964  try {
965    await resetUsage($)
966  } catch {
967    // End the host session even if display cleanup fails.
968  }
969  needsUsageReconciliation = reason === "clear" || reason === "resume"
970}
971
972export async function reconcileQuota($: EngineInterface): Promise<void> {
973  try {
974    if (uiEnabled) await reconcileUsage($)
975  } catch {
976    // Preserve the host command result when quota refresh fails.
977  }
978}
979
980export function registerCommands(on: On, usageUiEnabled: boolean) {
981  let registered = false
982  let generation = 0
983
984  on("session.start", async ($, e, next) => {
985    registered = false
986    const current = ++generation
987    await startQuota($)
988    const result = await next(e)
989    if (current !== generation) return result
990    try {
991      const command = await $.command.register({
992        name: COMMAND,
993        description: "Show your usage or refresh it",
994        argumentHint: ARGUMENT_HINT,
995      })
996      if (current !== generation) return result
997      registered = command.command === COMMAND
998    } catch {
999      if (current !== generation) return result
1000    }
1001    if (!registered) {
1002      try {
1003        await $.ui.log("antiburn: /antiburn could not be registered.", {
1004          to: "debug",
1005        })
1006      } catch {
1007        // Keep quota available when the host cannot log the registration failure.
1008      }
1009    }
1010    return result
1011  })
1012
1013  on("session.end", async ($, e, next) => {
1014    await endQuota($, e.reason)
1015    if (e.reason !== "clear" && e.reason !== "resume") {
1016      generation++
1017      registered = false
1018    }
1019    return next(e)
1020  })
1021
1022  on("ui.render", { component: "CommandOutput" }, (_$, e, next) => {
1023    if (e.props.command !== COMMAND) return next(e)
1024    return next({
1025      ...e,
1026      props: { ...e.props, text: e.props.text.replace(/^antiburn: /, "") },
1027    })
1028  })
1029
1030  on("command.run", async ($, e, next) => {
1031    if (e.command === "clear" || e.command === "resume") {
1032      const result = await next(e)
1033      await reconcileQuota($)
1034      return result
1035    }
1036    if (!registered || e.command !== COMMAND) return next(e)
1037    const command = parseUsageCommand(e.args)
1038    if (command === "invalid" || command === "help")
1039      return { text: renderUsageHelp(`/${COMMAND}`, command === "invalid") }
1040    try {
1041      if (command === "refresh" || !usageUiEnabled) await requestUsage($)
1042      const confirmation =
1043        command === "refresh"
1044          ? usageUiEnabled
1045            ? "Usage updated."
1046            : `Usage updated. Run \`/${COMMAND}\` to see it.`
1047          : undefined
1048      return await usageDetails($, COMMAND, confirmation)
1049    } catch {
1050      return {
1051        text: `Usage isn’t available right now. Run \`/${COMMAND} refresh\` to try again.`,
1052      }
1053    }
1054  })
1055}
1056
1057export function registerQuota(on: On, options: { usageUiEnabled: boolean }) {
1058  uiEnabled = options.usageUiEnabled
1059
1060  on("classic.SessionStart", async ($, e, next) => {
1061    if (e.source === "clear") {
1062      try {
1063        await resetUsage($)
1064        needsUsageReconciliation = true
1065        if (uiEnabled) await reconcileUsage($)
1066      } catch {
1067        // Keep the host clear operation available when quota refresh fails.
1068      }
1069    }
1070    return next(e)
1071  })
1072
1073  on("session.attach", async ($, e, next) => {
1074    const state = accountState
1075    const result = await next(e)
1076    if (e.surface === "desktop" && state === accountState) {
1077      desktopAttached = true
1078      if (uiEnabled) await readUsage($)
1079    }
1080    return result
1081  })
1082
1083  on("session.detach", async ($, e, next) => {
1084    uiGeneration++
1085    visibleSites.clear()
1086    usageOrder.reset()
1087    manualUsageRead = undefined
1088    const hadAccountReading = accountState.snapshot !== null
1089    accountState = newAccountState()
1090    if (e.surface === "desktop") desktopAttached = false
1091    hoverSites.clear()
1092    try {
1093      await update($, usage, (current) => ({
1094        ...current,
1095        pendingRead: null,
1096        accountRetryAt: null,
1097        snapshot: hadAccountReading
1098          ? { status: "empty" as const, windows: [], capturedAt: 0 }
1099          : current.snapshot,
1100        hovered: null,
1101      }))
1102    } catch {
1103      // Detach the client even if hover cleanup fails.
1104    }
1105    return next(e)
1106  })
1107
1108  on("session.measure", async ($, e, next) => {
1109    if (!uiEnabled) return next(e)
1110    if (desktopAttached && normalizeUsage(e.rateLimits, 0).status !== "ready") return next(e)
1111    const ticket = usageOrder.acceptEvent()
1112    needsUsageReconciliation = false
1113    try {
1114      const snapshot = normalizeUsage(e.rateLimits, await $.clock.now())
1115      if (!usageOrder.isCurrent(ticket)) return next(e)
1116      accountState.epoch++
1117      if (snapshot.status === "ready") {
1118        accountState.snapshot = null
1119        accountState.lastResult = null
1120      }
1121      if (snapshot.status === "ready" || accountState.snapshot === null)
1122        await commitUsage($, snapshot, ticket)
1123    } catch {
1124      try {
1125        await update($, usage, (current) =>
1126          usageOrder.isCurrent(ticket)
1127            ? {
1128                ...current,
1129                snapshot: usageFailure(current.snapshot, current.snapshot.capturedAt),
1130              }
1131            : current,
1132        )
1133      } catch {
1134        // Forward the measurement even if its unavailable label cannot be saved.
1135      }
1136    }
1137    return next(e)
1138  })
1139
1140  on("ui.message", async ($, e, next) => {
1141    if (!uiEnabled) return next(e)
1142    const match = /^usage-hover-(reset|pace)-(five_hour|seven_day)$/.exec(e.element)
1143    if (
1144      e.surface !== "terminal" ||
1145      e.component !== "AbovePrompt" ||
1146      e.module !== "hooks/usage-target.tsx" ||
1147      !match
1148    )
1149      return next(e)
1150    const data = e.data as {
1151      hovered?: unknown
1152      activate?: unknown
1153      binding?: unknown
1154    } | null
1155    if (
1156      !data ||
1157      typeof data.hovered !== "boolean" ||
1158      typeof data.activate !== "boolean" ||
1159      typeof data.binding !== "string" ||
1160      data.binding !== hoverSites.get(e.requestId)?.binding
1161    )
1162      return next(e)
1163    const target = match[1] === "pace" ? `pace-${match[2]}` : match[2]!
1164    try {
1165      await update($, usage, (current) => {
1166        const hovered = data.hovered
1167          ? { target, requestId: e.requestId, binding: data.binding as string }
1168          : current.hovered?.target === target && current.hovered.requestId === e.requestId
1169            ? null
1170            : current.hovered
1171        if (!data.activate) return { ...current, hovered }
1172        const dismissed = current.activated === target
1173        return {
1174          ...current,
1175          hovered,
1176          revealed: dismissed ? null : target,
1177          activated: dismissed ? null : target,
1178        }
1179      })
1180    } catch {
1181      // Keep host input available when display state cannot be saved.
1182    }
1183    return next(e)
1184  })
1185
1186  on("ui.close", async ($, e, next) => {
1187    visibleSites.delete(e.id)
1188    hoverSites.delete(e.id)
1189    try {
1190      await update($, usage, (current) =>
1191        current.hovered?.requestId === e.id ? { ...current, hovered: null } : current,
1192      )
1193    } catch {
1194      // Close the host UI even if hover cleanup fails.
1195    }
1196    return next(e)
1197  })
1198
1199  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
1200    if (!uiEnabled || e.props.hasSurvey || e.props.maxRows <= 0) {
lib/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
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/account-usage.ts 18 lines
1import type { UsageSnapshot } from './usage'
2
3export type AccountUsageResult =
4  | { status: 'ready'; snapshot: UsageSnapshot }
5  | { status: 'unsupported' }
6  | { status: 'error'; retryAfter?: string }
7
8export function retryAfterTime(value: string | undefined, now: number): number {
9  if (value === undefined) return 0
10  const trimmed = value.trim()
11  if (/^\d+(?:\.\d+)?$/.test(trimmed)) {
12    const delay = Number(trimmed) * 1000
13    return Number.isFinite(now + delay) ? now + delay : 0
14  }
15  const date = Date.parse(trimmed)
16  return Number.isFinite(date) && date > now ? date : 0
17}
18
hooks/usage.ts 126 lines
1export type RateLimitReading = { kind: string; percentUsed: number; resetsAt?: string }
2export type UsageWindow = { kind: string; label: string; percent: number | null; resetsAt: string | null; startsOnMessage?: boolean }
3export type UsageSnapshot = {
4  status: 'ready' | 'empty' | 'error'
5  windows: UsageWindow[]
6  capturedAt: number
7}
8
9const LABELS: Readonly<Record<string, string>> = {
10  five_hour: '5-hour',
11  seven_day: 'Weekly',
12  seven_day_opus: 'Weekly Opus',
13  seven_day_sonnet: 'Weekly Sonnet',
14  spend_limit: 'Spend',
15}
16const PERIODS: Readonly<Record<string, number>> = { five_hour: 5 * 60 * 60_000, seven_day: 7 * 24 * 60 * 60_000 }
17export const PRIMARY_WINDOWS = ['five_hour', 'seven_day'] as const
18
19export function normalizeUsage(readings: readonly RateLimitReading[], capturedAt: number): UsageSnapshot {
20  const windows: UsageWindow[] = []
21  for (const reading of readings) {
22    const label = LABELS[reading.kind]
23    if (label === undefined || windows.some((window) => window.kind === reading.kind)) continue
24    windows.push({
25      kind: reading.kind,
26      label,
27      percent: Number.isFinite(reading.percentUsed) && reading.percentUsed >= 0 ? reading.percentUsed : null,
28      resetsAt: typeof reading.resetsAt === 'string' && Number.isFinite(Date.parse(reading.resetsAt)) ? reading.resetsAt : null,
29    })
30  }
31  return { status: windows.some((window) => window.percent !== null) ? 'ready' : 'empty', windows, capturedAt }
32}
33
34export function primaryWindow(snapshot: UsageSnapshot, kind: (typeof PRIMARY_WINDOWS)[number]): UsageWindow {
35  return (
36    snapshot.windows.find((window) => window.kind === kind) ?? {
37      kind,
38      label: LABELS[kind]!,
39      percent: null,
40      resetsAt: null,
41      startsOnMessage:
42        kind === 'five_hour' &&
43        snapshot.status === 'ready' &&
44        snapshot.windows.some((window) => window.kind === 'seven_day' && window.percent !== null),
45    }
46  )
47}
48
49export function usageFailure(previous: UsageSnapshot, capturedAt: number): UsageSnapshot {
50  return { status: 'error', windows: previous.windows, capturedAt }
51}
52
53export function percentage(window: UsageWindow): string {
54  return window.percent === null ? '—' : `${window.percent}%`
55}
56
57export function resetText(window: UsageWindow, now: number): string {
58  if (window.resetsAt === null) return window.startsOnMessage ? 'Starts when a message is sent' : 'Reset unavailable'
59  const remaining = Date.parse(window.resetsAt) - now
60  if (!Number.isFinite(remaining)) return 'Reset unavailable'
61  if (remaining <= 0) return 'Reset due'
62  const minutes = Math.floor(remaining / 60_000)
63  if (minutes >= 24 * 60) return `RESETS IN ${Math.floor(minutes / (24 * 60))}D ${Math.floor(minutes / 60) % 24}H`
64  return `RESETS IN ${Math.floor(minutes / 60)}H ${minutes % 60}M`
65}
66
67export function pace(window: UsageWindow, now: number): number | null {
68  const period = PERIODS[window.kind]
69  if (period === undefined || window.percent === null || window.resetsAt === null) return null
70  const remaining = Date.parse(window.resetsAt) - now
71  if (!Number.isFinite(remaining) || remaining <= 0 || remaining > period) return null
72  return Math.max(0, Math.min(1, 1 - remaining / period))
73}
74
75export function paceLabel(value: number): string {
76  return `Even pace: ${Math.round(value * 100)}% used by now`
77}
78
79export function reachedPace(window: UsageWindow, value: number): boolean {
80  return window.percent !== null && window.percent / 100 >= value
81}
82
83export function dotFraction(percent: number | null, count: number, index: number): number {
84  if (percent === null || count < 2) return 0
85  return Math.max(0, Math.min(1, (Math.min(100, percent) / 100) * count - index))
86}
87
88export function filledDotCount(percent: number | null, count: number): number {
89  if (percent === null || !Number.isFinite(percent) || percent <= 0 || count < 2) return 0
90  if (percent >= 100) return count
91  return Math.max(1, Math.min(count - 1, Math.round((percent / 100) * count)))
92}
93
94export function graphicDotCount(width: number): number {
95  return Math.max(0, Math.min(20, Math.floor((width + 1) / 10)))
96}
97
98export function terminalDotCount(columns: number): number {
99  return Math.max(0, Math.min(256, Math.floor((columns + 1) / 2)))
100}
101
102// A read can commit only while its session and event revision still match.
103export class UsageOrder {
104  private generation = 0
105  private revision = 0
106
107  beginRead(): { generation: number; revision: number } {
108    this.revision += 1
109    return { generation: this.generation, revision: this.revision }
110  }
111
112  acceptEvent(): { generation: number; revision: number } {
113    this.revision += 1
114    return { generation: this.generation, revision: this.revision }
115  }
116
117  isCurrent(ticket: { generation: number; revision: number }): boolean {
118    return ticket.generation === this.generation && ticket.revision === this.revision
119  }
120
121  reset(): void {
122    this.generation += 1
123    this.revision += 1
124  }
125}
126
hooks/usage-graphics.ts 243 lines
1import { SVG_NAMESPACE } from './brand'
2import { BITCOUNT_PIXEL, captionSvg, captionWidth } from './usage-font'
3import { meterGeometry, PRESENTATION, desktopPercentage } from './usage-presentation'
4import { pace, paceLabel, percentage, resetText, type UsageWindow } from './usage'
5
6function xml(text: string): string {
7  return text.replace(
8    /[&<>"']/g,
9    (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[c]!,
10  )
11}
12
13export function meterDescription(window: UsageWindow, now: number, desktop = false): string {
14  const value = pace(window, now)
15  const reading =
16    window.percent === null
17      ? 'Unavailable'
18      : desktop
19        ? desktopPercentage(window)
20        : percentage(window)
21  return `${window.label}: ${reading}. ${resetText(window, now)}${value === null ? '' : `. ${paceLabel(value)}`}`
22}
23
24function fitted(text: string, width: number): string {
25  if (captionWidth(text, 12, false, 0.025) <= width) return text
26  if (captionWidth('...', 12, false, 0.025) > width) return ''
27  let shortened = text
28  while (shortened.length && captionWidth(shortened + '...', 12, false, 0.025) > width)
29    shortened = shortened.slice(0, -1)
30  return shortened + '...'
31}
32
33function meterStyles(fill: string): string {
34  return `<style>
35.ink{fill:#252525}.off{fill:#d1cec9}.used{fill:${fill};fill-opacity:${PRESENTATION.fillOpacity}}.marker{color:#666;opacity:.4;transition:opacity 125ms cubic-bezier(.23,1,.32,1)}.detail-reveal{fill:#64645f;opacity:0;clip-path:inset(0 100% 0 0);transition:opacity 180ms cubic-bezier(.32,.72,0,1),clip-path 180ms cubic-bezier(.32,.72,0,1)}
36@media(hover:hover) and (pointer:fine){.meter:has(.label:hover) .reset,.meter:has(.pace:hover) .pace-detail{opacity:1;clip-path:inset(0);transition-duration:280ms;transition-delay:300ms}.meter:hover .marker{opacity:1;transition-delay:300ms}.meter:has(.pace:hover) .reset{opacity:0;clip-path:inset(0 100% 0 0)}}
37.meter:has(.label:focus) .reset,.meter:has(.pace:focus) .pace-detail{opacity:1;clip-path:inset(0);transition:none}.meter:has(.pace:focus) .reset{opacity:0;clip-path:inset(0 100% 0 0);transition:none}.pace:focus .marker{opacity:1;transition:none}.hit{fill:transparent}.reset,.pace-detail{pointer-events:none}.reveal-background{fill:#f0efec;fill-opacity:.82}.focus-ring{fill:none;stroke:#ff5914;stroke-width:1;opacity:0}.label:focus .focus-ring,.pace:focus .focus-ring{opacity:1}
38@media(prefers-color-scheme:dark){.ink{fill:#ececea}.off{fill:#4b4843}.marker{color:#b8b5b0}.reset,.pace-detail{fill:#b8b5b0}.reveal-background{fill:#212121}}
39@media(prefers-reduced-motion:reduce){.detail-reveal{clip-path:none;transition:opacity 80ms cubic-bezier(.32,.72,0,1)}.meter:has(.label:hover) .reset,.meter:has(.pace:hover) .pace-detail{clip-path:none;transition-duration:80ms}.marker{transition-duration:80ms}.meter:has(.pace:hover) .reset{clip-path:none;transition:opacity 80ms cubic-bezier(.32,.72,0,1);transition-delay:0ms}.meter:has(.label:focus) .reset,.meter:has(.pace:focus) .pace-detail,.pace:focus .marker{transition:none}}
40</style>`
41}
42
43export function meterSvg(window: UsageWindow, width: number, now: number): string {
44  const scene = meterGeometry(window, width, now, 'inline')
45  const {
46    dots,
47    height,
48    baseline,
49    percentX,
50    resetX,
51    resetWidth,
52    labelWidth,
53    trackX,
54    trackWidth,
55  } = scene
56  const marker =
57    scene.marker === null ? null : { ...scene.marker, x: Math.round(scene.marker.x) }
58  const description = meterDescription(window, now, true)
59  const exhausted = window.percent !== null && window.percent >= 100
60  const fill = exhausted ? '#ed4545' : '#ff5914'
61  const circles = dots
62    .map(
63      (dot) =>
64        `<circle class="${dot.fraction === 1 ? 'used' : 'off'}" cx="${dot.x}" cy="${dot.y}" r="${PRESENTATION.dotSize / 2}"/>`,
65    )
66    .join('')
67  const resetCopy =
68    resetWidth < 110 && window.resetsAt !== null
69      ? resetText(window, now).replace(/^RESETS IN /, '')
70      : resetText(window, now)
71  const reset = fitted(resetCopy, resetWidth)
72  const paceText = marker === null ? '' : `Even pace: ${Math.round(marker.value * 100)}%`
73  const paceRight = marker === null ? 0 : Math.max(0, trackX + trackWidth - marker.x - 6)
74  const paceLeft = marker === null ? 0 : Math.max(0, marker.x - trackX - 6)
75  const paceOnRight =
76    captionWidth(paceText, 12, false, 0.025) <= paceRight || paceRight >= paceLeft
77  const paceCopy = fitted(paceText, paceOnRight ? paceRight : paceLeft)
78  const paceCopyX =
79    marker === null
80      ? trackX
81      : paceOnRight
82        ? marker.x + 6
83        : marker.x - 6 - captionWidth(paceCopy, 12, false, 0.025)
84  const markerX = marker === null ? 0 : Math.max(0, Math.min(width - 20, marker.x - 10))
85  return (
86    `<svg xmlns="${SVG_NAMESPACE}" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" aria-label="${xml(description)}">` +
87    `<defs><path id="bitcount-pixel" d="${BITCOUNT_PIXEL}"/><clipPath id="reset-bounds"><rect x="${resetX}" width="${resetWidth}" height="${height}"/></clipPath></defs>` +
88    meterStyles(fill) +
89    `<g class="meter"><g class="label ink" tabindex="0" aria-label="${xml(`${window.label}, ${resetText(window, now)}`)}"><rect class="hit" width="${labelWidth + 3}" height="${height}"/>${captionSvg(window.label, 0, baseline, PRESENTATION.captionSize)}<rect class="focus-ring" x=".5" y=".5" width="${labelWidth + 2}" height="${height - 1}"/></g>` +
90    `<g>${circles}</g><g class="ink">${captionSvg(desktopPercentage(window), percentX, baseline, PRESENTATION.captionSize, true)}</g>` +
91    (marker === null
92      ? ''
93      : `<g class="pace" tabindex="0" aria-label="${xml(paceLabel(marker.value))}"><rect class="hit" x="${markerX}" width="20" height="${height}"/><path class="marker-shadow" d="M${marker.x + 1.5} ${marker.y - marker.height / 2}v${marker.height}" stroke="#000" stroke-opacity=".22" stroke-width="1" pointer-events="none"/><path class="marker" d="M${marker.x} ${marker.y - marker.height / 2}v${marker.height}" stroke="${exhausted ? '#ed4545' : marker.reached ? '#ff5914' : 'currentColor'}" stroke-width="2"/><rect class="focus-ring" x="${markerX + 0.5}" y=".5" width="19" height="${height - 1}"/></g>`) +
94    `<g clip-path="url(#reset-bounds)"><g class="reset detail-reveal"><rect class="reveal-background" x="${resetX}" width="${resetWidth}" height="${height}"/>${captionSvg(reset, resetX, height / 2 + 3.6, 12, false, 0.025)}</g></g>` +
95    (marker === null
96      ? ''
97      : `<g class="pace-detail detail-reveal"><rect class="reveal-background" x="${trackX}" width="${trackWidth}" height="${height}"/>${captionSvg(paceCopy, paceCopyX, height / 2 + 3.6, 12, false, 0.025)}</g>`) +
98    '</g></svg>'
99  )
100}
101
102// The host clamps the frame to its flex slot. SVG coordinates remain CSS pixels.
103export const RESPONSIVE_FRAME_WIDTH = 4096
104
105export function responsiveMeterSvg(window: UsageWindow, now: number): string {
106  const scene = meterGeometry(window, 256, now, 'inline')
107  const { trackX, baseline, height, labelWidth } = scene
108  const percent = desktopPercentage(window)
109  const percentWidth = captionWidth(percent, PRESENTATION.captionSize, true)
110  const fixed = trackX + 7 + percentWidth
111  const exhausted = window.percent !== null && window.percent >= 100
112  const fill = exhausted ? '#ed4545' : '#ff5914'
113  const fraction = Math.max(0, Math.min(1, (window.percent ?? 0) / 100))
114  const filled =
115    fraction === 0
116      ? '0px'
117      : fraction === 1
118        ? 'var(--track)'
119        : `clamp(7px, calc(round(nearest, calc((var(--span) + 11px) * ${fraction}), 11px) - 4px), calc(var(--span) - 4px))`
120  const marker = scene.marker
121  const description = meterDescription(window, now, true)
122  const reset = resetText(window, now)
123  const paceCopy = marker === null ? '' : `Even pace: ${Math.round(marker.value * 100)}%`
124  const copy = (text: string, name: string) => {
125    const widths = [32, 64, 110, Math.max(111, Math.ceil(captionWidth(text, 12, false, 0.025)))]
126    return widths
127      .map((width, index) => {
128        const rule =
129          index === 0
130            ? ''
131            : `@media(min-width:${fixed + width + 10}px){.${name}-${index - 1}{display:none}.${name}-${index}{display:inline}}`
132        return `<style>.${name}-${index}{display:${index === 0 ? 'inline' : 'none'}}${rule}</style><g class="${name}-${index}">${captionSvg(fitted(text, width), 0, height / 2 + 3.6, 12, false, 0.025)}</g>`
133      })
134      .join('')
135  }
136  return (
137    `<svg xmlns="${SVG_NAMESPACE}" width="100%" height="${height}" aria-label="${xml(description)}">` +
138    `<defs><path id="bitcount-pixel" d="${BITCOUNT_PIXEL}"/><pattern id="off-dots" patternUnits="userSpaceOnUse" width="11" height="18"><circle class="off" cx="3.5" cy="9" r="3.5"/></pattern><pattern id="used-dots" patternUnits="userSpaceOnUse" width="11" height="18"><circle class="used" cx="3.5" cy="9" r="3.5"/></pattern><clipPath id="reset-bounds"><rect class="track-width" height="18"/></clipPath></defs>` +
139    meterStyles(fill) +
140    `<style>
141:root{--span:max(0px,round(down,calc(100vw - ${fixed + 7}px),11px));--track:calc(var(--span) + 7px);--filled:${filled}}
142.percent{transform:translateX(calc(100vw - ${percentWidth}px))}.track{transform:translateX(${trackX}px)}.track-width{width:var(--track)}.fill-width{width:var(--filled)}.track{display:none}
143@media(min-width:${fixed + 18}px){.track{display:inline}}
144${marker === null ? '' : `.pace{transform:translateX(round(nearest,calc(3.5px + var(--span) * ${marker.value}),1px))}`}
145</style>` +
146    `<g class="meter"><g class="label ink" tabindex="0" aria-label="${xml(`${window.label}, ${reset}`)}"><rect class="hit" width="${labelWidth + 3}" height="18"/>${captionSvg(window.label, 0, baseline, PRESENTATION.captionSize)}<rect class="focus-ring" x=".5" y=".5" width="${labelWidth + 2}" height="17"/></g>` +
147    `<g class="percent ink">${captionSvg(percent, 0, baseline, PRESENTATION.captionSize, true)}</g>` +
148    `<g class="track"><rect class="track-width" height="18" fill="url(#off-dots)"/><rect class="fill-width" height="18" fill="url(#used-dots)"/>` +
149    (marker === null
150      ? ''
151      : `<g class="pace" tabindex="0" aria-label="${xml(paceLabel(marker.value))}"><rect class="hit" x="-10" width="20" height="18"/><path class="marker-shadow" d="M1.5 0v18" stroke="#000" stroke-opacity=".22" stroke-width="1" pointer-events="none"/><path class="marker" d="M0 0v18" stroke="${exhausted ? '#ed4545' : marker.reached ? '#ff5914' : 'currentColor'}" stroke-width="2"/><rect class="focus-ring" x="-9.5" y=".5" width="19" height="17"/></g>`) +
152    `<g clip-path="url(#reset-bounds)"><g class="reset detail-reveal"><rect class="reveal-background track-width" height="18"/>${copy(reset, 'reset-copy')}</g>` +
153    (marker === null
154      ? ''
155      : `<g class="pace-detail detail-reveal"><rect class="reveal-background track-width" height="18"/>${copy(paceCopy, 'pace-copy')}</g>`) +
156    '</g></g></g></svg>'
157  )
158}
159
160export function usageStatusSvg(text: string, hint?: string, compact = false) {
161  const hintX = compact ? 0 : captionWidth(text, PRESENTATION.captionSize) + 12
162  const width = Math.ceil(
163    hint ? hintX + captionWidth(hint, 12) : captionWidth(text, PRESENTATION.captionSize),
164  )
165  const description = hint ? `${text}. ${hint}` : text
166  const checking = text === 'Checking usage...'
167  const caption = checking
168    ? captionSvg('Checking usage', 0, 13.2, PRESENTATION.captionSize) +
169      [0, 1, 2]
170        .map(
171          (index) =>
172            `<g class="checking-dot" style="animation-delay:${index * 80}ms">${captionSvg('.', captionWidth('Checking usage' + '.'.repeat(index), PRESENTATION.captionSize), 13.2, PRESENTATION.captionSize)}</g>`,
173        )
174        .join('')
175    : captionSvg(text, 0, 13.2, PRESENTATION.captionSize)
176  const motion = checking
177    ? '.checking-dot{animation:checking-dot 1200ms linear infinite}@keyframes checking-dot{0%,100%{opacity:.35}50%{opacity:1}}@media(prefers-reduced-motion:reduce){.checking-dot{animation:none;opacity:1}}'
178    : ''
179  return {
180    width,
181    height: 18,
182    description,
183    source: `<svg xmlns="${SVG_NAMESPACE}" width="${width}" height="18" viewBox="0 0 ${width} 18" preserveAspectRatio="xMinYMid meet"><title>${xml(description)}</title><defs><path id="bitcount-pixel" d="${BITCOUNT_PIXEL}"/></defs><style>.ink{fill:#242424}.hint{fill:#66615b}@media(prefers-color-scheme:dark){.ink{fill:#ececea}.hint{fill:#b8b5b0}}${motion}</style>${!compact || !hint ? `<g class="ink">${caption}</g>` : ''}${hint ? `<g class="hint">${captionSvg(hint, hintX, 13, 12)}</g>` : ''}</svg>`,
184  }
185}
186
187export function secondaryTextSvg(
188  lines: readonly string[],
189  availableWidth: number,
190  maxLines = 2,
191) {
192  const width = Math.max(1, Math.floor(availableWidth))
193  const wrapped = lines.flatMap((text) => {
194    const rows: string[] = []
195    let row = ''
196    for (const word of text.split(/\s+/)) {
197      const next = row ? `${row} ${word}` : word
198      if (row && captionWidth(next, 12, false, 0.025) > width) {
199        rows.push(fitted(row, width))
200        row = word
201      } else row = next
202    }
203    if (row) rows.push(fitted(row, width))
204    return rows
205  })
206  const limit = Math.max(1, Math.floor(maxLines))
207  const rows =
208    wrapped.length <= limit
209      ? wrapped
210      : [...wrapped.slice(0, limit - 1), fitted(wrapped.slice(limit - 1).join(' '), width)]
211  const height = Math.max(1, rows.length) * 18
212  const description = lines.join(' ')
213  return {
214    width,
215    height,
216    description,
217    source: `<svg xmlns="${SVG_NAMESPACE}" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><title>${xml(description)}</title><defs><path id="bitcount-pixel" d="${BITCOUNT_PIXEL}"/></defs><style>.hint{fill:#66615b}@media(prefers-color-scheme:dark){.hint{fill:#b8b5b0}}</style><g class="hint">${rows.map((line, index) => captionSvg(line, 0, index * 18 + 13, 12, false, 0.025)).join('')}</g></svg>`,
218  }
219}
220
221export const MARK_DOTS: readonly (readonly [number, number])[] = [
222  [16, 9.3],
223  [22.4, 9.3],
224  [28.8, 9.3],
225  [35.2, 15.7],
226  [16, 22.1],
227  [22.4, 22.1],
228  [28.8, 22.1],
229  [35.2, 22.1],
230  [9.6, 28.5],
231  [35.2, 28.5],
232  [16, 34.9],
233  [22.4, 34.9],
234  [35.2, 34.9],
235]
236
237export const BRAND_MARK_SVG =
238  `<svg xmlns="${SVG_NAMESPACE}" viewBox="6.2 5.9 32.4 32.4">` +
239  MARK_DOTS.map(([cx, cy]) => `<circle cx="${cx}" cy="${cy}" r="3.2" fill="#FF5914"/>`).join(
240    '',
241  ) +
242  '</svg>'
243
hooks/usage-presentation.ts 204 lines
1import { captionWidth } from './usage-font'
2import { filledDotCount, pace, reachedPace, type UsageWindow } from './usage'
3
4export const PRESENTATION = {
5  markSize: 13,
6  markSlotSize: 14,
7  captionSize: 14,
8  brandInset: 5,
9  brandGap: 13,
10  inlineGap: 24,
11  stackedGap: 15,
12  inlineWidth: 480,
13  captionHeight: 18.75,
14  captionBaseline: 12.675,
15  resetBaseline: 12.075,
16  trackHeight: 12,
17  dotSize: 7,
18  dotGap: 4,
19  fillOpacity: 0.85,
20  dotPitch: 11,
21  paceHeight: 18,
22  entranceEnd: 960,
23  rollDuration: 260,
24  rollStagger: 12,
25  rollMaxStagger: 24,
26} as const
27export function desktopPercentage(window: UsageWindow): string {
28  return window.percent === null ? '—' : `${Math.floor(window.percent)}%`
29}
30
31export type UsageLayout = 'inline' | 'current'
32export const INLINE_METER_HEIGHT = 18
33
34export const METER_HEIGHT = PRESENTATION.captionHeight + (PRESENTATION.trackHeight + PRESENTATION.paceHeight) / 2
35
36export function usageLayout(contentWidth: number, variant: UsageLayout = 'current') {
37  const width = Math.max(1, Number.isFinite(contentWidth) ? contentWidth : 1)
38  const inline = width >= PRESENTATION.inlineWidth
39  const metersX = PRESENTATION.brandInset + PRESENTATION.markSlotSize + PRESENTATION.brandGap
40  const metersWidth = Math.max(
41    1,
42    width - PRESENTATION.brandInset * 2 - PRESENTATION.markSlotSize - PRESENTATION.brandGap,
43  )
44  const meterWidth = inline ? (metersWidth - PRESENTATION.inlineGap) / 2 : metersWidth
45  const meterHeight = variant === 'inline' ? INLINE_METER_HEIGHT : METER_HEIGHT
46  const stackedGap = variant === 'inline' ? 1 : PRESENTATION.stackedGap
47  const height = inline ? meterHeight : meterHeight * 2 + stackedGap
48  return {
49    width,
50    height,
51    inline,
52    meterWidth,
53    meterHeight,
54    stackedGap,
55    mark: {
56      x: PRESENTATION.brandInset + (PRESENTATION.markSlotSize - PRESENTATION.markSize) / 2,
57      y: (height - PRESENTATION.markSize) / 2,
58    },
59    meters: [
60      { x: metersX, y: 0 },
61      {
62        x: inline ? metersX + meterWidth + PRESENTATION.inlineGap : metersX,
63        y: inline ? 0 : meterHeight + stackedGap,
64      },
65    ],
66  }
67}
68
69export function meterGeometry(window: UsageWindow, width: number, now: number, variant: UsageLayout = 'current') {
70  const inline = variant === 'inline'
71  const height = inline ? INLINE_METER_HEIGHT : METER_HEIGHT
72  const baseline = inline
73    ? height / 2 + PRESENTATION.captionSize * 0.3
74    : PRESENTATION.captionBaseline
75  const percent = desktopPercentage(window)
76  const percentX = Math.max(0, width - captionWidth(percent, PRESENTATION.captionSize, true))
77  const labelWidth = captionWidth(window.label, PRESENTATION.captionSize)
78  const trackX = inline
79    ? Math.max(
80        labelWidth,
81        captionWidth('5-hour', PRESENTATION.captionSize),
82        captionWidth('Weekly', PRESENTATION.captionSize),
83      ) + 7
84    : 0
85  const trackWidth = inline ? Math.max(0, percentX - 7 - trackX) : width
86  const trackY = inline ? height / 2 : PRESENTATION.captionHeight + 6
87  const count = Math.max(0, Math.floor((trackWidth - PRESENTATION.dotSize) / PRESENTATION.dotPitch) + 1)
88  const span = Math.max(0, (count - 1) * PRESENTATION.dotPitch)
89  const used = filledDotCount(window.percent, count)
90  const dots =
91    count < 2
92      ? []
93      : Array.from({ length: count }, (_, index) => ({
94          x: trackX + PRESENTATION.dotSize / 2 + index * PRESENTATION.dotPitch,
95          y: trackY,
96          fraction: index < used ? 1 : 0,
97        }))
98  const lastUsed = [...dots].reverse().find((dot) => dot.fraction > 0)
99  const value = pace(window, now)
100  const resetX = inline ? trackX : labelWidth + 8
101  return {
102    dots,
103    height,
104    baseline,
105    trackX,
106    trackWidth,
107    trackY,
108    labelWidth,
109    percent,
110    percentX,
111    resetX,
112    resetWidth: inline ? trackWidth : Math.max(0, percentX - 8 - resetX),
113    fillEnd:
114      lastUsed === undefined
115        ? trackX
116        : lastUsed.x - PRESENTATION.dotSize / 2 + PRESENTATION.dotSize * lastUsed.fraction,
117    marker:
118      value === null || dots.length === 0
119        ? null
120        : {
121            x: trackX + PRESENTATION.dotSize / 2 + span * value,
122            y: trackY,
123            height: PRESENTATION.paceHeight,
124            value,
125            reached: reachedPace(window, value),
126          },
127  }
128}
129
130function clamp(value: number): number {
131  return Math.max(0, Math.min(1, value))
132}
133
134export function bezier(progress: number, x1: number, y1: number, x2: number, y2: number): number {
135  const x = clamp(progress)
136  if (x === 0 || x === 1) return x
137  const at = (t: number, a: number, b: number) => 3 * (1 - t) ** 2 * t * a + 3 * (1 - t) * t ** 2 * b + t ** 3
138  let low = 0
139  let high = 1
140  for (let i = 0; i < 30; i++) {
141    const mid = (low + high) / 2
142    if (at(mid, x1, x2) < x) low = mid
143    else high = mid
144  }
145  return at((low + high) / 2, y1, y2)
146}
147
148export function entranceFrame(elapsed: number, reduced = false) {
149  if (reduced) {
150    const opacity = bezier(elapsed / 80, 0.32, 0.72, 0, 1)
151    return { markOpacity: opacity, markScale: 1, meters: [0, 1].map(() => ({ opacity, rise: 0 })), fill: 1, done: elapsed >= 80 }
152  }
153  const mark = bezier(elapsed / 240, 0.32, 0.72, 0, 1)
154  return {
155    markOpacity: mark,
156    markScale: 0.92 + 0.08 * mark,
157    meters: [100, 240].map((start) => {
158      const progress = bezier((elapsed - start) / 360, 0.32, 0.72, 0, 1)
159      return { opacity: progress, rise: 6 * (1 - progress) }
160    }),
161    fill: bezier((elapsed - 600) / 360, 0.23, 1, 0.32, 1),
162    done: elapsed >= PRESENTATION.entranceEnd,
163  }
164}
165
166export function rollingColumns(previous: string, current: string) {
167  const old = [...previous].reverse()
168  return [...current].map((character, index) => {
169    const column = current.length - index - 1
170    const before = old[column] ?? ' '
171    return {
172      character,
173      before,
174      changed: /[0-9]/.test(character) && before !== character,
175      delay: Math.min(PRESENTATION.rollMaxStagger, column * PRESENTATION.rollStagger),
176    }
177  })
178}
179
180export type EntranceState = { phase: 'pending' | 'running' | 'consumed'; generation: number; startedAt: number | null }
181
182// The session boundary owns this state. Render subtrees do not grant another run.
183export class EntranceOwner {
184  state: EntranceState = { phase: 'consumed', generation: 0, startedAt: null }
185
186  load(eligible: boolean): void {
187    this.state = { phase: eligible ? 'pending' : 'consumed', generation: this.state.generation + 1, startedAt: null }
188  }
189
190  claim(now: number, visible: boolean, hasReading: boolean): boolean {
191    if (this.state.phase !== 'pending' || !visible || !hasReading) return false
192    this.state = { ...this.state, phase: 'running', startedAt: now }
193    return true
194  }
195
196  cancel(): void {
197    this.state = { phase: 'consumed', generation: this.state.generation + 1, startedAt: null }
198  }
199
200  accepts(generation: number): boolean {
201    return this.state.phase === 'running' && this.state.generation === generation
202  }
203}
204
hooks/commands.ts 6 lines
1import { describe } from "../lib/product.mjs"
2
3export const ARGUMENT_HINT = "[refresh | help]"
4export const COMMAND = "antiburn"
5export const describeCommands = () => describe("claude-code", `/${COMMAND}`, "native")
6
hooks/checking-status.ts 20 lines
1import type { ClientModule } from 'claude-code'
2
3const CheckingStatus: ClientModule<null, number> = (_props, surface) => {
4  if (surface.state === undefined) {
5    let phase = 0
6    surface.setState(phase)
7    surface.every(300, () => {
8      phase = (phase + 1) % 3
9      surface.setState(phase)
10    })
11  }
12  const { Text } = surface.elements
13  return Text({
14    wrap: 'wrap',
15    children: ['Checking usage', ...[0, 1, 2].map((index) => Text({ dimColor: index !== (surface.state ?? 0), children: '.' }))],
16  })
17}
18
19export default CheckingStatus
20
hooks/usage-target.tsx 31 lines
1import type { ClientSurface, Color } from 'claude-code'
2
3export type UsageTargetProps = { binding: string; width: number; text: string; color?: Color; dimColor?: boolean }
4export type UsageTargetState = { binding: string; hovered: boolean; pressed: boolean }
5
6export function wireUsageTarget(props: UsageTargetProps, surface: ClientSurface<UsageTargetState>): void {
7  surface.onPointer((event) => {
8    const previous = surface.state?.binding === props.binding ? surface.state : { binding: props.binding, hovered: false, pressed: false }
9    const hovered = event.type !== 'leave' && event.y === 0 && event.x >= 0 && event.x < props.width
10    const pressed = event.type === 'down' ? hovered && event.button === 'left' : event.type === 'up' ? false : previous.pressed
11    const activate = event.type === 'up' && event.button === 'left' && previous.pressed && hovered
12    if (hovered !== previous.hovered || pressed !== previous.pressed) surface.setState({ binding: props.binding, hovered, pressed })
13    if (hovered !== previous.hovered || activate) surface.post({ binding: props.binding, hovered, activate })
14  })
15  surface.onKey((event) => {
16    if (event.key === 'return' && !event.ctrl && !event.meta && !event.shift) {
17      surface.post({ binding: props.binding, hovered: surface.state?.binding === props.binding && surface.state.hovered, activate: true })
18    }
19  })
20}
21
22export default function UsageTarget(props: UsageTargetProps, surface: ClientSurface<UsageTargetState>) {
23  wireUsageTarget(props, surface)
24  const { Text } = surface.elements
25  return (
26    <Text color={props.color} dimColor={props.dimColor} wrap="truncate">
27      {props.text}
28    </Text>
29  )
30}
31
hooks/usage-font.ts 34 lines
1import { BITCOUNT_GLYPHS, BITCOUNT_PIXEL, BITCOUNT_TABULAR, type DotGlyph } from './usage-font-data'
2
3export { BITCOUNT_PIXEL }
4export type CaptionGlyph = { character: string; x: number; advance: number; dots: DotGlyph['dots'] }
5
6export function captionGlyphs(text: string, size = 15, tabular = false, tracking = 0.035): CaptionGlyph[] {
7  let x = 0
8  return [...text.toUpperCase()].map((character) => {
9    const glyph = (tabular ? BITCOUNT_TABULAR[character] : undefined) ?? BITCOUNT_GLYPHS[character] ?? BITCOUNT_GLYPHS['—']!
10    // The source tnum feature keeps mixed advances. Fixed digit cells prevent rolls from shifting unchanged columns.
11    const units = tabular && /[0-9]/.test(character) ? 700 : glyph.advance
12    const advance = (units / 1000 + tracking) * size
13    const result = { character, x, advance, dots: glyph.dots }
14    x += advance
15    return result
16  })
17}
18
19export function captionWidth(text: string, size = 15, tabular = false, tracking = 0.035): number {
20  const glyphs = captionGlyphs(text, size, tabular, tracking)
21  const last = glyphs[glyphs.length - 1]
22  return last === undefined ? 0 : last.x + last.advance
23}
24
25export function captionSvg(text: string, x: number, baseline: number, size = 15, tabular = false, tracking = 0.035): string {
26  const scale = size / 1000
27  return captionGlyphs(text, size, tabular, tracking)
28    .map(
29      (glyph) =>
30        `<g transform="translate(${x + glyph.x} ${baseline}) scale(${scale} ${-scale})">${glyph.dots.map(([dx, dy]) => `<use href="#bitcount-pixel" x="${dx}" y="${dy}"/>`).join('')}</g>`,
31    )
32    .join('')
33}
34
hooks/usage-font-data.ts 831 lines
1// Generated from Bitcount Prop Single, weight 400, default variation axes.
2// See ../assets/bitcount/OFL.txt and README.md for source and license.
3export type DotGlyph = { advance: number; dots: readonly (readonly [number, number])[] }
4export const BITCOUNT_PIXEL =
5  'M52 1 51 0H50Q41 0 33.5 2.5Q26 5 20.0 9.5Q14 14 9.5 20.0Q5 26 2.5 33.5Q0 41 0 50V51L1 52H52Q52 52 52.0 52.0Q52 52 52.0 52.0Q52 52 52.0 52.0Q52 52 52.0 52.0Q52 52 52 52ZM48 1V52Q48 52 48.0 52.0Q48 52 48.0 52.0Q48 52 48.0 52.0Q48 52 48.0 52.0Q48 52 48 52H99L100 51V50Q100 41 97.5 33.5Q95 26 90.5 20.0Q86 14 80.0 9.5Q74 5 66.5 2.5Q59 0 50 0H49ZM48 99 49 100H50Q59 100 66.5 97.5Q74 95 80.0 90.5Q86 86 90.5 80.0Q95 74 97.5 66.5Q100 59 100 50V49L99 48H48Q48 48 48.0 48.0Q48 48 48.0 48.0Q48 48 48.0 48.0Q48 48 48.0 48.0Q48 48 48 48ZM52 99V48Q52 48 52.0 48.0Q52 48 52.0 48.0Q52 48 52.0 48.0Q52 48 52.0 48.0Q52 48 52 48H1L0 49V50Q0 59 2.5 66.5Q5 74 9.5 80.0Q14 86 20.0 90.5Q26 95 33.5 97.5Q41 100 50 100H51Z'
6export const BITCOUNT_GLYPHS: Readonly<Record<string, DotGlyph> & Record<'.' | ',' | ':', DotGlyph>> = {
7  '0': {
8    advance: 700,
9    dots: [
10      [0, 100],
11      [0, 200],
12      [0, 300],
13      [0, 400],
14      [100, 0],
15      [100, 500],
16      [200, 0],
17      [200, 500],
18      [300, 0],
19      [300, 500],
20      [400, 100],
21      [400, 200],
22      [400, 300],
23      [400, 400],
24    ],
25  },
26  '1': {
27    advance: 500,
28    dots: [
29      [0, 0],
30      [0, 400],
31      [100, 0],
32      [100, 100],
33      [100, 200],
34      [100, 300],
35      [100, 400],
36      [100, 500],
37      [200, 0],
38    ],
39  },
40  '2': {
41    advance: 700,
42    dots: [
43      [0, 0],
44      [0, 100],
45      [0, 400],
46      [100, 0],
47      [100, 200],
48      [100, 500],
49      [200, 0],
50      [200, 200],
51      [200, 500],
52      [300, 0],
53      [300, 200],
54      [300, 500],
55      [400, 0],
56      [400, 300],
57      [400, 400],
58    ],
59  },
60  '3': {
61    advance: 700,
62    dots: [
63      [0, 100],
64      [0, 500],
65      [100, 0],
66      [100, 300],
67      [100, 500],
68      [200, 0],
69      [200, 300],
70      [200, 500],
71      [300, 0],
72      [300, 300],
73      [300, 500],
74      [400, 100],
75      [400, 200],
76      [400, 400],
77      [400, 500],
78    ],
79  },
80  '4': {
81    advance: 700,
82    dots: [
83      [0, 100],
84      [0, 200],
85      [100, 100],
86      [100, 300],
87      [200, 100],
88      [200, 400],
89      [300, 0],
90      [300, 100],
91      [300, 200],
92      [300, 300],
93      [300, 400],
94      [300, 500],
95      [400, 100],
96    ],
97  },
98  '5': {
99    advance: 700,
100    dots: [
101      [0, 100],
102      [0, 300],
103      [0, 400],
104      [0, 500],
105      [100, 0],
106      [100, 300],
107      [100, 500],
108      [200, 0],
109      [200, 300],
110      [200, 500],
111      [300, 0],
112      [300, 300],
113      [300, 500],
114      [400, 100],
115      [400, 200],
116      [400, 500],
117    ],
118  },
119  '6': {
120    advance: 700,
121    dots: [
122      [0, 100],
123      [0, 200],
124      [0, 300],
125      [0, 400],
126      [100, 0],
127      [100, 300],
128      [100, 500],
129      [200, 0],
130      [200, 300],
131      [200, 500],
132      [300, 0],
133      [300, 300],
134      [300, 500],
135      [400, 100],
136      [400, 200],
137    ],
138  },
139  '7': {
140    advance: 700,
141    dots: [
142      [0, 500],
143      [100, 500],
144      [200, 0],
145      [200, 100],
146      [200, 500],
147      [300, 200],
148      [300, 300],
149      [300, 500],
150      [400, 400],
151      [400, 500],
152    ],
153  },
154  '8': {
155    advance: 700,
156    dots: [
157      [0, 100],
158      [0, 200],
159      [0, 400],
160      [100, 0],
161      [100, 300],
162      [100, 500],
163      [200, 0],
164      [200, 300],
165      [200, 500],
166      [300, 0],
167      [300, 300],
168      [300, 500],
169      [400, 100],
170      [400, 200],
171      [400, 400],
172    ],
173  },
174  '9': {
175    advance: 700,
176    dots: [
177      [0, 300],
178      [0, 400],
179      [100, 0],
180      [100, 200],
181      [100, 500],
182      [200, 0],
183      [200, 200],
184      [200, 500],
185      [300, 0],
186      [300, 200],
187      [300, 500],
188      [400, 100],
189      [400, 200],
190      [400, 300],
191      [400, 400],
192    ],
193  },
194  A: {
195    advance: 700,
196    dots: [
197      [0, 0],
198      [0, 100],
199      [0, 200],
200      [0, 300],
201      [0, 400],
202      [100, 200],
203      [100, 500],
204      [200, 200],
205      [200, 500],
206      [300, 200],
207      [300, 500],
208      [400, 0],
209      [400, 100],
210      [400, 200],
211      [400, 300],
212      [400, 400],
213    ],
214  },
215  B: {
216    advance: 700,
217    dots: [
218      [0, 0],
219      [0, 100],
220      [0, 200],
221      [0, 300],
222      [0, 400],
223      [0, 500],
224      [100, 0],
225      [100, 300],
226      [100, 500],
227      [200, 0],
228      [200, 300],
229      [200, 500],
230      [300, 0],
231      [300, 300],
232      [300, 500],
233      [400, 100],
234      [400, 200],
235      [400, 400],
236    ],
237  },
238  C: {
239    advance: 700,
240    dots: [
241      [0, 100],
242      [0, 200],
243      [0, 300],
244      [0, 400],
245      [100, 0],
246      [100, 500],
247      [200, 0],
248      [200, 500],
249      [300, 0],
250      [300, 500],
251      [400, 100],
252      [400, 400],
253    ],
254  },
255  D: {
256    advance: 700,
257    dots: [
258      [0, 0],
259      [0, 100],
260      [0, 200],
261      [0, 300],
262      [0, 400],
263      [0, 500],
264      [100, 0],
265      [100, 500],
266      [200, 0],
267      [200, 500],
268      [300, 0],
269      [300, 500],
270      [400, 100],
271      [400, 200],
272      [400, 300],
273      [400, 400],
274    ],
275  },
276  E: {
277    advance: 700,
278    dots: [
279      [0, 0],
280      [0, 100],
281      [0, 200],
282      [0, 300],
283      [0, 400],
284      [0, 500],
285      [100, 0],
286      [100, 300],
287      [100, 500],
288      [200, 0],
289      [200, 300],
290      [200, 500],
291      [300, 0],
292      [300, 300],
293      [300, 500],
294      [400, 0],
295      [400, 500],
296    ],
297  },
298  F: {
299    advance: 500,
300    dots: [
301      [0, 0],
302      [0, 100],
303      [0, 200],
304      [0, 300],
305      [0, 400],
306      [0, 500],
307      [100, 200],
308      [100, 500],
309      [200, 200],
310      [200, 500],
311      [300, 500],
312    ],
313  },
314  G: {
315    advance: 700,
316    dots: [
317      [0, 100],
318      [0, 200],
319      [0, 300],
320      [0, 400],
321      [100, 0],
322      [100, 500],
323      [200, 0],
324      [200, 500],
325      [300, 0],
326      [300, 200],
327      [300, 500],
328      [400, 100],
329      [400, 200],
330      [400, 400],
331    ],
332  },
333  H: {
334    advance: 700,
335    dots: [
336      [0, 0],
337      [0, 100],
338      [0, 200],
339      [0, 300],
340      [0, 400],
341      [0, 500],
342      [100, 300],
343      [200, 300],
344      [300, 300],
345      [400, 0],
346      [400, 100],
347      [400, 200],
348      [400, 300],
349      [400, 400],
350      [400, 500],
351    ],
352  },
353  I: {
354    advance: 300,
355    dots: [
356      [0, 0],
357      [0, 100],
358      [0, 200],
359      [0, 300],
360      [0, 400],
361      [0, 500],
362    ],
363  },
364  J: {
365    advance: 300,
366    dots: [
367      [-100, -100],
368      [0, 0],
369      [0, 100],
370      [0, 200],
371      [0, 300],
372      [0, 400],
373      [0, 500],
374    ],
375  },
376  K: {
377    advance: 700,
378    dots: [
379      [0, 0],
380      [0, 100],
381      [0, 200],
382      [0, 300],
383      [0, 400],
384      [0, 500],
385      [100, 200],
386      [200, 200],
387      [200, 300],
388      [300, 100],
389      [300, 400],
390      [400, 0],
391      [400, 500],
392    ],
393  },
394  L: {
395    advance: 500,
396    dots: [
397      [0, 0],
398      [0, 100],
399      [0, 200],
400      [0, 300],
401      [0, 400],
402      [0, 500],
403      [100, 0],
404      [200, 0],
405      [300, 0],
406    ],
407  },
408  M: {
409    advance: 900,
410    dots: [
411      [0, 0],
412      [0, 100],
413      [0, 200],
414      [0, 300],
415      [0, 400],
416      [0, 500],
417      [100, 400],
418      [200, 300],
419      [300, 200],
420      [400, 300],
421      [500, 400],
422      [600, 0],
423      [600, 100],
424      [600, 200],
425      [600, 300],
426      [600, 400],
427      [600, 500],
428    ],
429  },
430  N: {
431    advance: 700,
432    dots: [
433      [0, 0],
434      [0, 100],
435      [0, 200],
436      [0, 300],
437      [0, 400],
438      [0, 500],
439      [100, 400],
440      [200, 300],
441      [300, 200],
442      [400, 0],
443      [400, 100],
444      [400, 200],
445      [400, 300],
446      [400, 400],
447      [400, 500],
448    ],
449  },
450  O: {
451    advance: 700,
452    dots: [
453      [0, 100],
454      [0, 200],
455      [0, 300],
456      [0, 400],
457      [100, 0],
458      [100, 500],
459      [200, 0],
460      [200, 500],
461      [300, 0],
462      [300, 500],
463      [400, 100],
464      [400, 200],
465      [400, 300],
466      [400, 400],
467    ],
468  },
469  P: {
470    advance: 700,
471    dots: [
472      [0, 0],
473      [0, 100],
474      [0, 200],
475      [0, 300],
476      [0, 400],
477      [0, 500],
478      [100, 200],
479      [100, 500],
480      [200, 200],
481      [200, 500],
482      [300, 200],
483      [300, 500],
484      [400, 300],
485      [400, 400],
486    ],
487  },
488  Q: {
489    advance: 700,
490    dots: [
491      [0, 100],
492      [0, 200],
493      [0, 300],
494      [0, 400],
495      [100, 0],
496      [100, 500],
497      [200, 0],
498      [200, 100],
499      [200, 500],
500      [300, 0],
501      [300, 500],
502      [400, -100],
503      [400, 100],
504      [400, 200],
505      [400, 300],
506      [400, 400],
507    ],
508  },
509  R: {
510    advance: 700,
511    dots: [
512      [0, 0],
513      [0, 100],
514      [0, 200],
515      [0, 300],
516      [0, 400],
517      [0, 500],
518      [100, 200],
519      [100, 500],
520      [200, 200],
521      [200, 500],
522      [300, 200],
523      [300, 500],
524      [400, 0],
525      [400, 100],
526      [400, 300],
527      [400, 400],
528    ],
529  },
530  S: {
531    advance: 700,
532    dots: [
533      [0, 0],
534      [0, 400],
535      [100, 0],
536      [100, 300],
537      [100, 500],
538      [200, 0],
539      [200, 200],
540      [200, 300],
541      [200, 500],
542      [300, 0],
543      [300, 200],
544      [300, 500],
545      [400, 100],
546      [400, 500],
547    ],
548  },
549  T: {
550    advance: 600,
551    dots: [
552      [0, 500],
553      [100, 500],
554      [200, 0],
555      [200, 100],
556      [200, 200],
557      [200, 300],
558      [200, 400],
559      [200, 500],
560      [300, 500],
561      [400, 500],
562    ],
563  },
564  U: {
565    advance: 700,
566    dots: [
567      [0, 100],
568      [0, 200],
569      [0, 300],
570      [0, 400],
571      [0, 500],
572      [100, 0],
573      [200, 0],
574      [300, 0],
575      [400, 100],
576      [400, 200],
577      [400, 300],
578      [400, 400],
579      [400, 500],
580    ],
581  },
582  V: {
583    advance: 700,
584    dots: [
585      [0, 200],
586      [0, 300],
587      [0, 400],
588      [0, 500],
589      [100, 100],
590      [200, 0],
591      [300, 100],
592      [400, 200],
593      [400, 300],
594      [400, 400],
595      [400, 500],
596    ],
597  },
598  W: {
599    advance: 1100,
600    dots: [
601      [0, 200],
602      [0, 300],
603      [0, 400],
604      [0, 500],
605      [100, 100],
606      [200, 0],
607      [300, 100],
608      [400, 200],
609      [400, 300],
610      [400, 400],
611      [400, 500],
612      [500, 100],
613      [600, 0],
614      [700, 100],
615      [800, 200],
616      [800, 300],
617      [800, 400],
618      [800, 500],
619    ],
620  },
621  X: {
622    advance: 700,
623    dots: [
624      [0, 0],
625      [0, 500],
626      [100, 100],
627      [100, 400],
628      [200, 200],
629      [200, 300],
630      [300, 100],
631      [300, 400],
632      [400, 0],
633      [400, 500],
634    ],
635  },
636  Y: {
637    advance: 700,
638    dots: [
639      [0, 400],
640      [0, 500],
641      [100, 300],
642      [200, 0],
643      [200, 100],
644      [200, 200],
645      [300, 300],
646      [400, 400],
647      [400, 500],
648    ],
649  },
650  Z: {
651    advance: 700,
652    dots: [
653      [0, 0],
654      [0, 100],
655      [0, 500],
656      [100, 0],
657      [100, 200],
658      [100, 500],
659      [200, 0],
660      [200, 300],
661      [200, 500],
662      [300, 0],
663      [300, 400],
664      [300, 500],
665      [400, 0],
666      [400, 500],
667    ],
668  },
669  '%': {
670    advance: 500,
671    dots: [
672      [0, 100],
673      [0, 400],
674      [0, 500],
675      [100, 200],
676      [100, 400],
677      [100, 500],
678      [200, 0],
679      [200, 100],
680      [200, 300],
681      [300, 0],
682      [300, 100],
683      [300, 400],
684    ],
685  },
686  '.': {
687    advance: 300,
688    dots: [
689      [0, 0],
690      [0, 100],
691      [100, 0],
692      [100, 100],
693    ],
694  },
695  ',': {
696    advance: 300,
697    dots: [
698      [0, -100],
699      [100, 0],
700      [100, 100],
701    ],
702  },
703  ':': {
704    advance: 200,
705    dots: [
706      [0, 0],
707      [0, 300],
708    ],
709  },
710  ' ': { advance: 300, dots: [] },
711  '\u2014': {
712    advance: 600,
713    dots: [
714      [0, 200],
715      [100, 200],
716      [200, 200],
717      [300, 200],
718      [400, 200],
719    ],
720  },
721  '-': {
722    advance: 400,
723    dots: [
724      [0, 200],
725      [100, 200],
726      [200, 200],
727    ],
728  },
729  '+': {
730    advance: 600,
731    dots: [
732      [0, 200],
733      [100, 200],
734      [200, 0],
735      [200, 100],
736      [200, 200],
737      [200, 300],
738      [200, 400],
739      [300, 200],
740      [400, 200],
741    ],
742  },
743}
744function tabularGlyph(glyph: DotGlyph, offset: number): DotGlyph {
745  return { advance: 600, dots: glyph.dots.map(([x, y]) => [x + offset, y]) }
746}
747export const BITCOUNT_TABULAR: Readonly<Record<string, DotGlyph>> = {
748  '1': {
749    advance: 600,
750    dots: [
751      [0, 0],
752      [0, 300],
753      [100, 0],
754      [100, 400],
755      [200, 0],
756      [200, 100],
757      [200, 200],
758      [200, 300],
759      [200, 400],
760      [200, 500],
761      [300, 0],
762      [400, 0],
763    ],
764  },
765  '6': {
766    advance: 600,
767    dots: [
768      [0, 100],
769      [0, 200],
770      [0, 300],
771      [0, 400],
772      [100, 0],
773      [100, 300],
774      [100, 500],
775      [200, 0],
776      [200, 300],
777      [200, 500],
778      [300, 0],
779      [300, 300],
780      [300, 500],
781      [400, 100],
782      [400, 200],
783    ],
784  },
785  '7': {
786    advance: 600,
787    dots: [
788      [0, 500],
789      [100, 500],
790      [200, 0],
791      [200, 100],
792      [200, 500],
793      [300, 200],
794      [300, 300],
795      [300, 500],
796      [400, 400],
797      [400, 500],
798    ],
799  },
800  '%': {
801    advance: 600,
802    dots: [
803      [0, 100],
804      [0, 400],
805      [0, 500],
806      [100, 200],
807      [100, 400],
808      [100, 500],
809      [200, 0],
810      [200, 100],
811      [200, 300],
812      [300, 0],
813      [300, 100],
814      [300, 400],
815    ],
816  },
817  '.': tabularGlyph(BITCOUNT_GLYPHS['.'], 200),
818  ',': tabularGlyph(BITCOUNT_GLYPHS[','], 100),
819  ':': tabularGlyph(BITCOUNT_GLYPHS[':'], 200),
820  ' ': { advance: 600, dots: [] },
821  '-': {
822    advance: 600,
823    dots: [
824      [100, 200],
825      [200, 200],
826      [300, 200],
827      [400, 200],
828    ],
829  },
830}
831