● ● ● ○ │ ○ ○ · 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…

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.
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.
Desktop Code · usage states

Synthetic usage data illustrates the display states; this is not a current account reading.
Desktop Code · reset and pace details

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

Synthetic usage data illustrates the display states. Terminal meters stack when space is limited.
Terminal · reset and pace details

Illustrative hover details from the public plugin guide; not release acceptance evidence.
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.
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 control | Action |
|---|---|
/antiburn | Show usage, reset and pace details as text |
/antiburn refresh | Check usage and return a short result |
/antiburn help | Explain the usage and refresh commands |
| A meter's label or pace control | Reveal reset or pace details |
| Refresh / ↻, when shown | Check 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.
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.
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.
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.
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.
hooks/quota-controller.tsx 1252 lines1import { 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 lines1export const VERSION = '0.2.1'
2export const CONTRACT_VERSION = 1
3export const HOSTS = ['claude-code', 'codex', 'cursor', 'opencode', 'pi']
4
5/** @typedef {'plain' | 'ascii' | 'flame'} Style */
6/** @typedef {'help'|'status'|'diagnostics'|'usage'|'details'|'refresh'|'invalid'} Command */
7
8/** @param {string} input @returns {{command: Command, style: Style}} */
9export function parseCommand(input = '') {
10 if (typeof input !== 'string' || input.length > 512)
11 return { command: 'invalid', style: 'plain' }
12
13 const parts = input.trim().split(/\s+/).filter(Boolean)
14 const [command = 'help', arg] = parts
15 const style = arg === '--ascii' ? 'ascii' : arg === '--flame' ? 'flame' : 'plain'
16 if (parts.length > 2) return { command: 'invalid', style }
17 if (command === 'help' || command === 'status') {
18 if (arg && arg !== '--ascii' && arg !== '--flame') return { command: 'invalid', style }
19 return { command, style }
20 }
21 if (arg) {
22 if (command === 'usage' && ['details', 'refresh'].includes(arg))
23 return { command: /** @type {'details'|'refresh'} */ (arg), style }
24 return { command: 'invalid', style }
25 }
26 if (['diagnostics', 'usage', 'details', 'refresh'].includes(command))
27 return { command: /** @type {Command} */ (command), style }
28 return { command: 'invalid', style }
29}
30
31/** @param {string} host @param {string} invocation @param {'native'|'model-mediated'} mode @param {string} surface @param {string} [hostVersion] */
32export function describe(host, invocation, mode, surface = 'unknown', hostVersion = 'unknown') {
33 return {
34 id: 'antiburn',
35 version: VERSION,
36 contractVersion: CONTRACT_VERSION,
37 host: {
38 id: HOSTS.includes(host) ? host : 'unknown',
39 version: clean(hostVersion),
40 surface: clean(surface),
41 },
42 capabilities: {
43 invocationMode: mode,
44 invocation: clean(invocation),
45 output: clean(surface),
46 composition: host === 'claude-code',
47 usage: host === 'claude-code',
48 },
49 certification: 'unverified',
50 }
51}
52
53/** @param {string} text */
54function clean(text) {
55 return String(text)
56 .replace(/[\x00-\x1f\x7f-\x9f]/g, '')
57 .slice(0, 128)
58}
59
60/** @param {{markdown?: boolean, unicode?: boolean}} capability @returns {Style} */
61export function presentation(capability) {
62 return capability.unicode === false ? 'ascii' : 'plain'
63}
64
65/** @param {Style} style */
66function brand(style) {
67 if (style === 'ascii') return '`antiburn`'
68 return '🔥 `antiburn`'
69}
70
71/** @param {ReturnType<typeof describe>} meta @param {Style} [style] @param {boolean} [usage] */
72export function renderHelp(meta, style = 'plain', usage = false) {
73 return [
74 brand(style),
75 ...(usage ? ['Unknown arguments.'] : []),
76 `Usage: ${meta.capabilities.invocation} [help|status|diagnostics|usage [details|refresh]]`,
77 'Help and status accept --ascii or --flame.',
78 'Aliases: details and refresh show or refresh usage.',
79 'Shows antiburn feature and usage information.',
80 `antiburn ${VERSION} (contract ${CONTRACT_VERSION})`,
81 ].join('\n')
82}
83
84/** @param {ReturnType<typeof describe>} meta @param {Style} [style] */
85export function renderStatus(meta, style = 'plain') {
86 return [
87 brand(style),
88 `antiburn: ${meta.version} (contract ${meta.contractVersion})`,
89 `Usage feature: ${meta.capabilities.usage ? 'Included' : 'Not included'}`,
90 `Host: ${meta.host.id} ${meta.host.version}`,
91 `Surface: ${meta.host.surface}`,
92 `Invocation: ${meta.capabilities.invocation} (${meta.capabilities.invocationMode})`,
93 'Runtime certification: Unverified',
94 ].join('\n')
95}
96
97/** @param {string} input @returns {'details'|'refresh'|'help'|'invalid'} */
98export function parseUsageCommand(input = '') {
99 if (typeof input !== 'string' || input.length > 512) return 'invalid'
100 const args = input.trim().split(/\s+/).filter(Boolean).join(' ')
101 if (['', 'usage', 'details', 'usage details'].includes(args)) return 'details'
102 if (['refresh', 'usage refresh'].includes(args)) return 'refresh'
103 return args === 'help' ? 'help' : 'invalid'
104}
105
106/** @param {string} invocation @param {boolean} [invalid] */
107export function renderUsageHelp(invocation, invalid = false) {
108 return [
109 ...(invalid ? ['Unknown arguments.'] : []),
110 `Run \`${invocation}\` to see your usage.`,
111 `Run \`${invocation} refresh\` to update it.`,
112 ].join('\n')
113}
114hooks/brand.ts 4 lines1export const APP_URL = 'https://antiburn.com'
2
3export const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
4hooks/account-usage.ts 18 lines1import 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}
18hooks/usage.ts 126 lines1export 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}
126hooks/usage-graphics.ts 243 lines1import { 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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>'
243hooks/usage-presentation.ts 204 lines1import { 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}
204hooks/commands.ts 6 lines1import { describe } from "../lib/product.mjs"
2
3export const ARGUMENT_HINT = "[refresh | help]"
4export const COMMAND = "antiburn"
5export const describeCommands = () => describe("claude-code", `/${COMMAND}`, "native")
6hooks/checking-status.ts 20 lines1import 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
20hooks/usage-target.tsx 31 lines1import 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}
31hooks/usage-font.ts 34 lines1import { 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}
34hooks/usage-font-data.ts 831 lines1// 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