SLOPSHOPPER

usage-ticker

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.

newpanebandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-ticker
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ⢸ ⢸⠰⣉⡉⢰⣉⣱⢰⢉⣑⢸⣉⡉ ⢸ ⢸⢸⠢⣸⢰⣉⣱⢸ ⢸⢰⣉⣱ ⢹⠁⢸ ⢰⣉⣱⢸⣉⡱⢸ ⢸⣉⡉ ⢐ [ Today ] ⠘⠤⠜⠠⠤⠜⠸ ⠸⠘⠤⠜⠸⠤⠤ ⠘⠤⠜⠸ ⠸⠸ ⠸⠈⠢⠊⠸ ⠸ ⠼⠄⠸⠤⠤⠸ ⠸⠸⠤⠜⠸⠤⠤⠸⠤⠤ ⠐ Oct 9 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⢸ ⢸⠰⣉⡉⢰⣉⣱⢰⢉⣑⢸⣉⡉ ⢸ ⢸⢸⠢⣸⢰⣉⣱⢸ ⢸⢰⣉⣱ ⢹⠁⢸ ⢰⣉⣱⢸⣉⡱⢸ ⢸⣉⡉ ⢐ [ Today ] ⠘⠤⠜⠠⠤⠜⠸ ⠸⠘⠤⠜⠸⠤⠤ ⠘⠤⠜⠸ ⠸⠸ ⠸⠈⠢⠊⠸ ⠸ ⠼⠄⠸⠤⠤⠸ ⠸⠸⠤⠜⠸⠤⠤⠸⠤⠤ ⠐ Oct 9
README

claude-usage-meter

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.

Terminal

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.

The terminal board

Desktop app

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 desktop strip and the Details pane

The demos are drawn by the mod's own code from invented figures. The real app's fonts and spacing differ a little.

What it shows

  • Spend. Total and per model, biggest first, for Today, This week, This month or All time. "This week" starts at your last weekly-limit reset. Prices are ccusage API-equivalent estimates, not what you are billed. They include every agent ccusage reports on.
  • Weekly limit. The fill is what you have used. The faint extension is where you land if the rest of the week runs at the pace so far. The notch is where the calendar says you should be. The caption says it in words: 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.
  • Motion. The terminal crawl never stops. It drifts when you are idle and speeds up with your spending pace. The desktop strip moves at one constant speed.

Requirements

  • Claude Code 2.1.287 or later, with hooks modules enabled for your account (see Caveats).
  • ccusage on your PATH.
  • A plan that reports a seven-day limit. Without one, the gauge and the ring stay hidden.
  • Terminal: 24-bit color and braille characters. It was built against Ghostty on macOS.

Install

  1. Install ccusage.
   npm install -g ccusage
  1. Add the marketplace.
   claude plugin marketplace add aycandv/claude-usage-meter
  1. Install the plugin.
   claude plugin install usage-ticker@claude-usage-meter
  1. Load the plugin. In the terminal, run /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.

How it works

  • Data. One 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.
  • Limits. The engine pushes the seven-day window to the mod when it moves, so nothing is polled.
  • Terminal. The board is one two-row 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.
  • Desktop. The app has no 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.

Caveats

  • Hooks modules sit behind a rollout flag, 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.
  • Only the seven-day window is drawn. Other limit kinds, such as a gateway spend limit, are ignored.
  • The desktop strip has no hover tooltips. The Details pane has the full figures.
  • The desktop layout was checked in the real app on macOS only. Other platforms are untested.

Development

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.

License

MIT. See LICENSE.

Source 20 files
hooks/register.tsx 566 lines
1import 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}
566
hooks/base64.ts 33 lines
1import 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))
33
hooks/board.ts 91 lines
1import { 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}
91
hooks/cards.ts 115 lines
1import 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' }
115
hooks/desktop-state.ts 242 lines
1// 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}`
242
hooks/gauge.ts 114 lines
1import 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}
114
hooks/led.ts 125 lines
1import { 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}
125
hooks/pace.ts 46 lines
1// 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}
46
hooks/desktop-theme.ts 53 lines
1// 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)"/>`
53
hooks/pane-view.ts 174 lines
1// 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])
174
hooks/periods.ts 93 lines
1import { 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}
93
hooks/scroll.ts 25 lines
1export 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