A usage board for Claude Code: a weekly pace gauge and a sliding ticker of your spend per model, in the terminal and on the desktop.

A usage board for Claude Code, in the terminal and in the desktop app. It shows what today (or this week, this month, all time) has cost, model by model, and where your weekly limit is heading.
Two rows above the prompt: a weekly pace gauge on the left and an LED-style crawl of spend per model on the right. One button steps the period.

A slim strip above the prompt with the weekly ring, the total, and a crawl of the models. The Details button opens a pane with period tabs, a bar for each model, a 14-day sparkline and the weekly ring.

The demos are drawn by the mod's own code from invented figures. The real app's fonts and spacing differ a little.
Lands near 82%, resets Mon or Runs out Sat 22:00. The forecast is a straight line, so it runs pessimistic over nights and weekends.ccusage on your PATH. npm install -g ccusage
claude plugin marketplace add aycandv/claude-usage-meter
claude plugin install usage-ticker@claude-usage-meter
/reload-plugins. In the desktop app, restart the Code tab or open a new session.The board shows above the prompt.
To update, run claude plugin marketplace update claude-usage-meter, then claude plugin update usage-ticker@claude-usage-meter, then load the plugin again as in step 4.
ccusage daily --json --breakdown read gives every day. All four periods are added up from it. It runs when a session starts, after a turn, and every 5 minutes. Local time comes from your machine.Raster, repainted in place with $.ui.blit. From 100 columns it has the full layout and the period button. Narrower terminals get less. Under 50 columns you get one line of text.Raster, so the strip is an Svg image. The app reloads an interactive Svg each time it draws the band again, so the strip is a plain image and its figures wait while Claude works. They update about 15 seconds after a turn ends, at most every 5 minutes. A period or tab press updates at once. The pane draws its figures as native text, which updates in place.tengu_plugin_hooks_modules. If Claude Code says hooks modules are turned off, the mod cannot load until the flag is on for your account.dev/test.sh # pure-module tests under bun, no engine needed
claude plugin test plugins/usage-ticker # everything, including the engine mount tests
claude plugin validate . --strict
To type-check, load the mod once so Claude Code writes its type declarations, then run tsc -p .claude-plugin/types/tsconfig.json inside plugins/usage-ticker.
Code is in plugins/usage-ticker/hooks/. register.tsx wires the engine's events and timers to the modules. The other files each do one thing and have a .test.ts beside them: the terminal board (board, led, gauge, scroll, pace), the data (cards, periods, zone) and the desktop drawings (strip-svg, ring-svg, pane-view, desktop-state, desktop-theme). dev/design/render.ts renders the design scenes.
MIT. See LICENSE.
hooks/register.tsx 566 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { encodeCells } from './base64'
4import { BUTTON_COLS, LEAD_PX, boardFrame, rasterWidth, reserveFor, textLine } from './board'
5import { cardsFromDay, dayTotal, loadingCards, nextSpend, parseCcusage, unavailableCards } from './cards'
6import { PX_PER_COLUMN, deskClock, diagLine, firstReading, framePhase, percentMoved, readingAge, memoMarkup, panelInput, panelWidthFor, reasonFor, redrawDue, stripInput, stripKeyParts, stripWidthFor } from './desktop-state'
7import type { DesktopState, Memo, StripPart } from './desktop-state'
8import type { DayEntry, Parsed } from './cards'
9import { sevenDay, weekFromLimit } from './gauge'
10import type { Limit } from './gauge'
11import { buildStrip } from './led'
12import type { CardText } from './led'
13import { DAY_KEEP_MS, SESSION_KEEP_MS, addSample, paceNow, speedFor } from './pace'
14import type { Sample } from './pace'
15import { INK, hex } from './desktop-theme'
16import { PANE_CARD, PANE_EDGE, RING_SIZE, SPARK_HEIGHT, paneKey, paneView, ringBadgeSvg, ringKey, sparkKey, sparkSvg } from './pane-view'
17import { PERIODS, aggregate, buttonLabel, cardTitle, earliestPeriod, isPeriod, nextPeriod, rangeFor, rangeText } from './periods'
18import type { Period } from './periods'
19import { newScroll, remapOffset, stepScroll } from './scroll'
20import { gaugeShown, newStatus, turnCompleted, turnStarted, withWorking } from './status'
21import { STRIP_HEIGHT, stripAlt, stripSvg } from './strip-svg'
22import { RETRY_MS, paintDue, spendDue } from './timing'
23import { isoDate, parseUtcOffset } from './zone'
24
25const TICK_MS = 50 // how often the crawl is repainted while it moves
26const SAMPLE_MS = 2_000 // how often this session's spend is read to measure its pace
27const SPEND_EVERY_MS = 5 * 60_000
28const SPEND_MIN_GAP_MS = 60_000 // after a turn, refresh spend at most this often (a read of every day is not cheap)
29const FRAME_REFRESH_MS = 60_000 // the gauge notch drifts with the clock, so repaint now and then
30const DESK_TICK_MS = 1_000 // the desktop ticker animates itself; this only keeps count of where it is
31const PANE_ID = 'usage-panel'
32// The strip as an image (false) or an interactive sandboxed frame (true). The desktop reloads an interactive frame
33// each time the band is drawn again (the isWorking flip at every turn start and end), which blinks and restarts the
34// ticker; as an image it keeps animating and, the markup unchanged, stays put. The cost: no hover, no tooltips on
35// the strip. What they said is in the Details pane, which stays interactive (it does not move).
36const STRIP_INTERACTIVE = false
37// The pane's two drawings (the weekly ring, the daily line), likewise as images: the app re-creates an interactive
38// frame each time the pane is delivered, and the pane is delivered whenever one of its figures changes.
39const PANE_SVG_INTERACTIVE = false
40// With false the pane draws no Svg at all: the week as "39% used" in text, the daily line as its heading alone.
41const PANE_SVGS = true
42const PANE_REDRAW_MS = 3_000 // while the pane is open, its figures are brought up to date at most this often
43
44// What the drawing reads. A hot reload starts these over and session.start refills them.
45let period: Period = 'today' // which stretch of days the crawl adds up; the button steps it, $.store keeps it
46let days: DayEntry[] = [] // every day ccusage reported on the last good read
47let firstDay: string | undefined // the earliest of those days
48let spend: 'loading' | 'ready' | 'failed' = 'loading'
49let cards: CardText[] = loadingCards()
50let board = buildStrip(cards)
51let rangeLabel = '' // the small caption under the button, set together with the cards
52let limit: Limit | undefined
53let limitAt: number | null = null // when the last weekly reading was received (a push, the sampler, the start)
54let status = newStatus() // turns of the main conversation only; a subagent's turn is not one of them
55let offsetMin = 0 // minutes east of UTC, from `date +%z`
56let scroll = newScroll(LEAD_PX)
57let sessionSamples: Sample[] = [] // this session's running cost, for its live pace
58let daySamples: Sample[] = [] // today's machine-wide total from ccusage, for the pace of other sessions
59let requestId = ''
60let cols = 0 // the band's width as the last render saw it, at most 512
61let reserve = 0 // columns at its right end left to the button; the mounted Raster is `cols - reserve` wide
62let lastTick = 0
63let lastPx: number | null = null
64let dirty = true
65let retryAt = 0 // a repaint the engine refused is not tried again before this time
66let textShown = false // the last render was the one line of text, which only a redraw can update
67let spendBusy = false
68let spendWanted = false // a main turn finished, so the spend has probably moved
69let lastSpendAt = 0
70// The desktop: the SVG moves the ticker by itself, at one constant speed. Every new drawing reloads the surface's
71// frame (a blink, and the ticker restarts from the offset it is given), so the strip on screen is replaced only
72// when redrawDue says so: what the person did at once, new figures never mid-turn and rarely otherwise. Any other
73// render hands back the identical markup.
74let deskBand = false // the last band drawn was the desktop strip
75let deskPane = false // the details pane has been drawn and not closed since
76let deskColumns: number | undefined // the band's width in cells, as the surface last measured it
77let paneColumns: number | undefined
78let crawlStart: number | null = null // when the desktop ticker began: its first drawing, or a new period
79type Drawing = { source: string; alt: string; width: number; height: number }
80type Shown = { key: string; parts: Record<StripPart, unknown>; at: number; placeholder: boolean; drawing: Drawing }
81let stripShown: Shown | null = null // the strip on screen, and what it was drawn from
82let stripAsked = '' // the key the tick last asked a redraw for, so it asks once
83let userWants = false // a period chosen since the strip was last drawn
84let idleSince: number | null = null // when the main conversation was last seen idle; null while a turn runs
85let ringMemo: Memo<string> | null = null // the pane's two small drawings, each built once per key of its own
86let sparkMemo: Memo<string> | null = null
87let paneDrawn = '' // what the pane showed when it was last drawn or asked to be
88let paneAskedAt = 0 // when the tick last asked the pane to be drawn again
89// Diagnostics, shown on the last line of the ring's tooltip as of the latest drawing: desktop band renders, times
90// the strip's markup changed, redraws this module asked for, and which parts of the strip changed last.
91let deskRenders = 0
92let sourceChanges = 0
93let invalidates = 0
94let lastReason = 'none'
95
96const showGauge = () => gaugeShown(status, limit !== undefined)
97const weekAt = (now: number) => (limit ? weekFromLimit(limit, now) : null)
98const frameAt = (now: number) =>
99 boardFrame({ cols, reserveRight: reserve, week: weekAt(now), now, offsetMin, board, scrollOffset: scroll.offset, showGauge: showGauge() })
100const lineAt = (now: number) => textLine(cards, weekAt(now), now, offsetMin, showGauge())
101// The week, its caption and the day as the desktop shows them move in ten minute steps (deskClock); the pace is live.
102const deskState = (now: number): DesktopState => {
103 const at = deskClock(now)
104 return { days, spend, period, rangeLabel, week: weekAt(at), now: at, offsetMin, pacePerHour: paceNow(sessionSamples, daySamples, now) }
105}
106// The strip's input; its phase is where the ticker will be when a frame loaded now starts (framePhase).
107const deskStrip = (now: number) => stripInput(deskState(now), stripWidthFor(deskColumns), framePhase((crawlStart ??= now), now))
108const deskPanel = (now: number) => panelInput(deskState(now), panelWidthFor(paneColumns))
109const noteIdle = (now: number) => {
110 if (status.working) idleSince = null
111 else idleSince ??= now
112}
113// Whether the strip on screen should give way to one drawn from `input`.
114const stripAsk = (now: number, input: ReturnType<typeof deskStrip>) => {
115 const parts = stripKeyParts(input)
116 const key = JSON.stringify(parts)
117 const resized = stripShown !== null && JSON.stringify(stripShown.parts.width) !== JSON.stringify(parts.width)
118 const due = redrawDue({
119 working: status.working,
120 userInitiated: userWants || resized,
121 keyChanged: key !== stripShown?.key,
122 lastDrawAt: stripShown?.at ?? null,
123 idleSince,
124 now,
125 showsPlaceholder: stripShown?.placeholder ?? true,
126 firstReading: firstReading(stripShown?.parts ?? null, parts),
127 percentMoved: percentMoved(stripShown?.parts ?? null, parts),
128 })
129 return { parts, key, due }
130}
131// The markup for the band: the strip on screen, unless a new one is due.
132const stripDrawing = (now: number): Drawing => {
133 noteIdle(now)
134 const input = deskStrip(now)
135 const ask = stripAsk(now, input)
136 if (stripShown === null || ask.due) {
137 const reason = reasonFor(stripShown?.parts ?? null, ask.parts)
138 if (stripShown !== null) sourceChanges++
139 lastReason = reason
140 const diag = STRIP_INTERACTIVE ? diagLine({ renders: deskRenders, changes: sourceChanges, invalidates, reason }) : undefined
141 const drawing = { source: stripSvg(diag ? { ...input, diag } : input), alt: stripAlt(input), width: input.width, height: STRIP_HEIGHT }
142 stripShown = { key: ask.key, parts: ask.parts, at: now, placeholder: spend !== 'ready', drawing }
143 userWants = false
144 } else if (ask.key !== stripShown.key) {
145 stripAsked = '' // held back for now: the tick may ask again once it is due
146 }
147 return stripShown.drawing
148}
149// The pane's values, as text for native elements: they change in place, nothing reloads.
150const paneNow = (now: number) => paneView(deskPanel(now), paneColumns ?? 56, readingAge(now, limitAt))
151
152// A blit repaints the Raster's cells and nothing else. When what the tree holds has changed (the line of text, the
153// button's caption, the band's shape), the band has to be drawn again.
154const redrawIf = ($: EngineInterface, treeChanged: boolean) => {
155 if (treeChanged || textShown) askRedraw($)
156}
157
158// Every redraw this module asks for goes through here, so the desktop diagnostics can count them.
159const askRedraw = ($: EngineInterface) => {
160 invalidates++
161 $.ui.invalidate('ui.render')
162}
163
164const clockNow = async ($: EngineInterface): Promise<number> => {
165 try {
166 return await $.clock.now()
167 } catch {
168 return Date.now()
169 }
170}
171
172// The cards and the button's caption for the period on show, from the days already read.
173const rebuildCards = (now: number) => {
174 const { since, until } = rangeFor(period, now, offsetMin, weekAt(now)?.resetsAtMs ?? null)
175 const title = cardTitle(period)
176 rangeLabel = rangeText(period, since, firstDay)
177 if (spend === 'failed') cards = unavailableCards()
178 else if (spend === 'loading') cards = loadingCards(title)
179 else cards = cardsFromDay(aggregate(days, since, until), title)
180 board = buildStrip(cards)
181}
182
183const loadOffset = async ($: EngineInterface) => {
184 try {
185 const r = await $.process.run(['date', '+%z'])
186 const parsed = r.exitCode === 0 ? parseUtcOffset(r.stdout) : null
187 if (parsed !== null) {
188 offsetMin = parsed
189 dirty = true
190 return
191 }
192 } catch {
193 // fall through to the module's own idea of local time
194 }
195 offsetMin = -new Date().getTimezoneOffset()
196}
197
198const loadPeriod = async ($: EngineInterface) => {
199 try {
200 const saved = await $.store.get('period')
201 if (isPeriod(saved)) period = saved
202 } catch {
203 // the default stands
204 }
205}
206
207// One read of ccusage brings every day, and every period is added up from that list.
208const refreshSpend = async ($: EngineInterface) => {
209 if (spendBusy) return
210 spendBusy = true
211 spendWanted = false
212 const now = await clockNow($)
213 lastSpendAt = now
214 let read: Parsed | null = null
215 try {
216 await loadOffset($) // the offset changes with daylight saving, so it is read again each time
217 const r = await $.process.run(['ccusage', 'daily', '--json', '--breakdown'], { timeoutMs: 60_000 })
218 read = r.exitCode === 0 ? parseCcusage(r.stdout) : null
219 } catch {
220 read = null // a failed read leaves the days already read as they were
221 } finally {
222 spendBusy = false
223 }
224 const next = nextSpend({ days, state: spend }, read)
225 days = next.days
226 spend = next.state
227 if (read?.ok) {
228 firstDay = earliestPeriod(days)
229 const todayIso = isoDate(now, offsetMin)
230 daySamples = addSample(daySamples, now, dayTotal(days.find(d => d.period === todayIso)), DAY_KEEP_MS)
231 }
232 const captionBefore = rangeLabel
233 const oldLength = board.length
234 rebuildCards(now)
235 scroll.offset = remapOffset(scroll.offset, oldLength, board.length) // the same place in the new strip, not a jump
236 dirty = true
237 redrawIf($, rangeLabel !== captionBefore)
238}
239
240// A finished main turn asks for a fresh read of the spend by setting a flag. The 2 second timer serves it, at most
241// once in SPEND_MIN_GAP_MS, so nothing is started from inside the turn.complete hook after it has returned.
242const refreshSpendIfWanted = async ($: EngineInterface) => {
243 if (!spendWanted || spendBusy) return
244 const now = await clockNow($)
245 if (spendDue({ wanted: spendWanted, now, lastAt: lastSpendAt, minGapMs: SPEND_MIN_GAP_MS })) await refreshSpend($)
246}
247
248// The engine's running cost for this session grows with every API response, so its slope is the live pace.
249// The same read also picks up the weekly limit while none is known: the desktop often reports none at session start
250// and none is pushed until a response arrives, so the gauge would otherwise wait for that push.
251const sampleSession = async ($: EngineInterface) => {
252 try {
253 const usage = await $.session.usage()
254 if (limit === undefined) {
255 const found = sevenDay(usage.rateLimits)
256 if (found !== undefined) {
257 limit = found
258 limitAt = await clockNow($)
259 dirty = true
260 redrawIf($, false) // as a pushed reading: the text line is drawn again; the strip's tick sees the ring
261 }
262 }
263 const usd = usage.cost?.usd
264 if (typeof usd === 'number' && Number.isFinite(usd)) {
265 sessionSamples = addSample(sessionSamples, await $.clock.now(), usd, SESSION_KEEP_MS)
266 }
267 } catch {
268 // no sample this time; the pace simply keeps its last value
269 }
270}
271
272const start = async ($: EngineInterface) => {
273 await loadOffset($)
274 await loadPeriod($)
275 const usage = await $.session.usage().catch(() => null)
276 if (usage) limit = sevenDay(usage.rateLimits)
277 if (limit !== undefined) limitAt = await clockNow($)
278 rebuildCards(await clockNow($)) // until ccusage answers: the loading card and the caption of the saved period
279 askRedraw($)
280 await sampleSession($)
281 await refreshSpend($)
282}
283
284// A period chosen: the crawl back at its first card, and its numbers from the days already read. ccusage is
285// not run again.
286const choosePeriod = async ($: EngineInterface, chosen: Period) => {
287 const now = await clockNow($)
288 if (chosen !== period) {
289 crawlStart = now // the desktop ticker, likewise from its first card
290 userWants = true // and drawn at once, even mid-turn
291 }
292 period = chosen
293 rebuildCards(now)
294 scroll = newScroll(LEAD_PX)
295 dirty = true
296 askRedraw($) // the label and the caption are in the tree, so this is a redraw and not a blit
297 try {
298 await $.store.set('period', period) // the period as it is now, if another press came in meanwhile
299 } catch {
300 // the choice just does not outlive this session
301 }
302}
303
304// An error as one short line of plain text: the engine refuses a control character in a Text.
305const plainError = (err: unknown): string =>
306 String(err instanceof Error ? err.message : err).replace(/[\u0000-\u001f\u007f]+/g, ' ').slice(0, 200)
307
308// The button: on to the next period.
309const pressPeriod = ($: EngineInterface) => choosePeriod($, nextPeriod(period))
310
311// The desktop's Details button. A press is the person asking, so the pane is seated at any width.
312const openDetails = async ($: EngineInterface) => {
313 try {
314 await $.ui.open({ id: PANE_ID, title: 'Usage' })
315 } catch {
316 // the surface places no pane; the strip stays as it is
317 }
318}
319
320// Once a second on the desktop: ask for a redraw of the strip only when a new one is due (redrawDue), and of the
321// pane when what it shows has changed, at most once in PANE_REDRAW_MS (a tab press is drawn at once).
322const deskTick = async ($: EngineInterface) => {
323 if (!deskBand && !deskPane) return
324 const now = await clockNow($)
325 noteIdle(now)
326 let changed = false
327 if (deskBand && stripShown !== null) {
328 const ask = stripAsk(now, deskStrip(now))
329 if (ask.due && ask.key !== stripAsked) {
330 stripAsked = ask.key
331 changed = true
332 }
333 }
334 if (deskPane && now - paneAskedAt >= PANE_REDRAW_MS) {
335 const key = paneKey(paneNow(now))
336 if (key !== paneDrawn) {
337 paneDrawn = key
338 paneAskedAt = now
339 changed = true
340 }
341 }
342 if (changed) askRedraw($)
343}
344
345// Slides the crawl at the speed the current pace calls for and repaints it in place: no redraw of
346// the band, only new cells for the Raster. It never stops, so only a changed pixel is repainted.
347const tick = async ($: EngineInterface) => {
348 if (!requestId || cols === 0) return
349 try {
350 const now = await $.clock.now()
351 const dt = lastTick === 0 ? TICK_MS / 1000 : Math.min(0.25, (now - lastTick) / 1000)
352 lastTick = now
353 stepScroll(scroll, dt, speedFor(paceNow(sessionSamples, daySamples, now), status.working))
354 const px = Math.floor(scroll.offset)
355 if (!paintDue({ px, lastPx, dirty, now, retryAt })) return
356 const frame = frameAt(now)
357 if (frame === null) return
358 // The size sent is the mounted Raster's own: the frame is `cols - reserve` wide, as drawn by the last render.
359 const result = await $.ui.blit({ requestId, key: 'meter', cells: encodeCells(frame.cells), columns: frame.cols, rows: frame.rows })
360 if (result.deny === undefined) {
361 lastPx = px
362 dirty = false
363 } else {
364 retryAt = now + RETRY_MS // refused (a resize, a hidden band): stay dirty, ask again in a moment
365 }
366 } catch {
367 // a repaint that fails is retried on the next tick
368 }
369}
370
371export const register: Register = on => {
372 on('session.start', async ($, e, next) => {
373 void start($)
374 $.clock.every(TICK_MS, () => void tick($))
375 $.clock.every(SAMPLE_MS, () => {
376 void sampleSession($)
377 void refreshSpendIfWanted($)
378 })
379 $.clock.every(SPEND_EVERY_MS, () => void refreshSpend($))
380 $.clock.every(FRAME_REFRESH_MS, () => {
381 dirty = true
382 })
383 $.clock.every(DESK_TICK_MS, () => void deskTick($))
384 return next(e)
385 })
386
387 on('ui.close', async ($, e, next) => {
388 if (e.id === PANE_ID) deskPane = false
389 return next(e)
390 })
391
392 // Pushed by the engine when a limit window moves, so there is nothing to poll.
393 on('session.measure', async ($, e, next) => {
394 limit = sevenDay(e.rateLimits)
395 if (limit !== undefined) limitAt = await clockNow($) // measured after each main turn: the last response's reading
396 dirty = true
397 redrawIf($, false) // the Raster keeps its size when the gauge comes or goes, so only the text line needs a redraw
398 return next(e)
399 })
400
401 on('turn.start', async ($, e, next) => {
402 status = turnStarted(status)
403 return next(e)
404 })
405
406 on('turn.complete', async ($, e, next) => {
407 status = turnCompleted(status, e.agentId) // a subagent's turn changes nothing
408 if (e.agentId === undefined) {
409 spendWanted = true
410 dirty = true
411 redrawIf($, false)
412 }
413 return next(e)
414 })
415
416 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
417 if (e.props.hasSurvey) return next(e)
418 status = withWorking(status, e.props.isWorking)
419 const now = await $.clock.now()
420 if (e.surface === 'terminal') {
421 const { Box, Button, Raster, Text } = $.ui.resolve(e)
422 requestId = e.requestId
423 cols = rasterWidth(e.props.bodyColumns)
424 reserve = reserveFor(e.props.bodyColumns)
425 const frame = frameAt(now)
426 if (frame !== null) {
427 textShown = false
428 const meter = <Raster key="meter" columns={frame.cols} rows={frame.rows} cells={encodeCells(frame.cells)} />
429 if (reserve === 0) return meter
430 return (
431 <Box flexDirection="row">
432 {meter}
433 <Box flexDirection="column" width={BUTTON_COLS}>
434 <Button key="period" label={buttonLabel(period)} hotkey="p" onPress={() => void pressPeriod($)} />
435 <Text dimColor wrap="truncate">{rangeLabel || cardTitle(period)}</Text>
436 </Box>
437 </Box>
438 )
439 }
440 textShown = true
441 return <Text dimColor wrap="truncate">{lineAt(now)}</Text>
442 }
443 if (e.surface === 'desktop') {
444 const { Box, Button, Svg } = $.ui.resolve(e)
445 deskBand = true
446 textShown = false
447 deskColumns = e.viewport?.columns ?? e.props.bodyColumns
448 deskRenders++
449 const strip = stripDrawing(now)
450 return (
451 <Box flexDirection="row" alignItems="center" gap={1}>
452 <Svg source={strip.source} alt={strip.alt} width={strip.width} height={strip.height} isInteractive={STRIP_INTERACTIVE} />
453 <Button key="period" label={buttonLabel(period)} hotkey="p" onPress={() => void pressPeriod($)} />
454 <Button key="details" label="Details" onPress={() => void openDetails($)} />
455 </Box>
456 )
457 }
458 // The editor and the phone: one line of text.
459 const { Text } = $.ui.resolve(e)
460 deskBand = false
461 textShown = true
462 return <Text dimColor wrap="truncate">{lineAt(now)}</Text>
463 })
464
465 // The details pane, opened from the desktop's Details button: the period as tabs above a panel card.
466 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
467 if (e.requestId !== PANE_ID) return next(e)
468 const now = await clockNow($)
469 if (e.surface !== 'desktop') {
470 const { Text } = $.ui.resolve(e)
471 return <Text dimColor wrap="truncate">{lineAt(now)}</Text>
472 }
473 try {
474 const { Box, Button, Svg, Text } = $.ui.resolve(e)
475 deskPane = true
476 paneColumns = e.props.bodyColumns
477 const v = paneNow(now)
478 paneDrawn = paneKey(v)
479 // The card's width inside its border and padding (two cells a side); the surface scales an Svg down if it is less.
480 const sparkWidth = Math.min(480, Math.max(200, Math.round((paneColumns ?? 56) * PX_PER_COLUMN) - 31))
481 if (PANE_SVGS && v.week) ringMemo = memoMarkup(ringMemo, ringKey(v.week, v.now, v.offsetMin), () => ringBadgeSvg(v.week!, v.now, v.offsetMin))
482 if (PANE_SVGS && v.daily.length > 0) sparkMemo = memoMarkup(sparkMemo, sparkKey(v.daily, v.accent, sparkWidth), () => sparkSvg(v.daily, v.accent, sparkWidth))
483 const diag = diagLine({ renders: deskRenders, changes: sourceChanges, invalidates, reason: lastReason })
484 return (
485 <Box flexDirection="column" gap={1}>
486 <Box flexDirection="row" gap={1}>
487 {PERIODS.map(p => (
488 <Button key={`tab-${p}`} label={buttonLabel(p)} {...(p === period ? { variant: 'primary' as const } : {})} onPress={() => void choosePeriod($, p)} />
489 ))}
490 </Box>
491 <Box flexDirection="column" backgroundColor={hex(PANE_CARD)} borderStyle="round" borderColor={PANE_EDGE} paddingX={2} paddingY={1}>
492 <Box flexDirection="row" justifyContent="space-between" alignItems="flex-end">
493 <Text bold color={INK.figure}>{v.total}</Text>
494 <Text color={INK.label} wrap="truncate">{v.meta}</Text>
495 </Box>
496 <Box flexDirection="row" justifyContent="space-between" marginTop={1}>
497 <Text color={INK.label}>By model</Text>
498 <Text color={INK.dim} wrap="truncate">{v.source}</Text>
499 </Box>
500 {v.emptyText !== null ? (
501 <Text color={INK.dim}>{v.emptyText}</Text>
502 ) : (
503 v.rows.map(r => (
504 <Box key={`row-${r.top ? 'top' : r.name.replace(/[^a-z0-9]+/gi, '-')}`} flexDirection="row" gap={1}>
505 <Box width={v.nameCols}>
506 <Text color={INK.text} wrap="truncate">{r.name}</Text>
507 </Box>
508 <Box width={v.barCols} flexGrow={1} height={1} backgroundColor={v.barTrack}>
509 <Box width={r.bar} height={1} backgroundColor={r.top ? v.accent : v.barRest} />
510 </Box>
511 {v.detailCols > 0 ? (
512 <Box width={v.detailCols} justifyContent="flex-end">
513 <Text color={INK.label}>{r.detail}</Text>
514 </Box>
515 ) : null}
516 <Box width={v.valueCols} justifyContent="flex-end">
517 <Text color={INK.figure}>{r.value}</Text>
518 </Box>
519 </Box>
520 ))
521 )}
522 <Box flexDirection="row" justifyContent="space-between" marginTop={1}>
523 <Text color={INK.label}>{v.daily.length > 0 ? `Last ${v.daily.length} days` : 'No daily history yet'}</Text>
524 <Text color={INK.dim}>{v.peak}</Text>
525 </Box>
526 {PANE_SVGS && sparkMemo && v.daily.length > 0 ? <Svg source={sparkMemo.value} alt={`Daily spend, ${v.peak}`} width={sparkWidth} height={SPARK_HEIGHT} isInteractive={PANE_SVG_INTERACTIVE} /> : null}
527 <Box flexDirection="row" gap={2} alignItems="center" marginTop={1}>
528 {PANE_SVGS && ringMemo && v.week ? <Svg source={ringMemo.value} alt={v.weekCaption} width={RING_SIZE} height={RING_SIZE} isInteractive={PANE_SVG_INTERACTIVE} /> : null}
529 <Box flexDirection="column">
530 <Text color={INK.label}>{v.weekLabel}</Text>
531 {!PANE_SVGS && v.weekUsed !== null ? <Text color={v.accent}>{v.weekUsed}</Text> : null}
532 <Text color={v.weekDanger ? v.accent : INK.figure}>{v.weekCaption}</Text>
533 {v.pace !== null ? (
534 <Text color={INK.label}>
535 Pace <Text color={INK.text}>{v.pace}</Text>
536 {v.readingAge !== null ? <Text color={INK.dim}>{` · ${v.readingAge}`}</Text> : null}
537 </Text>
538 ) : v.readingAge !== null ? (
539 <Text color={INK.dim}>{v.readingAge}</Text>
540 ) : null}
541 </Box>
542 </Box>
543 <Box marginTop={1}>
544 <Text color={INK.dim} wrap="wrap">{diag}</Text>
545 </Box>
546 </Box>
547 </Box>
548 )
549 } catch (err) {
550 // Drawing the pane failed: the figures as one line and the reason, rather than no pane at all.
551 const { Box, Button, Text } = $.ui.resolve(e)
552 return (
553 <Box flexDirection="column" gap={1}>
554 <Box flexDirection="row" gap={1}>
555 {PERIODS.map(p => (
556 <Button key={`tab-${p}`} label={buttonLabel(p)} {...(p === period ? { variant: 'primary' as const } : {})} onPress={() => void choosePeriod($, p)} />
557 ))}
558 </Box>
559 <Text wrap="truncate">{lineAt(now)}</Text>
560 <Text dimColor wrap="truncate">{`Details could not be drawn: ${plainError(err)}`}</Text>
561 </Box>
562 )
563 }
564 })
565}
566hooks/base64.ts 33 lines1import type { Cell } from './led'
2
3const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
4
5// Standard base64 with padding. Written out so it needs nothing from the host.
6export const base64 = (bytes: Uint8Array): string => {
7 let out = ''
8 for (let i = 0; i < bytes.length; i += 3) {
9 const a = bytes[i]!
10 const b = bytes[i + 1]
11 const c = bytes[i + 2]
12 const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
13 out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]!
14 out += b === undefined ? '=' : ALPHABET[(n >> 6) & 63]!
15 out += c === undefined ? '=' : ALPHABET[n & 63]!
16 }
17 return out
18}
19
20// A Raster's `cells`: per cell, glyph, foreground and background as little-endian 32-bit words.
21export const cellBytes = (cells: readonly Cell[]): Uint8Array => {
22 const out = new Uint8Array(cells.length * 12)
23 const view = new DataView(out.buffer)
24 cells.forEach(([cp, fg, bg], i) => {
25 view.setUint32(i * 12, cp, true)
26 view.setUint32(i * 12 + 4, fg, true)
27 view.setUint32(i * 12 + 8, bg, true)
28 })
29 return out
30}
31
32export const encodeCells = (cells: readonly Cell[]): string => base64(cellBytes(cells))
33hooks/board.ts 91 lines1import { caption, gaugeCells } from './gauge'
2import type { Week } from './gauge'
3import { crawlCells } from './led'
4import type { Board, CardText, Cell } from './led'
5import { PALETTE } from './palette'
6
7export const FADE_CELLS = 6
8// The crawl rests with a card this many pixels in, just past the soft left edge.
9export const LEAD_PX = FADE_CELLS * 2
10
11const GAUGE_X = 6 // after one blank column and the word "Week"
12
13// The engine takes a Raster of 1 to 512 columns; a band wider than that is drawn 512 wide.
14export const RASTER_MAX_COLS = 512
15export const rasterWidth = (bodyColumns: number): number => Math.min(RASTER_MAX_COLS, bodyColumns)
16
17// The period button lives in a column at the right end of the band, which the Raster leaves free. It is
18// there from 100 columns up, with or without a gauge.
19export const BUTTON_COLS = 14
20const BUTTON_MIN_COLS = 100
21export const reserveFor = (bodyColumns: number): number => (bodyColumns >= BUTTON_MIN_COLS ? BUTTON_COLS : 0)
22
23export type Layout = { gaugeX: number; gaugeW: number; crawlX: number | null; crawlW: number; captionMax: number }
24// `reserveRight` columns at the right edge are left to something else (the period button): the frame is that much
25// narrower and the crawl ends that much sooner, while the layout is still the one for the whole `cols`.
26export type BoardInput = {
27 cols: number
28 reserveRight?: number
29 week: Week | null
30 now: number
31 offsetMin: number
32 board: Board
33 scrollOffset: number
34 showGauge?: boolean
35}
36export type Frame = { cols: number; rows: 2; cells: Cell[] }
37
38// Three tiers by width. Under 50 columns there is no board, only a line of text.
39// With no gauge (an account that has no weekly limit) the crawl takes the whole width.
40export const layoutFor = (cols: number, showGauge = true): Layout | null => {
41 if (!showGauge) return cols < 30 ? null : { gaugeX: 0, gaugeW: 0, crawlX: 1, crawlW: cols - 2, captionMax: 0 }
42 if (cols < 50) return null
43 if (cols >= 100) return { gaugeX: GAUGE_X, gaugeW: 28, crawlX: 37, crawlW: cols - 1 - 37, captionMax: 30 }
44 if (cols >= 70) return { gaugeX: GAUGE_X, gaugeW: 20, crawlX: 29, crawlW: cols - 1 - 29, captionMax: 22 }
45 return { gaugeX: GAUGE_X, gaugeW: 20, crawlX: null, crawlW: 0, captionMax: cols - 1 - GAUGE_X }
46}
47
48// The whole band as two rows of cells.
49export const boardFrame = (input: BoardInput): Frame | null => {
50 const { cols, reserveRight = 0, week, now, offsetMin, board, scrollOffset, showGauge = true } = input
51 const layout = layoutFor(cols, showGauge)
52 if (layout === null) return null
53 const reserve = Math.max(0, reserveRight)
54 const width = cols - reserve
55 if (width < 1) return null
56 const cells: Cell[] = Array.from({ length: width * 2 }, (): Cell => [0x20, PALETTE.label, PALETTE.panel])
57 const put = (col: number, row: number, str: string, fg: number) => {
58 let x = col
59 for (const ch of str) {
60 if (x < width) cells[row * width + x] = [ch.codePointAt(0)!, fg, PALETTE.panel]
61 x++
62 }
63 }
64 if (showGauge) {
65 put(1, 0, 'Week', PALETTE.label)
66 gaugeCells(week, layout.gaugeW).cells.forEach((cell, i) => {
67 if (layout.gaugeX + i < width) cells[layout.gaugeX + i] = cell
68 })
69 const cap = caption(week, now, offsetMin)
70 const room = Math.max(0, Math.min(layout.captionMax, width - 1 - layout.gaugeX)) // the caption stays inside the frame
71 const fitted = cap.text.length <= room ? cap.text : cap.short.slice(0, room)
72 put(layout.gaugeX, 1, fitted, cap.isDanger ? PALETTE.signal : PALETTE.label)
73 }
74 // The crawl's right end is one blank column short of the frame's, with or without a reserve.
75 const crawlW = layout.crawlX === null ? 0 : Math.max(0, layout.crawlW - reserve)
76 if (layout.crawlX !== null && crawlW > 0) {
77 const crawl = crawlCells(board, scrollOffset, crawlW, FADE_CELLS)
78 for (let r = 0; r < 2; r++) {
79 for (let c = 0; c < crawlW; c++) cells[r * width + layout.crawlX + c] = crawl[r * crawlW + c]!
80 }
81 }
82 return { cols: width, rows: 2, cells }
83}
84
85// One line for where the board cannot be drawn: a narrow terminal, or a surface with no Raster.
86export const textLine = (cards: readonly CardText[], week: Week | null, now: number, offsetMin: number, showGauge = true, maxCards = 3): string => {
87 const parts = showGauge ? [caption(week, now, offsetMin).text] : []
88 for (const card of cards.slice(0, maxCards)) parts.push([card.name, card.value, card.extra].filter(Boolean).join(' '))
89 return parts.join(' ')
90}
91hooks/cards.ts 115 lines1import type { CardText } from './led'
2
3export type ModelRow = {
4 modelName: string
5 cost: number
6 inputTokens: number
7 outputTokens: number
8 cacheCreationTokens: number
9 cacheReadTokens: number
10}
11// The part of a `ccusage daily --json` entry the board reads.
12export type Day = { totalCost?: number; modelBreakdowns?: ModelRow[] }
13// One entry of the list, with the local date it is for (`period`, as YYYY-MM-DD).
14export type DayEntry = Day & { period: string }
15
16// A minor version is one or two digits; an eight digit date after it is not a version.
17const MINOR = '(\\d{1,2})(?!\\d)'
18const NAME_FIRST = new RegExp(`^claude-(opus|sonnet|haiku|fable)-(\\d+)(?:-${MINOR})?(?:-|$)`) // claude-opus-4-1-20250805
19const NAME_LAST = new RegExp(`^claude-(\\d+)(?:-${MINOR})?-(opus|sonnet|haiku|fable)(?:-|$)`) // claude-3-5-sonnet-20241022
20
21export const prettyModel = (id: string): string => {
22 const version = (major: string | undefined, minor: string | undefined) => (minor === undefined ? major : `${major}.${minor}`)
23 const first = id.match(NAME_FIRST)
24 if (first) return `${first[1]} ${version(first[2], first[3])}`
25 const last = id.match(NAME_LAST)
26 if (last) return `${last[3]} ${version(last[1], last[2])}`
27 const gpt = id.match(/^gpt-(\d+\.\d+)/)
28 if (gpt) return `gpt ${gpt[1]}`
29 return id.toUpperCase().slice(0, 12)
30}
31
32export const money = (n: number): string => `$${n.toFixed(2)}`
33
34// Tokens in K, M or B, moving up a unit before the number would read 1000.
35export const tok = (n: number): string => {
36 if (n < 1e3) return String(n)
37 if (n < 999_500) return `${Math.round(n / 1e3)}K`
38 if (n < 999_500_000) return `${Math.round(n / 1e6)}M`
39 return `${(n / 1e9).toFixed(1)}B`
40}
41
42const MAX_MODEL_CARDS = 8
43const tokensOf = (m: ModelRow) => m.inputTokens + m.outputTokens + m.cacheCreationTokens + m.cacheReadTokens
44
45// The day's spend: the reported total, else the sum of its models.
46export const dayTotal = (day: Day | undefined): number => day?.totalCost ?? (day?.modelBreakdowns ?? []).reduce((sum, m) => sum + m.cost, 0)
47
48export type ModelSum = { name: string; cost: number; tokens: number }
49
50// Each model's cost and tokens, biggest cost first. Ids that shorten to the same name (a dated one and an undated
51// one) are one model, as two of one name would only confuse.
52export const modelSums = (day: Day | undefined): ModelSum[] => {
53 const byName = new Map<string, ModelSum>()
54 for (const m of day?.modelBreakdowns ?? []) {
55 const name = prettyModel(m.modelName)
56 const sum = byName.get(name) ?? { name, cost: 0, tokens: 0 }
57 sum.cost += m.cost
58 sum.tokens += tokensOf(m)
59 byName.set(name, sum)
60 }
61 return [...byName.values()].sort((a, b) => b.cost - a.cost)
62}
63
64// Past eight models the smallest are one `N others` entry, so a long tail does not stretch the crawl. It is
65// never an entry for one other.
66export const capModels = (models: readonly ModelSum[]): { shown: ModelSum[]; others: ModelSum | null } => {
67 if (models.length <= MAX_MODEL_CARDS) return { shown: [...models], others: null }
68 const tail = models.slice(MAX_MODEL_CARDS - 1)
69 return {
70 shown: models.slice(0, MAX_MODEL_CARDS - 1),
71 others: { name: `${tail.length} others`, cost: tail.reduce((s, m) => s + m.cost, 0), tokens: tail.reduce((s, m) => s + m.tokens, 0) },
72 }
73}
74
75// The whole stretch first, named `title`, then each model by cost, biggest first.
76export const cardsFromDay = (day: Day | undefined, title = 'today'): CardText[] => {
77 const models = modelSums(day)
78 const tokens = models.reduce((s, m) => s + m.tokens, 0)
79 const { shown, others } = capModels(models)
80 return [
81 { name: title, value: money(dayTotal(day)), extra: `${tok(tokens)} tokens` },
82 ...shown.map(m => ({ name: m.name, value: money(m.cost), extra: tok(m.tokens) })),
83 ...(others ? [{ name: others.name, value: money(others.cost), extra: tok(others.tokens) }] : []),
84 ]
85}
86
87export const unavailableCards = (): CardText[] => [{ name: 'usage unavailable', value: '', extra: '' }]
88
89// Until the first read of ccusage is back.
90export const loadingCards = (title = 'today'): CardText[] => [{ name: title, value: '', extra: 'loading' }]
91
92export type Parsed = { ok: true; days: DayEntry[] } | { ok: false }
93
94// Every day in `ccusage daily --json` output. An empty list is fine: no usage yet. An entry whose `period`
95// is not text cannot be placed in time, so it is dropped.
96export const parseCcusage = (stdout: string): Parsed => {
97 let data: unknown
98 try {
99 data = JSON.parse(stdout)
100 } catch {
101 return { ok: false }
102 }
103 const daily = (data as { daily?: unknown } | null)?.daily
104 if (!Array.isArray(daily)) return { ok: false }
105 const days = daily.filter((d: unknown): d is DayEntry => d !== null && typeof d === 'object' && typeof (d as { period?: unknown }).period === 'string')
106 return { ok: true, days }
107}
108
109export type SpendState = 'loading' | 'ready' | 'failed'
110export type Spend = { days: DayEntry[]; state: SpendState }
111
112// What the list of days and its state become after a read. A read that failed leaves a good list as it was.
113export const nextSpend = (current: Spend, read: Parsed | null): Spend =>
114 read?.ok ? { days: read.days, state: 'ready' } : { days: current.days, state: current.state === 'ready' ? 'ready' : 'failed' }
115hooks/desktop-state.ts 242 lines1// From the module's state to what the desktop draws: the strip's and the panel's inputs, their widths, the
2// ticker's quantized speed and travelled distance, and the keys that say when a redraw is due. Pure.
3import { capModels, dayTotal, modelSums, money, tok } from './cards'
4import type { Day, DayEntry, ModelSum, SpendState } from './cards'
5import { caption } from './gauge'
6import type { Week } from './gauge'
7import type { PanelDay, PanelInput, PanelModel } from './panel-svg'
8import { aggregate, cardTitle, rangeFor } from './periods'
9import type { Period } from './periods'
10import type { StripInput, TickerCard } from './strip-svg'
11import { STRIP_MAX_WIDTH, STRIP_MIN_WIDTH } from './strip-svg'
12import { PANEL_MAX_WIDTH, PANEL_MIN_WIDTH } from './panel-svg'
13import { isoDate } from './zone'
14
15export type DesktopState = {
16 days: DayEntry[]
17 spend: SpendState
18 period: Period
19 rangeLabel: string
20 week: Week | null
21 now: number
22 offsetMin: number
23 pacePerHour: number
24}
25
26export const PX_PER_COLUMN = 7.25 // the desktop's code-font advance, as measured on the owner's app
27export const STRIP_RESERVED_PX = 190 // the period and Details buttons beside the strip, with the gaps
28const DEFAULT_COLUMNS = 106 // the owner's band, when the surface has not measured yet
29export const PANEL_DEFAULT_WIDTH = 400
30const PANEL_INSET_PX = 16
31export const DAILY_DAYS = 14
32export const SOURCE = 'Prices are ccusage estimates'
33
34const clamp = (x: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, x))
35const usable = (x: number | undefined): x is number => typeof x === 'number' && Number.isFinite(x) && x > 0
36
37export const stripWidthFor = (columns: number | undefined): number =>
38 clamp(Math.round((usable(columns) ? columns : DEFAULT_COLUMNS) * PX_PER_COLUMN) - STRIP_RESERVED_PX, STRIP_MIN_WIDTH, STRIP_MAX_WIDTH)
39
40export const panelWidthFor = (columns: number | undefined): number =>
41 usable(columns) ? clamp(Math.round(columns * PX_PER_COLUMN) - PANEL_INSET_PX, PANEL_MIN_WIDTH, PANEL_MAX_WIDTH) : PANEL_DEFAULT_WIDTH
42
43// The desktop ticker moves at one speed. A change of speed cannot be smooth in an SVG animation (the frame takes
44// new markup and its animation starts over), so pace, turns and idling never touch it; the panel shows the pace.
45// 12 px/s is about one and a half monospace characters a second at the ticker's 12.5 px values: calm enough to
46// read a card as it passes, quick enough to be seen moving, and a 580 px strip turns over in about half a minute.
47export const DESK_SPEED = 12
48
49// Where the ticker is at `t`: the speed times the time since the crawl began (`start`). Derived, never summed.
50export const phaseAt = (start: number, t: number): number => (DESK_SPEED * Math.max(0, t - start)) / 1000
51
52// An estimate, to tune: how long after the markup is handed over the desktop's frame starts its SMIL clock.
53// The begin offset is computed for that moment, so a reloaded frame picks up where the old one would be.
54export const FRAME_LATENCY_MS = 150
55export const framePhase = (start: number, now: number): number => phaseAt(start, now + FRAME_LATENCY_MS)
56
57// When new figures may replace the strip on screen. Every new drawing reloads the surface's frame (a blink, and
58// the ticker restarts), so: what the person did (a period, a tab, a resize, the first drawing) at once; a
59// placeholder ("Reading usage…") and the first weekly reading into an empty ring at once; anything else never
60// while a turn runs, only after 15 s of quiet, and
61// at most once in five minutes.
62export const DEFER_IDLE_MS = 15_000
63export const DEFER_MIN_GAP_MS = 5 * 60_000
64export type RedrawAsk = {
65 working: boolean
66 userInitiated: boolean
67 keyChanged: boolean
68 lastDrawAt: number | null
69 idleSince: number | null // when the last turn ended (null while one runs)
70 now: number
71 showsPlaceholder: boolean
72 firstReading: boolean // the ring on screen is empty and a weekly reading has arrived
73 percentMoved: boolean // the ring's whole percent differs from the one on screen
74}
75
76// A new whole percent on the ring is worth a quicker redraw than other figures: after the turn, 3 s of quiet,
77// and at least 60 s since the last drawing.
78export const FAST_IDLE_MS = 3_000
79export const FAST_GAP_MS = 60_000
80export const redrawDue = (a: RedrawAsk): boolean => {
81 if (!a.keyChanged) return false
82 if (a.userInitiated || a.showsPlaceholder || a.firstReading || a.lastDrawAt === null) return true
83 if (a.working || a.idleSince === null) return false
84 if (a.percentMoved && a.now - a.idleSince >= FAST_IDLE_MS && a.now - a.lastDrawAt >= FAST_GAP_MS) return true
85 if (a.now - a.idleSince < DEFER_IDLE_MS) return false
86 return a.now - a.lastDrawAt >= DEFER_MIN_GAP_MS
87}
88
89// One drawing per key: while the key holds, every render hands back the identical markup (and the phase
90// baked into it), so the surface has nothing new to load.
91export type Memo<V> = { key: string; value: V }
92export const memoMarkup = <V>(prev: Memo<V> | null, key: string, build: () => V): Memo<V> =>
93 prev !== null && prev.key === key ? prev : { key, value: build() }
94
95// The desktop's picture of time moves in ten minute steps: the week's caption ("Runs out Sat 22:00" drifts by
96// the minute), its notch and the day boundary, so the clock alone never reloads the strip more often than that.
97export const DESK_CLOCK_MS = 10 * 60_000
98export const deskClock = (now: number): number => Math.floor(now / DESK_CLOCK_MS) * DESK_CLOCK_MS
99
100// The pace as the panel shows it: to the half dollar, and nothing when there is none to speak of.
101export const paceShown = (usdPerHour: number): number | null => {
102 const half = Number.isFinite(usdPerHour) ? Math.round(usdPerHour * 2) / 2 : 0
103 return half > 0 ? half : null
104}
105
106const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
107
108// The last `n` local days up to `todayIso`, oldest first, each with its spend; a day ccusage has no entry for is zero.
109export const dailySeries = (days: readonly DayEntry[], todayIso: string, n = DAILY_DAYS): PanelDay[] => {
110 const today = Date.parse(`${todayIso}T00:00:00Z`)
111 if (!/^\d{4}-\d{2}-\d{2}$/.test(todayIso) || Number.isNaN(today)) return []
112 const byDay = new Map(days.map(d => [d.period, dayTotal(d)]))
113 return Array.from({ length: n }, (_, i) => {
114 const d = new Date(today - (n - 1 - i) * 86400e3)
115 const iso = isoDate(d.getTime(), 0)
116 const cost = byDay.get(iso)
117 return { label: `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`, cost: typeof cost === 'number' && Number.isFinite(cost) ? cost : 0 }
118 })
119}
120
121// The period's days added up, from the days already read.
122const periodDay = (s: DesktopState): Day => {
123 const { since, until } = rangeFor(s.period, s.now, s.offsetMin, s.week?.resetsAtMs ?? null)
124 return aggregate(s.days, since, until)
125}
126
127type Shown = { total: string; tokens: string; models: ModelSum[]; totalCost: number; emptyText?: string }
128
129const shown = (s: DesktopState): Shown => {
130 if (s.spend === 'loading') return { total: '…', tokens: 'loading', models: [], totalCost: 0, emptyText: 'Reading usage…' }
131 if (s.spend === 'failed') return { total: '–', tokens: 'usage unavailable', models: [], totalCost: 0, emptyText: 'Usage unavailable' }
132 const day = periodDay(s)
133 const models = modelSums(day)
134 const { shown: top, others } = capModels(models)
135 const totalCost = dayTotal(day)
136 return { total: money(totalCost), tokens: `${tok(models.reduce((sum, m) => sum + m.tokens, 0))} tokens`, models: others ? [...top, others] : top, totalCost }
137}
138
139const shareOf = (m: ModelSum, total: number) => (total > 0 && Number.isFinite(m.cost) ? Math.min(1, Math.max(0, m.cost / total)) : 0)
140const percent = (share: number) => (share > 0 && share < 0.005 ? '<1%' : `${Math.round(share * 100)}%`)
141const tooltip = (m: ModelSum, total: number, title: string) => `${m.name}: ${money(m.cost)}, ${tok(m.tokens)} tokens, ${percent(shareOf(m, total))} of ${title}`
142
143export const tickerCards = (s: DesktopState): TickerCard[] => {
144 const v = shown(s)
145 const title = cardTitle(s.period)
146 return v.models.map(m => ({ name: m.name, value: money(m.cost), share: shareOf(m, v.totalCost), tooltip: tooltip(m, v.totalCost, title) }))
147}
148
149export const stripInput = (s: DesktopState, width: number, phasePx: number): StripInput => {
150 const v = shown(s)
151 return {
152 width,
153 title: cardTitle(s.period),
154 total: v.total,
155 tokens: v.tokens,
156 cards: tickerCards(s),
157 week: s.week,
158 now: s.now,
159 offsetMin: s.offsetMin,
160 speedPxPerSec: DESK_SPEED,
161 phasePx,
162 rangeChip: s.rangeLabel,
163 ...(v.emptyText ? { emptyText: v.emptyText } : {}),
164 }
165}
166
167export const panelInput = (s: DesktopState, width: number): PanelInput => {
168 const v = shown(s)
169 const title = cardTitle(s.period)
170 const models: PanelModel[] = v.models.map(m => ({
171 name: m.name,
172 value: money(m.cost),
173 cost: m.cost,
174 tooltip: tooltip(m, v.totalCost, title),
175 detail: `${tok(m.tokens)} · ${percent(shareOf(m, v.totalCost))}`,
176 }))
177 return {
178 width,
179 title,
180 total: v.total,
181 tokens: v.tokens,
182 range: s.rangeLabel,
183 models,
184 daily: dailySeries(s.days, isoDate(s.now, s.offsetMin)),
185 week: s.week,
186 now: s.now,
187 offsetMin: s.offsetMin,
188 pacePerHour: paceShown(s.pacePerHour),
189 source: SOURCE,
190 header: 0, // the tabs are native buttons above the card
191 ...(v.emptyText ? { emptyText: v.emptyText } : {}),
192 }
193}
194
195// What the week looks like at the resolution the drawings show it: the caption, the used percent, the notch.
196export const weekLook = (week: Week | null, now: number, offsetMin: number) =>
197 week === null ? null : [caption(week, now, offsetMin).text, Math.round(week.usedPct), week.elapsedFrac === null ? null : Math.round(week.elapsedFrac * 100)]
198
199// What the strip shows, part by part, give or take the ticker's position. Equal parts draw the same picture.
200export const STRIP_PARTS = ['width', 'period', 'total', 'cards', 'range', 'week', 'percent'] as const
201export type StripPart = (typeof STRIP_PARTS)[number]
202export const stripKeyParts = (i: StripInput): Record<StripPart, unknown> => ({
203 width: i.width,
204 period: i.title,
205 total: [i.total, i.tokens],
206 cards: [i.cards, i.emptyText ?? ''],
207 range: i.rangeChip,
208 week: i.week === null ? null : [caption(i.week, i.now, i.offsetMin).text, i.week.elapsedFrac === null ? null : Math.round(i.week.elapsedFrac * 100)],
209 percent: i.week === null ? null : Math.round(i.week.usedPct), // the ring's figure, apart from the clock's drift
210})
211
212// The ring's whole percent moved between two readings (a first reading has its own rule).
213export const percentMoved = (shown: Record<StripPart, unknown> | null, next: Record<StripPart, unknown>): boolean =>
214 shown !== null && shown.percent !== null && next.percent !== null && shown.percent !== next.percent
215
216// How old the weekly reading is: the engine's figures are those of this session's last response, so they go stale
217// while it is idle, whatever other sessions spend. From when it was received, not when it was drawn.
218export const readingAge = (now: number, readAt: number | null): string | null => {
219 if (readAt === null) return null
220 const min = Math.floor(Math.max(0, now - readAt) / 60_000)
221 if (min < 1) return 'reading just now'
222 if (min < 90) return `reading ${min} min ago`
223 return `reading ${Math.round(min / 6) / 10} h ago`
224}
225
226// The empty ring on screen meets its first weekly reading (the desktop often has none until the first response).
227export const firstReading = (shown: Record<StripPart, unknown> | null, next: Record<StripPart, unknown>): boolean =>
228 shown !== null && shown.week === null && next.week !== null
229
230// A redraw is due only when the key moves.
231export const stripKey = (i: StripInput): string => JSON.stringify(stripKeyParts(i))
232
233// Which parts changed between two drawings, for the diagnostic line: 'total+cards', 'period+total+cards+range', or 'none'.
234export const reasonFor = (prev: Record<StripPart, unknown> | null, next: Record<StripPart, unknown>): string => {
235 if (prev === null) return 'none'
236 const changed = STRIP_PARTS.filter(p => JSON.stringify(prev[p]) !== JSON.stringify(next[p]))
237 return changed.length > 0 ? changed.join('+') : 'none'
238}
239
240export type Diag = { renders: number; changes: number; invalidates: number; reason: string }
241export const diagLine = (d: Diag): string => `diag r${d.renders} c${d.changes} i${d.invalidates} last: ${d.reason}`
242hooks/gauge.ts 114 lines1import type { Cell } from './led'
2import { PALETTE, mix } from './palette'
3
4export const WEEK_MS = 7 * 86400e3
5
6export type Limit = { percentUsed: number; resetsAt?: string }
7// usedPct is 0..100; elapsedFrac is how far into the week we are (null when the reset time is unknown).
8export type Week = { usedPct: number; elapsedFrac: number | null; resetsAtMs: number | null }
9export type Forecast = { projected: number; hasForecast: boolean; isOut: boolean; outInFrac: number | null }
10export type Caption = { text: string; short: string; isDanger: boolean }
11
12// A reset time that has already passed means the window this reading described is over, so there is no week to draw until the engine reports the next one.
13export const weekFromLimit = (limit: Limit, now: number): Week | null => {
14 const resetsAtMs = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
15 if (Number.isNaN(resetsAtMs)) return { usedPct: limit.percentUsed, elapsedFrac: null, resetsAtMs: null }
16 if (resetsAtMs <= now) return null
17 const elapsedFrac = Math.max(0, 1 - (resetsAtMs - now) / WEEK_MS) // a reset more than a week away is the start of a week
18 return { usedPct: limit.percentUsed, elapsedFrac, resetsAtMs }
19}
20
21// Straight line: use so far, divided by the share of the week gone.
22export const forecast = (usedPct: number, elapsedFrac: number | null): Forecast => {
23 if (usedPct >= 100) return { projected: usedPct, hasForecast: true, isOut: true, outInFrac: 0 }
24 if (elapsedFrac === null || elapsedFrac < 0.02 || usedPct <= 0) {
25 return { projected: usedPct, hasForecast: false, isOut: false, outInFrac: null }
26 }
27 const projected = usedPct / elapsedFrac
28 const isOut = projected >= 100
29 return { projected, hasForecast: true, isOut, outInFrac: isOut ? (elapsedFrac * 100) / usedPct - elapsedFrac : null }
30}
31
32// One continuous ramp from cool to red instead of a traffic light.
33export const heat = (projectedPct: number): number => {
34 const stops: [number, number][] = [
35 [55, PALETTE.glacier],
36 [85, PALETTE.straw],
37 [100, PALETTE.ember],
38 [112, PALETTE.signal],
39 ]
40 if (projectedPct <= stops[0]![0]) return stops[0]![1]
41 for (let i = 1; i < stops.length; i++) {
42 const [p1, c1] = stops[i]!
43 const [p0, c0] = stops[i - 1]!
44 if (projectedPct <= p1) return mix(c0, c1, (projectedPct - p0) / (p1 - p0))
45 }
46 return PALETTE.signal
47}
48
49const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
50const two = (n: number) => String(n).padStart(2, '0')
51// Local day and time from a UTC instant and the machine's offset east of UTC, in minutes.
52const local = (ms: number, offsetMin: number) => {
53 const d = new Date(Math.round(ms) + offsetMin * 60e3)
54 return { day: DAYS[d.getUTCDay()]!, time: `${two(d.getUTCHours())}:${two(d.getUTCMinutes())}` }
55}
56
57export const caption = (week: Week | null, now: number, offsetMin: number): Caption => {
58 if (week === null) return { text: 'No reading yet', short: 'No reading', isDanger: false }
59 const f = forecast(week.usedPct, week.elapsedFrac)
60 const reset = week.resetsAtMs === null ? null : local(week.resetsAtMs, offsetMin)
61 if (week.usedPct >= 100) {
62 return { text: reset ? `At limit, resets ${reset.day} ${reset.time}` : 'At limit', short: 'At limit', isDanger: true }
63 }
64 if (!f.hasForecast) {
65 const text = reset ? `Resets ${reset.day} ${reset.time}` : 'No forecast yet'
66 return { text, short: text, isDanger: false }
67 }
68 if (f.isOut) {
69 const out = local(now + (f.outInFrac ?? 0) * WEEK_MS, offsetMin)
70 const text = `Runs out ${out.day} ${out.time}`
71 return { text, short: text, isDanger: true }
72 }
73 const lands = `Lands near ${Math.round(f.projected)}%`
74 return { text: reset ? `${lands}, resets ${reset.day}` : lands, short: lands, isDanger: false }
75}
76
77const PARTIAL = [0x258f, 0x258e, 0x258d, 0x258c, 0x258b, 0x258a, 0x2589] // 1/8 to 7/8 of a cell, from the left
78
79// One row: the fill, a faint ghost out to where you land, and a notch where the calendar is.
80export const gaugeCells = (week: Week | null, width: number): { cells: Cell[]; hue: number } => {
81 if (week === null) {
82 return { cells: Array.from({ length: width }, (): Cell => [0x20, PALETTE.track, PALETTE.track]), hue: PALETTE.glacier }
83 }
84 const f = forecast(week.usedPct, week.elapsedFrac)
85 const hue = heat(f.projected)
86 const ghostColor = mix(PALETTE.panel, hue, 0.3)
87 const eighths = (pct: number) => Math.round((Math.min(pct, 100) / 100) * width * 8)
88 const used8 = eighths(week.usedPct)
89 const ghost8 = Math.max(used8, eighths(f.projected))
90 let notchCell = week.elapsedFrac === null ? -1 : Math.min(width - 1, Math.floor(week.elapsedFrac * width))
91 if (notchCell === Math.floor(used8 / 8) && used8 % 8 !== 0) notchCell = Math.min(width - 1, notchCell + 1)
92 const cells: Cell[] = []
93 for (let i = 0; i < width; i++) {
94 const lo = i * 8
95 const u = Math.min(Math.max(used8 - lo, 0), 8)
96 const g = Math.min(Math.max(ghost8 - lo, 0), 8)
97 let cell: Cell
98 if (u === 8) cell = [0x2588, hue, hue]
99 else if (u > 0) cell = [PARTIAL[u - 1]!, hue, g > u ? ghostColor : PALETTE.track]
100 else if (g === 8) cell = [0x20, ghostColor, ghostColor]
101 else if (g > 0) cell = [PARTIAL[g - 1]!, ghostColor, PALETTE.track]
102 else cell = [0x20, PALETTE.track, PALETTE.track]
103 if (i === notchCell) cell = [0x258f, PALETTE.notch, cell[0] === 0x2588 ? hue : cell[2]]
104 cells.push(cell)
105 }
106 return { cells, hue }
107}
108
109// Only the seven-day window is drawn; any other kind the engine reports is ignored.
110export const sevenDay = (limits: readonly (Limit & { kind: string })[]): Limit | undefined => {
111 const found = limits.find(l => l.kind === 'seven_day')
112 return found ? { percentUsed: found.percentUsed, resetsAt: found.resetsAt } : undefined
113}
114hooks/led.ts 125 lines1import { PALETTE, mix, smooth } from './palette'
2
3// A Raster cell: code point, foreground, background.
4export type Cell = [codePoint: number, fg: number, bg: number]
5export type Tone = 0 | 1
6export type StripColumn = { m: number; tone: Tone }
7export type Board = { strip: StripColumn[]; starts: number[]; length: number }
8export type CardText = { name: string; value: string; extra: string }
9
10export const TONE_BRIGHT: Tone = 0
11export const TONE_DIM: Tone = 1
12export const ADVANCE = 6 // a 5 pixel glyph and a 1 pixel gap
13export const SEP_DOTS = 0b0101010 // the dotted tick between cards
14
15// 5x7 dot-matrix face. Capitals, digits and the few symbols a price needs.
16export const FONT_ROWS: Record<string, readonly string[]> = {
17 A: ['.###.', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'],
18 B: ['####.', '#...#', '#...#', '####.', '#...#', '#...#', '####.'],
19 C: ['.###.', '#...#', '#....', '#....', '#....', '#...#', '.###.'],
20 D: ['####.', '#...#', '#...#', '#...#', '#...#', '#...#', '####.'],
21 E: ['#####', '#....', '#....', '####.', '#....', '#....', '#####'],
22 F: ['#####', '#....', '#....', '####.', '#....', '#....', '#....'],
23 G: ['.###.', '#...#', '#....', '#.###', '#...#', '#...#', '.###.'],
24 H: ['#...#', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'],
25 I: ['.###.', '..#..', '..#..', '..#..', '..#..', '..#..', '.###.'],
26 J: ['..###', '...#.', '...#.', '...#.', '...#.', '#..#.', '.##..'],
27 K: ['#...#', '#..#.', '#.#..', '##...', '#.#..', '#..#.', '#...#'],
28 L: ['#....', '#....', '#....', '#....', '#....', '#....', '#####'],
29 M: ['#...#', '##.##', '#.#.#', '#.#.#', '#...#', '#...#', '#...#'],
30 N: ['#...#', '##..#', '#.#.#', '#..##', '#...#', '#...#', '#...#'],
31 O: ['.###.', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
32 P: ['####.', '#...#', '#...#', '####.', '#....', '#....', '#....'],
33 Q: ['.###.', '#...#', '#...#', '#...#', '#.#.#', '#..#.', '.##.#'],
34 R: ['####.', '#...#', '#...#', '####.', '#.#..', '#..#.', '#...#'],
35 S: ['.####', '#....', '#....', '.###.', '....#', '....#', '####.'],
36 T: ['#####', '..#..', '..#..', '..#..', '..#..', '..#..', '..#..'],
37 U: ['#...#', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
38 V: ['#...#', '#...#', '#...#', '#...#', '#...#', '.#.#.', '..#..'],
39 W: ['#...#', '#...#', '#...#', '#.#.#', '#.#.#', '##.##', '#...#'],
40 X: ['#...#', '#...#', '.#.#.', '..#..', '.#.#.', '#...#', '#...#'],
41 Y: ['#...#', '#...#', '.#.#.', '..#..', '..#..', '..#..', '..#..'],
42 Z: ['#####', '....#', '...#.', '..#..', '.#...', '#....', '#####'],
43 0: ['.###.', '#...#', '#..##', '#.#.#', '##..#', '#...#', '.###.'],
44 1: ['..#..', '.##..', '..#..', '..#..', '..#..', '..#..', '.###.'],
45 2: ['.###.', '#...#', '....#', '...#.', '..#..', '.#...', '#####'],
46 3: ['#####', '...#.', '..#..', '...#.', '....#', '#...#', '.###.'],
47 4: ['...#.', '..##.', '.#.#.', '#..#.', '#####', '...#.', '...#.'],
48 5: ['#####', '#....', '####.', '....#', '....#', '#...#', '.###.'],
49 6: ['..##.', '.#...', '#....', '####.', '#...#', '#...#', '.###.'],
50 7: ['#####', '....#', '...#.', '..#..', '.#...', '.#...', '.#...'],
51 8: ['.###.', '#...#', '#...#', '.###.', '#...#', '#...#', '.###.'],
52 9: ['.###.', '#...#', '#...#', '.####', '....#', '...#.', '.##..'],
53 $: ['..#..', '.####', '#.#..', '.###.', '..#.#', '####.', '..#..'],
54 '.': ['.....', '.....', '.....', '.....', '.....', '.##..', '.##..'],
55 '%': ['##..#', '##..#', '...#.', '..#..', '.#...', '#..##', '#..##'],
56 '-': ['.....', '.....', '.....', '#####', '.....', '.....', '.....'],
57 ' ': ['.....', '.....', '.....', '.....', '.....', '.....', '.....'],
58}
59
60const BRAILLE_LEFT = [0x01, 0x02, 0x04, 0x40] // left pixel column, top to bottom
61const BRAILLE_RIGHT = [0x08, 0x10, 0x20, 0x80] // right pixel column
62
63// The 5 column masks of a glyph, row 0 in bit 0. A character the font lacks is blank.
64export const glyphCols = (ch: string): number[] => {
65 const rows = FONT_ROWS[ch] ?? FONT_ROWS[' ']!
66 const cols: number[] = []
67 for (let x = 0; x < 5; x++) {
68 let m = 0
69 for (let y = 0; y < 7; y++) if (rows[y]?.[x] === '#') m |= 1 << y
70 cols.push(m)
71 }
72 return cols
73}
74
75const textStrip = (text: string, tone: Tone): StripColumn[] => {
76 const out: StripColumn[] = []
77 for (const ch of text) {
78 for (const m of glyphCols(ch.toUpperCase())) out.push({ m, tone })
79 out.push({ m: 0, tone })
80 }
81 return out
82}
83
84const blank = (n: number): StripColumn[] => Array.from({ length: n }, () => ({ m: 0, tone: TONE_DIM }))
85
86// One long 8 pixel tall bitmap of every card, and where each card begins in it.
87export const buildStrip = (cards: readonly CardText[]): Board => {
88 const strip: StripColumn[] = []
89 const starts: number[] = []
90 for (const card of cards) {
91 starts.push(strip.length)
92 strip.push(...textStrip(card.name, TONE_DIM), ...blank(ADVANCE * 2))
93 strip.push(...textStrip(card.value, TONE_BRIGHT), ...blank(ADVANCE * 2))
94 strip.push(...textStrip(card.extra, TONE_DIM))
95 strip.push(...blank(16), { m: SEP_DOTS, tone: TONE_DIM }, ...blank(16))
96 }
97 return { strip, starts, length: strip.length }
98}
99
100// The crawl as two rows of braille cells, `offset` pixels into the strip, with soft ends.
101export const crawlCells = (board: Board, offset: number, cols: number, fadeCells: number): Cell[] => {
102 const cells: Cell[] = []
103 for (let r = 0; r < 2; r++) {
104 for (let c = 0; c < cols; c++) {
105 let bits = 0
106 let tone: Tone | null = null
107 for (let px = 0; px < 2 && board.length > 0; px++) {
108 const x = Math.floor(offset) + c * 2 + px
109 const col = board.strip[((x % board.length) + board.length) % board.length]!
110 const nibble = (col.m >> (4 * r)) & 0xf
111 if (!nibble) continue
112 if (tone === null) tone = col.tone
113 for (let dy = 0; dy < 4; dy++) {
114 if (nibble & (1 << dy)) bits |= (px === 0 ? BRAILLE_LEFT : BRAILLE_RIGHT)[dy]!
115 }
116 }
117 const edge = Math.min(c + 0.5, cols - c - 0.5) / fadeCells
118 const strength = edge >= 1 ? 1 : smooth(Math.max(0, edge))
119 const lit = tone === TONE_DIM ? PALETTE.bulbDim : PALETTE.bulb
120 cells.push([bits ? 0x2800 + bits : 0x20, mix(PALETTE.panel, lit, strength), PALETTE.panel])
121 }
122 }
123 return cells
124}
125hooks/pace.ts 46 lines1// How fast the crawl slides. It always moves: a slow drift when nothing is happening,
2// faster the quicker money is being spent, and never below a visible floor while a turn runs.
3export const IDLE_PX_S = 6 // about one character a second
4export const WORKING_FLOOR_PX_S = 14 // so the crawl visibly picks up the moment a turn starts
5export const MAX_PX_S = 44 // about seven characters a second
6
7const PACE_SCALE = 9.5 // px/s gained per natural-log step of pace
8const PACE_KNEE = 4 // dollars an hour at which the curve starts to bend
9
10export const SESSION_KEEP_MS = 90_000 // this session's spend, sampled every couple of seconds
11export const DAY_KEEP_MS = 15 * 60_000 // today's machine-wide total, sampled every few minutes
12const SESSION_MIN_SPAN_MS = 8_000
13const DAY_MIN_SPAN_MS = 60_000
14const DAY_TAU_MS = 180_000 // how fast the machine-wide pace fades once its samples stop changing
15
16export type Sample = { t: number; usd: number }
17
18export const speedFor = (usdPerHour: number, working: boolean): number => {
19 const rate = Number.isFinite(usdPerHour) && usdPerHour > 0 ? usdPerHour : 0
20 const paced = IDLE_PX_S + PACE_SCALE * Math.log(1 + rate / PACE_KNEE)
21 return Math.min(MAX_PX_S, Math.max(working ? WORKING_FLOOR_PX_S : IDLE_PX_S, paced))
22}
23
24export const addSample = (samples: readonly Sample[], t: number, usd: number, keepMs: number): Sample[] =>
25 [...samples, { t, usd }].filter(s => t - s.t <= keepMs)
26
27// Dollars an hour between the first and last sample. Zero when there is too little to say,
28// or the total went down (a new day starts the machine-wide total over).
29export const rateOf = (samples: readonly Sample[], minSpanMs: number): number => {
30 const first = samples[0]
31 const last = samples[samples.length - 1]
32 if (!first || !last || samples.length < 2) return 0
33 const span = last.t - first.t
34 const spent = last.usd - first.usd
35 return span >= minSpanMs && spent > 0 ? (spent * 3600e3) / span : 0
36}
37
38export const fade = (rate: number, ageMs: number, tauMs: number): number => rate * Math.exp(-Math.max(0, ageMs) / tauMs)
39
40// The faster of this session's live pace and the machine-wide pace, which fades as it ages.
41export const paceNow = (session: readonly Sample[], day: readonly Sample[], now: number): number => {
42 const lastDay = day[day.length - 1]
43 const machine = lastDay ? fade(rateOf(day, DAY_MIN_SPAN_MS), now - lastDay.t, DAY_TAU_MS) : 0
44 return Math.max(rateOf(session, SESSION_MIN_SPAN_MS), machine)
45}
46hooks/desktop-theme.ts 53 lines1// The desktop instrument panel's tokens: one dark glass card in either app theme, quiet text tones,
2// and a single accent taken from the weekly heat ramp.
3import { forecast, heat } from './gauge'
4import type { Week } from './gauge'
5import { PALETTE, mix } from './palette'
6import { n } from './svg-util'
7
8export const hex = (c: number): string => `#${(c & 0xffffff).toString(16).padStart(6, '0')}`
9
10export const INK = {
11 cardTop: '#171d27',
12 cardBottom: '#0d1117',
13 edge: 'rgba(255,255,255,0.1)', // the card's hairline border
14 rule: 'rgba(255,255,255,0.07)', // dividers inside the card
15 track: hex(PALETTE.track),
16 figure: hex(PALETTE.notch), // the numbers that matter
17 text: hex(mix(PALETTE.notch, PALETTE.label, 0.42)), // names and words
18 label: hex(PALETTE.label), // captions
19 dim: hex(mix(PALETTE.label, PALETTE.panel, 0.38)), // the quietest marks
20 notch: hex(PALETTE.notch),
21} as const
22
23export const FONT = {
24 mono: "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace",
25 sans: "system-ui, -apple-system, 'Segoe UI', sans-serif",
26} as const
27
28// The accent follows where the week lands; with no reading it rests on the coolest tone.
29export const accentOf = (week: Week | null): number => (week === null ? PALETTE.glacier : heat(forecast(week.usedPct, week.elapsedFrac).projected))
30
31// Every drawing starts with this: a transparent frame in both color schemes and the two type classes.
32export const styleTag = (css: string): string =>
33 '<style>:root{color-scheme:light dark} svg{background:transparent}' +
34 `.m{font-family:${FONT.mono};font-variant-numeric:tabular-nums}` +
35 `.s{font-family:${FONT.sans}}` +
36 `${css}</style>`
37
38// A soft halo: the shape blurred, faded to `strength`, laid under the crisp original.
39export const glowFilter = (id: string, blur: number, strength: number): string =>
40 `<filter id="${id}" x="-50%" y="-100%" width="200%" height="300%" color-interpolation-filters="sRGB">` +
41 `<feGaussianBlur in="SourceGraphic" stdDeviation="${n(blur)}" result="b"/>` +
42 `<feComponentTransfer in="b" result="g"><feFuncA type="linear" slope="${n(strength)}"/></feComponentTransfer>` +
43 '<feMerge><feMergeNode in="g"/><feMergeNode in="SourceGraphic"/></feMerge></filter>'
44
45// The glass card: a top-lit gradient, a hairline edge, and a faint highlight along the top.
46export const cardDefs = (id: string, width: number): string =>
47 `<linearGradient id="${id}bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${INK.cardTop}"/><stop offset="1" stop-color="${INK.cardBottom}"/></linearGradient>` +
48 `<linearGradient id="${id}lit" x1="0" y1="0" x2="${n(width)}" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff" stop-opacity="0.07"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>`
49
50export const card = (id: string, width: number, height: number, radius: number): string =>
51 `<rect x="0.5" y="0.5" width="${n(width - 1)}" height="${n(height - 1)}" rx="${n(radius)}" fill="url(#${id}bg)" stroke="${INK.edge}"/>` +
52 `<path d="M${n(radius)} 1.5H${n(width - radius)}" stroke="url(#${id}lit)"/>`
53hooks/pane-view.ts 174 lines1// The Details pane as the desktop draws it: native Box and Text for everything that changes (they update in
2// place), and two small SVGs for what changes rarely, each with its own key: the weekly ring and the daily
3// sparkline. An Svg can only change by being replaced, which reloads its frame, so nothing live goes in one.
4import { money } from './cards'
5import { INK, accentOf, glowFilter, hex, styleTag } from './desktop-theme'
6import { mix } from './palette'
7import { weekLook } from './desktop-state'
8import { caption } from './gauge'
9import type { Week } from './gauge'
10import { sparkPath, sparkPoints } from './panel-svg'
11import type { PanelDay, PanelInput } from './panel-svg'
12import { ringModel, ringSvg } from './ring-svg'
13import { escape, fit, n, sansWidth } from './svg-util'
14
15const NAME_MAX_COLS = 14
16const CARD_CHROME_COLS = 6 // the card's border and padding, both sides, in cells
17const MIN_BAR_COLS = 6
18// The pane's card, flattened from the strip's glass (a native Box takes one color), and its hairline.
19export const PANE_CARD = 0x12171f
20export const PANE_EDGE = '#2a303b'
21
22// A model's row, in flow: name | bar track | detail | price. The bar fills its track to the model's share of the
23// biggest one, in whole percents (the engine refuses "33.3%"); the top model's bar is in the accent.
24export type PaneRow = { name: string; value: string; detail: string; bar: string; top: boolean }
25export type PaneView = {
26 total: string
27 meta: string // '96M tokens · Oct 1'
28 source: string
29 rows: PaneRow[]
30 emptyText: string | null
31 barTrack: string // the empty part of a bar: barely there
32 barRest: string // the bars after the first: the accent, sunk halfway into the card
33 nameCols: number // the row's columns, in cells, fitted to the pane; detailCols is 0 when it is too narrow for it
34 barCols: number
35 detailCols: number
36 valueCols: number
37 daily: PanelDay[]
38 peak: string
39 pace: string | null // '$6.50/h'
40 week: Week | null
41 now: number
42 offsetMin: number
43 weekLabel: string
44 weekCaption: string
45 weekDanger: boolean
46 weekUsed: string | null // '39% used', for a pane drawn without its ring
47 readingAge: string | null // 'reading 4 min ago': how stale the weekly figures are
48 accent: string
49}
50
51const finite = (x: number) => (Number.isFinite(x) && x > 0 ? x : 0)
52
53// The row's columns for a pane `columns` cells across: name, a gap, the bar, a gap, the detail (dropped first when
54// there is no room), a gap, the price. The bar keeps at least a stub; the name shrinks before it goes.
55export const rowColumns = (columns: number, nameW: number, detailW: number, valueW: number) => {
56 const inner = Math.max(10, (Number.isFinite(columns) ? columns : 0) - CARD_CHROME_COLS)
57 let nameCols = Math.min(nameW, NAME_MAX_COLS)
58 let detailCols = detailW
59 let barCols = inner - nameCols - valueW - detailCols - 3
60 if (barCols < MIN_BAR_COLS) {
61 detailCols = 0
62 barCols = inner - nameCols - valueW - 2
63 }
64 if (barCols < MIN_BAR_COLS) {
65 nameCols = Math.max(4, inner - valueW - 2 - MIN_BAR_COLS)
66 barCols = Math.max(0, inner - nameCols - valueW - 2)
67 }
68 return { nameCols, barCols, detailCols, valueCols: valueW }
69}
70
71export const paneView = (input: PanelInput, columns: number, readingAge: string | null = null): PaneView => {
72 const names = input.models.map(m => m.name.slice(0, NAME_MAX_COLS))
73 const details = input.models.map(m => m.detail ?? '')
74 const longest = (xs: string[]) => Math.max(0, ...xs.map(x => [...x].length))
75 const cols = rowColumns(columns, longest(names), longest(details), longest(input.models.map(m => m.value)))
76 const top = Math.max(0, ...input.models.map(m => finite(m.cost)))
77 const rows = input.models.map((m, i) => {
78 const frac = top > 0 ? finite(m.cost) / top : 0
79 // Whole percents only: the engine refuses a tree with "33.3%" (a width is a number or /^\d{1,3}%$/).
80 const pct = frac > 0 ? Math.max(2, Math.round(frac * 100)) : 0 // a model that cost anything shows a sliver
81 return { name: names[i]!, value: m.value, detail: details[i]!, bar: `${pct}%`, top: i === 0 }
82 })
83 const accent = accentOf(input.week)
84 const cap = input.week ? caption(input.week, input.now, input.offsetMin) : null
85 const peak = Math.max(0, ...input.daily.map(d => finite(d.cost)))
86 return {
87 total: input.total,
88 meta: [input.tokens, input.range].filter(Boolean).join(' · '),
89 source: input.source,
90 rows,
91 emptyText: rows.length === 0 ? (input.emptyText ?? `No usage ${input.title}`) : null,
92 barTrack: hex(mix(0xffffff, PANE_CARD, 0.975)),
93 barRest: hex(mix(accent, PANE_CARD, 0.62)),
94 ...cols,
95 daily: input.daily,
96 peak: input.daily.length > 0 ? `peak ${money(peak)}` : '',
97 pace: input.pacePerHour === null || !Number.isFinite(input.pacePerHour) ? null : `${money(Math.max(0, input.pacePerHour))}/h`,
98 week: input.week,
99 now: input.now,
100 offsetMin: input.offsetMin,
101 weekLabel: 'Weekly limit',
102 weekCaption: cap ? cap.text : 'No weekly limit reading yet',
103 weekDanger: cap?.isDanger ?? false,
104 weekUsed: input.week ? `${Math.round(Math.max(0, input.week.usedPct))}% used` : null,
105 readingAge: input.week ? readingAge : null,
106 accent: hex(accent),
107 }
108}
109
110const svgOpen = (w: number, h: number) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`
111
112// The weekly ring alone, 64 px, on a transparent ground (the native card shows through).
113export const RING_SIZE = 64
114export const ringBadgeSvg = (week: Week, now: number, offsetMin: number): string => {
115 const ring = ringModel(week)
116 const c = RING_SIZE / 2
117 const tip = `Weekly limit ${ring.pct}% used. ${caption(week, now, offsetMin).text}`
118 return (
119 svgOpen(RING_SIZE, RING_SIZE) +
120 styleTag('') +
121 `<g><title>${escape(tip)}</title>${ringSvg({ cx: c, cy: c, r: 27, stroke: 5 }, ring)}</g>` +
122 `<text class="m" x="${c}" y="${c + 4.5}" font-size="14" fill="${INK.figure}" text-anchor="middle">${escape(ring.pct)}<tspan fill="${INK.label}" font-size="10">%</tspan></text>` +
123 '</svg>'
124 )
125}
126export const ringKey = (week: Week | null, now: number, offsetMin: number): string => JSON.stringify(weekLook(week, now, offsetMin))
127
128// The daily spend line alone: area, line, today's dot, the first and last day under it, a tooltip per day.
129export const SPARK_HEIGHT = 62
130export const sparkSvg = (daily: readonly PanelDay[], accent: string, width: number): string => {
131 const W = Math.round(Math.min(480, Math.max(200, Number.isFinite(width) ? width : 340)))
132 const top = 6
133 const floor = 44
134 const pts = sparkPoints(daily, 3, top, W - 6, floor - top)
135 const parts: string[] = [`<path d="M0 ${floor + 0.5}H${W}" stroke="${INK.rule}"/>`]
136 if (pts.length > 1) {
137 const line = sparkPath(pts)
138 parts.push(`<path d="${line}L${n(pts[pts.length - 1]!.x)} ${floor}L${n(pts[0]!.x)} ${floor}Z" fill="url(#ksarea)"/>`)
139 parts.push(`<path d="${line}" fill="none" stroke="${accent}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>`)
140 }
141 const colW = daily.length > 1 ? (W - 6) / (daily.length - 1) : W
142 daily.forEach((d, i) => {
143 const p = pts[i]!
144 const x0 = Math.max(0, p.x - colW / 2)
145 const x1 = Math.min(W, p.x + colW / 2)
146 parts.push(
147 `<g class="day"><title>${escape(`${d.label}: ${money(finite(d.cost))}`)}</title>` +
148 `<rect class="hi" x="${n(x0)}" y="0" width="${n(x1 - x0)}" height="${floor + 2}" fill="#fff" pointer-events="all"/></g>`,
149 )
150 })
151 const last = pts[pts.length - 1]
152 if (last) parts.push(`<circle cx="${n(last.x)}" cy="${n(last.y)}" r="5.5" fill="${accent}" fill-opacity="0.16"/><circle cx="${n(last.x)}" cy="${n(last.y)}" r="2.6" fill="${accent}" filter="url(#ksglow)"/>`)
153 const label = (s: string, x: number, anchor = '') =>
154 `<text class="s" x="${n(x)}" y="58" font-size="9.5" fill="${INK.dim}"${anchor}>${escape(fit(s, W / 2 - 4, t => sansWidth(t, 9.5)))}</text>`
155 if (daily.length > 0) parts.push(label(daily[0]!.label, 0))
156 if (daily.length > 1) parts.push(label(daily[daily.length - 1]!.label, W, ' text-anchor="end"'))
157 return (
158 svgOpen(W, SPARK_HEIGHT) +
159 styleTag('.day .hi{fill-opacity:0;transition:fill-opacity .15s}.day:hover .hi{fill-opacity:.05}') +
160 '<defs>' +
161 glowFilter('ksglow', 4, 0.4) +
162 `<linearGradient id="ksarea" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${accent}" stop-opacity="0.26"/><stop offset="1" stop-color="${accent}" stop-opacity="0"/></linearGradient>` +
163 '</defs>' +
164 parts.join('') +
165 '</svg>'
166 )
167}
168export const sparkKey = (daily: readonly PanelDay[], accent: string, width: number): string => JSON.stringify([daily, accent, width])
169
170// What a pane redraw is for: everything the pane shows that changes, as one string. The diagnostic line is no part
171// of it: it is read afresh at every draw, and asking a redraw for it would chase its own counters.
172export const paneKey = (v: PaneView): string =>
173 JSON.stringify([v.total, v.meta, v.source, v.rows, v.emptyText, v.peak, v.pace, v.weekCaption, v.weekDanger, v.readingAge, v.accent, ringKey(v.week, v.now, v.offsetMin), v.daily])
174hooks/periods.ts 93 lines1import { dayTotal } from './cards'
2import type { Day, DayEntry, ModelRow } from './cards'
3import { isoDate, monthStartIso, weekStartIso } from './zone'
4
5// The stretches of time the crawl can show, and the button that steps through them.
6export const PERIODS = ['today', 'week', 'month', 'all'] as const
7export type Period = (typeof PERIODS)[number]
8
9export const isPeriod = (x: unknown): x is Period => typeof x === 'string' && (PERIODS as readonly string[]).includes(x)
10
11export const nextPeriod = (p: Period): Period => PERIODS[(PERIODS.indexOf(p) + 1) % PERIODS.length]!
12
13const LABELS: Record<Period, string> = { today: 'Today', week: 'Week', month: 'Month', all: 'All time' }
14const TITLES: Record<Period, string> = { today: 'today', week: 'this week', month: 'this month', all: 'all time' }
15
16// On the button.
17export const buttonLabel = (p: Period): string => LABELS[p]
18
19// The name on the first LED card.
20export const cardTitle = (p: Period): string => TITLES[p]
21
22const num = (x: unknown): number => (typeof x === 'number' && Number.isFinite(x) ? x : 0)
23
24// The days from `sinceIso` (every day, when null) to `untilIso`, both included, as one stretch: the sum of their
25// totals, and each model added up across them. Dates are YYYY-MM-DD, which sort as text. A figure that is not a
26// usable number counts as zero. The days themselves are not changed.
27export const aggregate = (days: readonly DayEntry[], sinceIso: string | null, untilIso: string): Day => {
28 let totalCost = 0
29 const byModel = new Map<string, ModelRow>()
30 for (const d of days) {
31 if ((sinceIso !== null && d.period < sinceIso) || d.period > untilIso) continue
32 totalCost += num(dayTotal(d))
33 for (const m of d.modelBreakdowns ?? []) {
34 const sum = byModel.get(m.modelName) ?? { modelName: m.modelName, cost: 0, inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 }
35 sum.cost += num(m.cost)
36 sum.inputTokens += num(m.inputTokens)
37 sum.outputTokens += num(m.outputTokens)
38 sum.cacheCreationTokens += num(m.cacheCreationTokens)
39 sum.cacheReadTokens += num(m.cacheReadTokens)
40 byModel.set(m.modelName, sum)
41 }
42 }
43 return { totalCost, modelBreakdowns: [...byModel.values()] }
44}
45
46export type Range = { since: string | null; until: string }
47
48// The local days a period covers: `since` is null for all time. A week that would begin after today is held to today.
49export const rangeFor = (period: Period, now: number, offsetMin: number, resetsAtMs: number | null): Range => {
50 const until = isoDate(now, offsetMin)
51 switch (period) {
52 case 'today':
53 return { since: until, until }
54 case 'week': {
55 const since = weekStartIso(now, offsetMin, resetsAtMs)
56 return { since: since < until ? since : until, until }
57 }
58 case 'month':
59 return { since: monthStartIso(now, offsetMin), until }
60 case 'all':
61 return { since: null, until }
62 }
63}
64
65const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
66
67// The parts of a YYYY-MM-DD date, or null when it is anything else.
68const dateParts = (iso: string | null | undefined): { year: string; month: string; day: number } | null => {
69 const m = iso ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso) : null
70 const month = m ? MONTHS[Number(m[2]) - 1] : undefined
71 const day = Number(m?.[3])
72 return m && month && day >= 1 && day <= 31 ? { year: m[1]!, month, day } : null
73}
74
75// The small caption under the button, 14 characters at most: which days the crawl is adding up. A date
76// that cannot be read leaves the name of the period.
77export const rangeText = (period: Period, since: string | null, firstPeriod?: string): string => {
78 if (period === 'all') {
79 const first = dateParts(firstPeriod)
80 return first ? `since ${first.month} ${first.year}` : cardTitle(period)
81 }
82 const from = dateParts(since)
83 if (!from) return cardTitle(period)
84 return period === 'today' ? `${from.month} ${from.day}` : `since ${from.month} ${from.day}`
85}
86
87// The first day in the data, whatever order the days come in.
88export const earliestPeriod = (days: readonly DayEntry[]): string | undefined => {
89 let first: string | undefined
90 for (const d of days) if (first === undefined || d.period < first) first = d.period
91 return first
92}
93hooks/scroll.ts 25 lines1export const ACCEL = 30 // px/s^2 speeding up
2export const DECEL = 20 // px/s^2 slowing down
3
4export type Scroll = { offset: number; v: number }
5
6// Starts with the first card just past the soft left edge, `lead` pixels in, standing still.
7export const newScroll = (lead: number): Scroll => ({ offset: -lead, v: 0 })
8
9// Where the same spot is once the strip has been rebuilt at another length: the same share of the way round.
10// Whole laps are dropped, so the result lies inside the new strip. An empty strip, or one that kept its length, leaves the offset alone.
11export const remapOffset = (offset: number, oldLength: number, newLength: number): number => {
12 if (oldLength <= 0 || newLength <= 0 || oldLength === newLength) return offset
13 const position = ((offset % oldLength) + oldLength) % oldLength
14 return (position * newLength) / oldLength
15}
16
17// Eases the speed toward `target` (pixels a second) and moves the strip. It never stops on its own.
18export const stepScroll = (s: Scroll, dt: number, target: number): Scroll => {
19 const step = Math.min(dt, 0.5) // a stalled timer must not fling the strip
20 const want = Number.isFinite(target) && target > 0 ? target : 0
21 s.v = s.v < want ? Math.min(want, s.v + ACCEL * step) : Math.max(want, s.v - DECEL * step)
22 s.offset += s.v * step
23 return s
24}
25