Shows your plan limits (5-hour and weekly), context window usage and session cost at API prices in a band above the prompt, with pace warnings, a details pane…

A Claude Code plugin marketplace for my mods, tools and skills. Each top-level folder is one plugin that can be installed and switched on or off by itself.
Add the marketplace once per device:
claude plugin marketplace add Pimzino/pimzinos-cool-cc-mods
Then install what you want:
claude plugin install usage-meter@pimzinos-cool-cc-mods
Mods need Claude Code v2.1.287 or later. On earlier builds, set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the env block of ~/.claude/settings.json.
| Plugin | What it does | | :- | :- | | usage-meter | Shows your plan limits (5-hour and weekly), context window usage and session cost at API prices in a band above the prompt, with pace warnings, a details pane and cost history |
Each meter starts with an icon: a clock for the 5-hour limit, a calendar for the weekly limit, a coin for the spend limit, stacked layers for the context window, a cylinder for the prompt cache, and a receipt for the session's cost at API prices.
In the desktop app the figures are drawn in Claude's own typeface, read from the Claude app installed at /Applications/Claude.app; without it they use the system font.
.claude-plugin/plugin.json and the plugin's files..claude-plugin/marketplace.json.claude plugin validate . and push.hooks/register.js 936 lines1import { parseFont } from './font.js'
2import { ADDED_HEX, ALERT_HEX, ICONS, WEIGHT_NORMAL, WEIGHT_STRONG, WRITTEN_HEX, bar, clamp, colorFor, detailIcon, icon, iconFor, setFont, svg, text } from './draw.js'
3import { heading, settingLabel, usageCards, usageMarkdown } from './panes.js'
4
5// Where the meters are drawn. 'AbovePrompt' is the band directly above the prompt box,
6// the site the Desktop app is known to draw. It does not draw 'PromptHint', the line under it.
7const SITE = 'AbovePrompt'
8
9// How often to refresh the figures and the reset countdowns, in milliseconds
10const REFRESH_MS = 60_000
11
12// The side panes: the full picture, and the settings
13const USAGE_PANE = 'usage-meter'
14const SETTINGS_PANE = 'usage-meter-settings'
15
16// The repository is read at most this often
17const REPO_REFRESH_MS = 10_000
18
19// The usage pane is redrawn at most this often, however fast the figures move, so it holds
20// still while a reply is being written
21const PANE_REFRESH_MS = 5_000
22
23// How long a limit's readings are kept to work out how fast it is filling, and the least
24// time and rise there must be before a projection is trusted
25const PACE_WINDOW_MS = 30 * 60_000
26const PACE_MIN_MS = 5 * 60_000
27const PACE_MIN_RISE = 2
28
29// The context meter says so when the window is within this many points of auto-compaction
30const COMPACT_NEAR = 10
31
32// Days of cost history kept between sessions
33const HISTORY_DAYS = 90
34
35// How much of each meter is drawn, from roomiest to tightest: the bar's width in pixels
36// (0 for no bar) and whether the reset time shows. The band uses the first that fits its width.
37const LAYOUTS = [
38 { bar: 56, hasReset: true },
39 { bar: 28, hasReset: true },
40 { bar: 28, hasReset: false },
41 { bar: 0, hasReset: false },
42]
43
44// The Desktop app reports widths in character cells of about this many pixels,
45// and draws one cell of gap as this many
46const CELL_PX = 8
47
48// Where a tooltip sits against its element, in rows: negative is above it
49const TIP_ROWS = -1
50
51// How far the divider reaches past each end of the repository row, in pixels
52const DIVIDER_OVERHANG = 28
53
54// Cells between two meters, and the cells the band's two buttons take
55const GAP = 3
56const BUTTON_CELLS = 10
57
58// The band's buttons, each a single character: the usage pane's and the settings pane's.
59// The second ends with a mark that keeps the cog a plain character, not an emoji.
60const USAGE_GLYPH = '☰'
61const SETTINGS_GLYPH = '⚙\uFE0E'
62
63// Where the Desktop app keeps its own typeface, Anthropic Sans. The mod reads the file from
64// the app installed on this computer and draws text with its letter shapes, so the
65// font is neither shipped with the mod nor installed.
66const FONT_FOLDERS = ['/Applications/Claude.app/Contents/Resources/fonts']
67const FONT_FILE = /^AnthropicSans-Romans?-.*\.ttf$/
68
69// The settings, in the order the settings pane lists them: its group, name, what it does,
70// and its value until the person changes it
71const SETTINGS = [
72 { key: 'showFiveHour', group: 'Meters', name: '5-hour limit', description: 'Show the 5-hour plan limit', initial: true },
73 { key: 'showWeekly', group: 'Meters', name: 'Weekly limit', description: 'Show the weekly plan limit', initial: true },
74 { key: 'showSpendLimit', group: 'Meters', name: 'Spend limit', description: 'Show the spend limit, where the account has one', initial: true },
75 { key: 'showContext', group: 'Meters', name: 'Context window', description: 'Show how full the context window is', initial: true },
76 { key: 'showCache', group: 'Meters', name: 'Prompt cache', description: "Show how much of the session's input was read from cache", initial: true },
77 { key: 'showCost', group: 'Meters', name: 'Session cost', description: 'Show what the session would have cost at API prices', initial: true },
78 { key: 'showTurnCost', group: 'Meters', name: "Latest turn's cost", description: 'Show the latest turn beside the session cost', initial: true },
79 { key: 'showRepo', group: 'Repository', name: 'Repository row', description: 'Show the repository, branch and changes under the meters', initial: true },
80 { key: 'showVersion', group: 'Footer', name: 'Claude Code version', description: 'Show the bundled version under the prompt', initial: true },
81 { key: 'showSessionAge', group: 'Footer', name: 'Session age', description: 'Show how long the session has been running', initial: true },
82 { key: 'notifyOnReset', group: 'Notices', name: 'Limit reset', description: 'Say so when a plan limit starts over', initial: true },
83 { key: 'notifyOnWarn', group: 'Notices', name: 'Limit warning', description: 'Say so when a plan limit reaches the warning level', initial: true },
84]
85// The warning levels on offer: every 5% up to 100%, with 90% used until one is picked
86const WARN_LEVELS = Array.from({ length: 20 }, (_, i) => (i + 1) * 5)
87const WARN_INITIAL = 90
88
89// The setting that shows or hides each meter
90const SHOW = { five_hour: 'showFiveHour', seven_day: 'showWeekly', spend_limit: 'showSpendLimit', context: 'showContext', cache: 'showCache', cost: 'showCost' }
91
92// The latest figures from $.session.usage(), shared by the hooks below
93let usage = null
94
95// The person's settings, kept in the store between sessions
96let settings = {}
97
98// The Claude Code version this session runs on, shown in the footer under the prompt
99let version = ''
100
101// The context window by category and the point where auto-compaction runs, read after each turn
102let breakdown = null
103
104// Each plan limit's last reading, to notice one crossing the warning level or resetting
105const lastLimit = new Map()
106
107// Each plan limit's recent readings, as { at, percent }, to work out how fast it is filling
108const readings = new Map()
109
110// The session's cost when the running turn began, and what each finished turn cost
111let turnStartCost = null
112const turnCosts = []
113
114// The session's tokens by kind, summed over every finished turn, and the latest turn's alone:
115// input read from the prompt cache, written to it, sent uncached, and the output
116let tokens = { read: 0, written: 0, fresh: 0, output: 0 }
117let lastTurnTokens = null
118let tokensKey = ''
119
120// Cost history: this session's spend by day, as kept in the store, and every session's by day
121let historyKey = ''
122let mySpend = {}
123let recordedCost = null
124let spendByDay = {}
125let sessionCount = 0
126
127// The git repository the session is working in, or null when it is not in one
128let repo = null
129let repoReadAt = 0
130let sessionFolder = ''
131
132// What the band last showed, and the usage pane's last drawing with when it was made
133let shownBand = ''
134let paneDrawing = null
135let paneTimer = null
136
137const money = (usd) => '$' + usd.toFixed(2)
138
139// "1.2M", "80.4K" or "912" tokens
140const count = (n) => (n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : String(Math.round(n)))
141
142// The share of the input that was read from the prompt cache, as a percent, or null with no input yet
143const cacheShare = (t) => (t && t.read + t.written + t.fresh > 0 ? (t.read / (t.read + t.written + t.fresh)) * 100 : null)
144const isOn = (key) => settings[key] ?? SETTINGS.find((s) => s.key === key)?.initial ?? true
145const warnAt = () => (WARN_LEVELS.includes(settings.warnAt) ? settings.warnAt : WARN_INITIAL)
146
147// "2h 14m" for a span under a day, "3d 4h" for a longer one
148function formatSpan(ms) {
149 const minutes = Math.max(0, Math.round(ms / 60_000))
150 if (minutes < 60) return minutes + 'm'
151 if (minutes < 24 * 60) return Math.floor(minutes / 60) + 'h ' + (minutes % 60) + 'm'
152 return Math.floor(minutes / 1440) + 'd ' + Math.floor((minutes % 1440) / 60) + 'h'
153}
154
155// "2h 14m" for a reset later today, "Thu 09:00" for one further out
156function formatReset(resetsAt, now) {
157 const at = Date.parse(resetsAt)
158 if (Number.isNaN(at)) return ''
159 if (at - now <= 0) return 'now'
160 if (at - now < 86_400_000) return formatSpan(at - now)
161 const date = new Date(at)
162 const day = date.toLocaleDateString(undefined, { weekday: 'short' })
163 const time = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
164 return day + ' ' + time
165}
166
167// "Friday 9 Oct, 17:28", for the notices, the tooltip and the pane
168function formatResetLong(resetsAt) {
169 const date = new Date(resetsAt)
170 if (Number.isNaN(date.getTime())) return ''
171 const day = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'short' })
172 const time = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
173 return day + ', ' + time
174}
175
176// "2026-10-04" in this computer's time zone, the key a day's spend is kept under
177function dayKey(ms) {
178 const date = new Date(ms)
179 const two = (n) => String(n).padStart(2, '0')
180 return date.getFullYear() + '-' + two(date.getMonth() + 1) + '-' + two(date.getDate())
181}
182
183// Finds the Desktop app's typeface on this computer and reads it. Without it, text is
184// drawn in the system font.
185async function loadFont($) {
186 for (const folder of FONT_FOLDERS) {
187 try {
188 const file = (await $.fs.list(folder)).find((entry) => FONT_FILE.test(entry.name))
189 if (!file) continue
190 const { base64 } = await $.fs.read(folder + '/' + file.name, { as: 'bytes' })
191 const parsed = parseFont(Uint8Array.fromBase64(base64))
192 if (parsed) return setFont(parsed)
193 } catch {
194 // No Desktop app in this folder
195 }
196 }
197}
198
199// A warning stays in every layout; a reset time or other detail goes when room is short
200const showsDetail = (m, layout) => Boolean(m.detail) && (layout.hasReset || m.detail.kind === 'alert')
201
202// One whole meter for the Desktop app: icon, rounded bar, figure and detail, as far
203// as the layout allows. A meter with no percent, the session cost, has no bar.
204function meterSvg(m, layout) {
205 const hasBar = typeof m.percent === 'number' && layout.bar > 0
206 let x = 20
207 let body = icon(m.key, 0, 0)
208 if (hasBar) {
209 body += bar(x, 5, layout.bar, 6, m.percent, colorFor(m.percent).hex)
210 x += layout.bar + 7
211 }
212 // With no bar to carry the color, the figure does
213 const tone = typeof m.percent === 'number' && !hasBar ? colorFor(m.percent).hex : undefined
214 const figure = text(m.figure, x, 12.25, { weight: WEIGHT_STRONG, color: tone })
215 body += figure.markup
216 x += figure.width
217 // A meter of flows, the prompt cache: an arrow and a figure for each direction
218 for (const [i, flow] of (m.flows ?? []).entries()) {
219 if (i > 0) x += 8
220 body += detailIcon(flow.kind, x, 3.25, flow.hex)
221 const amount = text(flow.text, x + 12, 12.25, { weight: WEIGHT_STRONG })
222 body += amount.markup
223 x += 12 + amount.width
224 }
225 if (showsDetail(m, layout)) {
226 x += 7
227 if (m.detail.kind !== 'plain') {
228 body += detailIcon(m.detail.kind, x, 3.25)
229 x += 13
230 }
231 const detail = text(m.detail.text, x, 12.25, { weight: WEIGHT_NORMAL, tone: 'dim', color: m.detail.kind === 'alert' ? ALERT_HEX : undefined })
232 body += detail.markup
233 x += detail.width
234 }
235 return svg(Math.ceil(x + 1), 16, body)
236}
237
238// The repository row's pieces, most important first: each is a list of icons and texts,
239// and the row drops pieces from the end until it fits
240function repoPieces(r) {
241 const pieces = [
242 [{ icon: 'repo' }, { text: r.name, weight: WEIGHT_STRONG }],
243 [{ icon: 'branch' }, { text: r.branch, tone: 'dim' }],
244 [{ text: '+' + r.added.toLocaleString(), weight: WEIGHT_STRONG, color: ADDED_HEX }, { text: '−' + r.removed.toLocaleString(), weight: WEIGHT_STRONG, color: ALERT_HEX }],
245 [{ text: r.files + (r.files === 1 ? ' file' : ' files') + (r.untracked ? ', ' + r.untracked + ' new' : ''), tone: 'dim' }],
246 ]
247 const distance = [r.ahead ? r.ahead + ' ahead' : '', r.behind ? r.behind + ' behind' : ''].filter(Boolean).join(', ')
248 if (distance) pieces.push([{ text: distance, tone: 'dim' }])
249 if (r.folder) pieces.push([{ icon: 'folder' }, { text: r.folder, tone: 'dim' }])
250 return pieces
251}
252
253// What the repository row's tooltip says
254function repoHint(r) {
255 const distance = r.ahead === null ? '' : r.ahead || r.behind ? ', ' + [r.ahead ? r.ahead + ' ahead' : '', r.behind ? r.behind + ' behind' : ''].filter(Boolean).join(' and ') + ' of upstream' : ', up to date with upstream'
256 const commit = r.commit ? '. Last commit ' + r.commit.hash + ', ' + r.commit.when : ''
257 return r.files + (r.files === 1 ? ' file' : ' files') + ' changed, +' + r.added + ' −' + r.removed + (r.untracked ? ', ' + r.untracked + ' new' : '') + distance + commit
258}
259
260// The repository row for the Desktop app: as many pieces as fit in `room` pixels, under a
261// faint line that divides it from the meters. The line reaches a little past the row's
262// own ends, and never past the meters above, which are `span` pixels across.
263function repoSvg(r, room, span) {
264 const gap = 18
265 const sized = repoPieces(r).map((piece) => ({
266 piece,
267 width: piece.reduce((sum, part) => sum + (part.icon ? 20 : text(part.text, 0, 0, part).width + 6), -6),
268 }))
269 while (sized.length > 1 && sized.reduce((sum, p) => sum + p.width + gap, -gap) > room) sized.pop()
270 let x = 0
271 let body = ''
272 for (const { piece } of sized) {
273 for (const part of piece) {
274 if (part.icon) {
275 body += icon(part.icon, x, 0)
276 x += 20
277 } else {
278 const drawn = text(part.text, x, 12.25, part)
279 body += drawn.markup
280 x += drawn.width + 6
281 }
282 }
283 x += gap - 6
284 }
285 const content = Math.ceil(x - gap + 1)
286 const width = Math.ceil(Math.min(room, Math.max(content, span)))
287 const reach = Math.min(width, Math.max(content, Math.min(content + 2 * DIVIDER_OVERHANG, span * 0.8)))
288 const line = `<rect class="track" x="${((width - reach) / 2).toFixed(2)}" y="4" width="${reach.toFixed(2)}" height="1" fill="#888888" fill-opacity="0.3"/>`
289 return svg(width, 27, line + `<g transform="translate(${Math.max(0, (width - content) / 2).toFixed(2)} 11)">${body}</g>`)
290}
291
292// The same bar as characters, for the terminal: one cell for every 7 pixels of the drawn bar
293function barText(percent, layout) {
294 const cells = layout.bar / 7
295 const filled = Math.round((clamp(percent) / 100) * cells)
296 return '▰'.repeat(filled) + '▱'.repeat(cells - filled)
297}
298
299// When a limit will be full at the pace of its recent readings, in milliseconds from now,
300// or null when it is not rising or there is too little to go on
301function fullIn(kind, now) {
302 const list = readings.get(kind) ?? []
303 if (list.length < 2) return null
304 const first = list[0]
305 const last = list.at(-1)
306 const rise = last.percent - first.percent
307 if (last.at - first.at < PACE_MIN_MS || rise < PACE_MIN_RISE || last.percent >= 100) return null
308 const perMs = rise / (last.at - first.at)
309 return (100 - last.percent) / perMs - (now - last.at)
310}
311
312// Whether a limit runs out before it resets at its current pace, and how long it has
313function paceOf(limit, now) {
314 const left = fullIn(limit.kind, now)
315 const resetIn = limit.resetsAt ? Date.parse(limit.resetsAt) - now : Infinity
316 return { left, runsOut: left !== null && left < resetIn }
317}
318
319// The tokens where auto-compaction runs, as a percent of the window, or null when it is off or unknown
320function compactAt() {
321 const threshold = breakdown?.isAutoCompactEnabled ? breakdown.autoCompactThreshold : undefined
322 const window = usage?.context?.window
323 return typeof threshold === 'number' && window > 0 ? Math.round((threshold / window) * 100) : null
324}
325
326// The tokens the cache figures are worked out from: the session's once a turn has finished,
327// and until then the latest request's, so the meter has something to show from the start
328function cacheTokens() {
329 if (cacheShare(tokens) !== null) return { ...tokens, isSession: true }
330 const u = breakdown?.apiUsage
331 return u ? { read: u.cache_read_input_tokens, written: u.cache_creation_input_tokens, fresh: u.input_tokens, output: u.output_tokens, isSession: false } : null
332}
333
334// What the running turn has cost so far
335function turnCost() {
336 const usd = usage?.cost?.usd
337 return typeof usd === 'number' && turnStartCost !== null ? Math.max(0, usd - turnStartCost) : 0
338}
339
340// What was spent over the last `days` days, today included
341function spentOver(days, now) {
342 const from = dayKey(now - (days - 1) * 86_400_000)
343 return Object.entries(spendByDay).reduce((sum, [day, usd]) => (day >= from ? sum + usd : sum), 0)
344}
345
346// The meters to show: each plan limit, the context window, then the session's cost.
347// `figure` and `detail` are drawn in the band, `name` and `hint` make up the meter's tooltip.
348function meters(now) {
349 if (!usage) return []
350 const list = usage.rateLimits.map((limit) => {
351 const percent = Math.round(limit.percentUsed)
352 const resets = limit.resetsAt ? formatResetLong(limit.resetsAt) : ''
353 const { left, runsOut } = paceOf(limit, now)
354 return {
355 key: limit.kind,
356 name: iconFor(limit.kind).name,
357 percent: limit.percentUsed,
358 figure: percent + '%',
359 detail: runsOut
360 ? { kind: 'alert', text: 'full in ~' + formatSpan(left) }
361 : limit.resetsAt
362 ? { kind: 'reset', text: formatReset(limit.resetsAt, now) }
363 : null,
364 hint: percent + '% used' + (resets ? ', resets ' + resets : '') + (runsOut ? '. At the current pace it is full in about ' + formatSpan(left) : ''),
365 }
366 })
367 if (typeof usage.context?.percent === 'number') {
368 const percent = Math.round(usage.context.percent)
369 const compacts = compactAt()
370 const isNear = compacts !== null && percent >= compacts - COMPACT_NEAR
371 list.push({
372 key: 'context',
373 name: ICONS.context.name,
374 percent: usage.context.percent,
375 figure: percent + '%',
376 detail: isNear ? { kind: 'alert', text: 'compacts at ' + compacts + '%' } : null,
377 hint: percent + '% full' + (compacts !== null ? ', summarised at about ' + compacts + '%' : ''),
378 })
379 }
380 // The prompt cache: tokens read out of it, which are billed at a fraction of the price,
381 // and tokens written into it, each behind its own arrow
382 const cached = cacheTokens()
383 const share = cacheShare(cached)
384 if (share !== null) {
385 const span = cached.isSession ? 'this session' : 'in the latest request'
386 list.push({
387 key: 'cache',
388 name: ICONS.cache.name,
389 figure: '',
390 flows: [
391 { kind: 'down', glyph: '↓', text: count(cached.read), hex: ADDED_HEX, color: 'green' },
392 { kind: 'up', glyph: '↑', text: count(cached.written), hex: WRITTEN_HEX, color: 'yellow' },
393 ],
394 detail: null,
395 hint: count(cached.read) + ' tokens read from cache and ' + count(cached.written) + ' written ' + span + ', ' + Math.round(share) + '% of input served from cache',
396 })
397 }
398 // What the session's requests add up to at API prices, as /cost totals it
399 if (typeof usage.cost?.usd === 'number') {
400 const turn = turnCost()
401 list.push({
402 key: 'cost',
403 name: ICONS.cost.name,
404 figure: money(usage.cost.usd),
405 detail: isOn('showTurnCost') && turn >= 0.005 ? { kind: 'plain', text: '+' + turn.toFixed(2) } : null,
406 hint: 'what this session would cost at API prices' + (turn >= 0.005 ? ', ' + money(turn) + ' in the latest turn' : ''),
407 })
408 }
409 return list.filter((m) => isOn(SHOW[m.key] ?? 'showSpendLimit'))
410}
411
412// Everything the usage pane shows, as plain data for its drawing
413function usageModel(now) {
414 const limits = (usage?.rateLimits ?? []).map((limit) => {
415 const { left, runsOut } = paceOf(limit, now)
416 return {
417 key: limit.kind,
418 name: iconFor(limit.kind).name,
419 percent: limit.percentUsed,
420 resetLong: limit.resetsAt ? formatResetLong(limit.resetsAt) : null,
421 resetShort: limit.resetsAt ? formatSpan(Date.parse(limit.resetsAt) - now) : null,
422 pace: runsOut
423 ? { kind: 'alert', text: 'Full in about ' + formatSpan(left) + ' at the current pace' }
424 : left !== null
425 ? { kind: 'fine', text: 'At the current pace it lasts until it resets' }
426 : null,
427 }
428 })
429
430 let context = null
431 if (typeof usage?.context?.percent === 'number') {
432 const window = breakdown?.rawMaxTokens || usage.context.window
433 context = {
434 percent: usage.context.percent,
435 tokens: usage.context.tokens,
436 window: usage.context.window,
437 compactAt: compactAt(),
438 categories: (breakdown?.categories ?? [])
439 .filter((c) => c.kind === 'used' && c.tokens > 0)
440 .sort((a, b) => b.tokens - a.tokens)
441 .slice(0, 8)
442 .map((c) => ({ name: c.name, tokens: c.tokens, share: (c.tokens / window) * 100 })),
443 }
444 }
445
446 let cost = null
447 if (typeof usage?.cost?.usd === 'number') {
448 const days = []
449 for (let back = 6; back >= 0; back--) {
450 const at = now - back * 86_400_000
451 days.push({ label: new Date(at).toLocaleDateString(undefined, { weekday: 'short' }), usd: spendByDay[dayKey(at)] ?? 0, isToday: back === 0 })
452 }
453 const turn = turnCost()
454 cost = {
455 session: usage.cost.usd,
456 turn,
457 today: spentOver(1, now),
458 week: spentOver(7, now),
459 month: spentOver(30, now),
460 days,
461 turns: [...turnCosts, ...(turn >= 0.005 ? [turn] : [])].slice(-24),
462 }
463 }
464
465 // The whole record: every day of the last HISTORY_DAYS, oldest first
466 let history = null
467 if (cost) {
468 const days = []
469 for (let back = HISTORY_DAYS - 1; back >= 0; back--) {
470 const at = now - back * 86_400_000
471 const date = new Date(at)
472 days.push({
473 usd: spendByDay[dayKey(at)] ?? 0,
474 // The month's name under its first day, to mark the chart
475 month: date.getDate() === 1 || back === HISTORY_DAYS - 1 ? date.toLocaleDateString(undefined, { month: 'short' }) : '',
476 label: date.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' }),
477 })
478 }
479 const spent = days.filter((d) => d.usd > 0)
480 const total = spent.reduce((sum, d) => sum + d.usd, 0)
481 const busiest = spent.reduce((top, d) => (d.usd > (top?.usd ?? 0) ? d : top), null)
482 history = {
483 days,
484 total,
485 activeDays: spent.length,
486 average: spent.length ? total / spent.length : 0,
487 busiest,
488 sessions: sessionCount,
489 recent: spent.slice(-7).reverse(),
490 note: 'Counts sessions where this mod was running.',
491 }
492 }
493
494 const cached = cacheTokens()
495 const share = cacheShare(cached)
496 const cache =
497 share === null
498 ? null
499 : {
500 share,
501 read: count(cached.read),
502 written: count(cached.written),
503 fresh: count(cached.fresh),
504 output: count(cached.output),
505 lastShare: cacheShare(lastTurnTokens),
506 span: cached.isSession ? 'Tokens this session.' : 'Tokens in the latest request; the session total starts with the next reply.',
507 }
508
509 const session = []
510 if (usage?.startedAt) session.push(['Running for', formatSpan(now - usage.startedAt)])
511 if (version) session.push(['Bundled Claude Code', version])
512 if (breakdown?.model) session.push(['Model', breakdown.model])
513 return { limits, context, cache, cost, history, session }
514}
515
516// Reads every session's spend by day from the store, and drops sessions past HISTORY_DAYS
517async function loadHistory($, now) {
518 const oldest = dayKey(now - HISTORY_DAYS * 86_400_000)
519 const total = {}
520 let sessions = 0
521 for (const key of await $.store.keys()) {
522 // Token counts of sessions past the same age go too
523 if (key.startsWith('tokens:') && key !== tokensKey) {
524 const kept = await $.store.get(key)
525 if (!kept?.day || kept.day < oldest) await $.store.delete(key)
526 continue
527 }
528 if (!key.startsWith('spend:')) continue
529 const days = (await $.store.get(key)) ?? {}
530 const latest = Object.keys(days).sort().at(-1)
531 if (key !== historyKey && (!latest || latest < oldest)) {
532 await $.store.delete(key)
533 continue
534 }
535 if (key === historyKey) mySpend = { ...days }
536 sessions++
537 for (const [day, usd] of Object.entries(days)) total[day] = (total[day] ?? 0) + usd
538 }
539 spendByDay = total
540 // This session counts even before its first spend is recorded
541 sessionCount = sessions + (mySpend && Object.keys(mySpend).length ? 0 : 1)
542}
543
544// Adds what the session has spent since the last reading to today's total. Each session
545// writes only its own entry in the store, so two sessions never overwrite each other.
546async function recordSpend($, now) {
547 const usd = usage?.cost?.usd
548 if (typeof usd !== 'number' || !historyKey) return
549 // The first reading after a load continues from what this session already recorded
550 if (recordedCost === null) recordedCost = Object.values(mySpend).reduce((sum, n) => sum + n, 0)
551 // A total that went down means the ledger started over
552 const spent = usd >= recordedCost ? usd - recordedCost : usd
553 recordedCost = usd
554 if (spent <= 0) return
555 const day = dayKey(now)
556 mySpend[day] = (mySpend[day] ?? 0) + spent
557 spendByDay[day] = (spendByDay[day] ?? 0) + spent
558 await $.store.set(historyKey, mySpend)
559}
560
561// Keeps the latest figures, raises the notices they call for, and redraws
562async function take($, figures) {
563 const now = await $.clock.now()
564 usage = figures
565 await readRepo($, now)
566 for (const limit of usage.rateLimits) {
567 const before = lastLimit.get(limit.kind)
568 const name = iconFor(limit.kind).name
569 if (before) {
570 // A later reset time and a lower reading: the window rolled over
571 const hasReset = limit.resetsAt && before.resetsAt && Date.parse(limit.resetsAt) > Date.parse(before.resetsAt) && limit.percentUsed < before.percent
572 if (hasReset) {
573 readings.delete(limit.kind)
574 if (isOn('notifyOnReset') && before.percent >= 25) $.ui.toast(name + ' has reset', { timeoutMs: 8000 })
575 } else if (isOn('notifyOnWarn') && before.percent < warnAt() && limit.percentUsed >= warnAt()) {
576 const resets = limit.resetsAt ? '. It resets ' + formatResetLong(limit.resetsAt) : ''
577 $.ui.toast(name + ' is at ' + Math.round(limit.percentUsed) + '%' + resets, { timeoutMs: 8000 })
578 }
579 }
580 lastLimit.set(limit.kind, { percent: limit.percentUsed, resetsAt: limit.resetsAt })
581 const list = (readings.get(limit.kind) ?? []).filter((r) => now - r.at <= PACE_WINDOW_MS)
582 list.push({ at: now, percent: limit.percentUsed })
583 readings.set(limit.kind, list)
584 }
585 await recordSpend($, now)
586 showStatus($, now)
587 redraw($, now)
588}
589
590// Asks for a redraw only when what is shown has changed: at once for the band, and for
591// the usage pane no sooner than PANE_REFRESH_MS after its last drawing
592function redraw($, now) {
593 const band = JSON.stringify([meters(now), isOn('showRepo') ? repo : null])
594 if (band !== shownBand) {
595 shownBand = band
596 $.ui.invalidate('ui.render')
597 }
598 if (paneTimer || !paneDrawing) return
599 if (paneDrawing.shows === JSON.stringify(usageModel(now))) return
600 paneTimer = $.clock.after(Math.max(0, paneDrawing.at + PANE_REFRESH_MS - now), () => {
601 paneTimer = null
602 paneDrawing = null
603 $.ui.invalidate('ui.render')
604 })
605}
606
607// The footer under the prompt: the Claude Code version and how long the session has run.
608// The Desktop app puts the plugin's name in front of it, and draws no other footer slot.
609function footerText(now) {
610 const parts = []
611 if (version && isOn('showVersion')) parts.push('Bundled CC ' + version)
612 if (usage?.startedAt && isOn('showSessionAge')) parts.push('session ' + formatSpan(now - usage.startedAt))
613 return parts.join(' · ')
614}
615
616function showStatus($, now) {
617 $.ui.status(footerText(now) || undefined)
618}
619
620// Read the figures now, and redraw
621async function refresh($) {
622 await take($, await $.session.usage())
623}
624
625// Reads the context window by category. Estimated on this computer, so it sends no request.
626async function readBreakdown($) {
627 try {
628 breakdown = (await $.session.usage({ breakdown: 'summary' })).context.breakdown ?? null
629 } catch {
630 breakdown = null
631 }
632}
633
634// Runs git in the session's folder and answers what it printed, or null when it failed
635async function git($, ...args) {
636 try {
637 const ran = await $.process.run(['git', ...args], { timeoutMs: 5000 })
638 return ran.exitCode === 0 ? ran.stdout.trim() : null
639 } catch {
640 return null
641 }
642}
643
644// Reads the repository the session is in: its name, branch, uncommitted changes, how far
645// it is from its upstream, and its last commit. Leaves `repo` null outside a repository.
646async function readRepo($, now) {
647 if (now - repoReadAt < REPO_REFRESH_MS) return
648 repoReadAt = now
649 const top = await git($, 'rev-parse', '--show-toplevel')
650 if (top === null) {
651 repo = null
652 return
653 }
654 const [branch, remote, numstat, status, distance, last] = await Promise.all([
655 git($, 'branch', '--show-current'),
656 git($, 'remote', 'get-url', 'origin'),
657 git($, 'diff', '--numstat', 'HEAD'),
658 git($, 'status', '--porcelain'),
659 git($, 'rev-list', '--left-right', '--count', '@{upstream}...HEAD'),
660 git($, 'log', '-1', '--format=%h%x09%s%x09%cr'),
661 ])
662 let added = 0
663 let removed = 0
664 for (const line of (numstat ?? '').split('\n')) {
665 const [plus, minus] = line.split('\t')
666 added += Number(plus) || 0
667 removed += Number(minus) || 0
668 }
669 const changes = (status ?? '').split('\n').filter(Boolean)
670 const [behind, ahead] = distance ? distance.split(/\s+/).map(Number) : [null, null]
671 const [hash, subject, when] = (last ?? '').split('\t')
672 // "owner/name" from the remote's address, or the folder's own name without one
673 const named = remote?.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/)?.[1]
674 const inside = sessionFolder.startsWith(top) ? sessionFolder.slice(top.length).replace(/^\//, '') : ''
675 repo = {
676 name: named ?? top.split('/').at(-1),
677 branch: branch || 'detached',
678 root: top,
679 folder: inside,
680 added,
681 removed,
682 files: changes.filter((line) => !line.startsWith('??')).length,
683 untracked: changes.filter((line) => line.startsWith('??')).length,
684 ahead,
685 behind,
686 commit: hash ? { hash, subject, when } : null,
687 }
688}
689
690// Changes one setting, keeps it for later sessions, and redraws everything it touches
691async function setSetting($, key, value) {
692 settings = { ...settings, [key]: value }
693 await $.store.set('settings', settings)
694 showStatus($, await $.clock.now())
695 paneDrawing = null
696 $.ui.invalidate('ui.render')
697}
698
699async function openUsage($) {
700 await readBreakdown($)
701 repoReadAt = 0
702 await readRepo($, await $.clock.now())
703 paneDrawing = null
704 await $.ui.open({ id: USAGE_PANE, title: 'Details' })
705}
706
707export function register(on) {
708 // Runs before your first prompt, and again after a reload
709 on('session.start', async ($, e, next) => {
710 sessionFolder = e.cwd ?? ''
711 await loadFont($)
712 settings = (await $.store.get('settings')) ?? {}
713 // The release, such as 2.1.280, or the full version when it is not spelled as one
714 const engine = await $.session.version()
715 version = engine.base ?? engine.version
716
717 const first = await $.session.usage()
718 // One entry in the store for each session, named by when it began
719 historyKey = 'spend:' + first.startedAt
720 // The session's token counts are kept too, so a resumed session carries on from them
721 tokensKey = 'tokens:' + first.startedAt
722 const kept = await $.store.get(tokensKey)
723 if (kept) tokens = { read: kept.read ?? 0, written: kept.written ?? 0, fresh: kept.fresh ?? 0, output: kept.output ?? 0 }
724 await loadHistory($, await $.clock.now())
725 await take($, first)
726 await readBreakdown($)
727 // Keeps the reset countdowns current while the session is idle
728 $.clock.every(REFRESH_MS, () => refresh($))
729 return next(e)
730 })
731
732 // A new turn begins: close the books on the one before
733 on('prompt.submit', ($, e, next) => {
734 const usd = usage?.cost?.usd
735 if (typeof usd === 'number') {
736 if (turnStartCost !== null && usd - turnStartCost >= 0.005) turnCosts.push(usd - turnStartCost)
737 turnStartCost = usd
738 }
739 return next(e)
740 })
741
742 // A turn ended, the main one or a subagent's: add its tokens to the session's
743 on('turn.complete', async ($, e, next) => {
744 const u = e.usage
745 if (u) {
746 lastTurnTokens = { read: u.cache_read_input_tokens, written: u.cache_creation_input_tokens, fresh: u.input_tokens, output: u.output_tokens }
747 tokens = { read: tokens.read + lastTurnTokens.read, written: tokens.written + lastTurnTokens.written, fresh: tokens.fresh + lastTurnTokens.fresh, output: tokens.output + lastTurnTokens.output }
748 if (tokensKey) await $.store.set(tokensKey, { ...tokens, day: dayKey(await $.clock.now()) })
749 redraw($, await $.clock.now())
750 }
751 return next(e)
752 })
753
754 // Runs after each turn, and when a plan limit's percent used changes
755 on('session.measure', async ($, e, next) => {
756 await take($, { startedAt: usage?.startedAt, context: e.context, rateLimits: e.rateLimits, cost: e.cost })
757 if (e.changed.includes('context')) {
758 await readBreakdown($)
759 redraw($, await $.clock.now())
760 }
761 return next(e)
762 })
763
764 // The usage pane: cards drawn for the pane's width on the Desktop app, tables elsewhere
765 on('ui.render', { component: 'Pane', requestId: USAGE_PANE }, async ($, e) => {
766 const { Box, Markdown, Svg } = $.ui.resolve(e)
767 const now = await $.clock.now()
768 const model = usageModel(now)
769 if (e.surface !== 'desktop') return Markdown({ text: usageMarkdown(model) })
770 const width = Math.max(240, Math.min(720, (e.props.bodyColumns ?? 50) * CELL_PX - 16))
771 // The same cards again until redraw() says the figures have moved on, or the pane's
772 // width changes. Unchanged cards are then the very same pictures, which the app leaves alone.
773 if (!paneDrawing || paneDrawing.width !== width) {
774 paneDrawing = { at: now, width, shows: JSON.stringify(model), cards: usageCards(model, width) }
775 }
776 return Box({
777 flexDirection: 'column',
778 rowGap: 1,
779 children: paneDrawing.cards.map((card) => Svg({ source: card.source, alt: card.alt, width: card.width, height: card.height })),
780 })
781 })
782
783 // The settings pane: a switch for each setting, and the warning level
784 on('ui.render', { component: 'Pane', requestId: SETTINGS_PANE }, async ($, e) => {
785 const table = $.ui.resolve(e)
786 const { Box, Button, Select, Text } = table
787 const isDesktop = e.surface === 'desktop'
788 const width = Math.max(200, Math.min(560, (e.props.bodyColumns ?? 50) * CELL_PX - 16 - 9 * CELL_PX))
789 const drawn = (picture, alt) => table.Svg({ source: picture.source, alt, width: picture.width, height: picture.height })
790
791 const rows = []
792 let group = ''
793 for (const setting of SETTINGS) {
794 if (setting.group !== group) {
795 group = setting.group
796 rows.push(isDesktop ? drawn(heading(group, width), group) : Text({ bold: true, children: [group] }))
797 }
798 const isEnabled = isOn(setting.key)
799 rows.push(
800 Box({
801 flexDirection: 'row',
802 alignItems: 'center',
803 columnGap: 2,
804 children: [
805 Button({ key: 'toggle-' + setting.key, label: isEnabled ? 'On' : 'Off', ...(isEnabled ? { variant: 'primary' } : {}), onPress: () => setSetting($, setting.key, !isEnabled) }),
806 isDesktop
807 ? drawn(settingLabel(setting.name, setting.description, width), setting.name + ': ' + setting.description)
808 : Text({ children: [setting.name + ', ' + setting.description.toLowerCase()] }),
809 ],
810 }),
811 )
812 }
813 rows.push(
814 Box({
815 flexDirection: 'row',
816 alignItems: 'center',
817 columnGap: 2,
818 children: [
819 Select({
820 key: 'warn-at',
821 options: WARN_LEVELS.map((level) => ({ value: String(level), label: level + '%' })),
822 value: String(warnAt()),
823 onSelect: (value) => setSetting($, 'warnAt', Number(value)),
824 }),
825 isDesktop
826 ? drawn(settingLabel('Warning level', 'How full a plan limit is when the warning appears', width), 'Warning level')
827 : Text({ children: ['Warning level, how full a plan limit is when the warning appears'] }),
828 ],
829 }),
830 )
831 return Box({ flexDirection: 'column', rowGap: 1, children: rows })
832 })
833
834 on('ui.render', { component: SITE }, async ($, e, next) => {
835 // Claude Code is asking a survey question in the band, so leave it alone
836 if (e.props.hasSurvey) return next(e)
837 const { Box, Button, Text, Svg } = $.ui.resolve(e)
838 // Only the Desktop app can draw an Svg
839 const canDrawSvg = e.surface === 'desktop'
840 const theirs = await next(e)
841 const now = await $.clock.now()
842 const items = meters(now)
843
844 // Wraps an element so that pointing at it shows a tooltip: a card the app draws above it,
845 // in its own frame and typeface. The card holds one line of text, since the Desktop app
846 // draws no Svg inside it, and that app places the card itself, from the element's left edge.
847 const withTip = (key, element, label) =>
848 Box({
849 key,
850 position: 'relative',
851 children: [
852 element,
853 Box({
854 position: 'absolute',
855 top: TIP_ROWS,
856 left: 0,
857 display: 'none',
858 hover: { display: 'flex' },
859 // A long one runs onto a second line, since the app caps the card's width
860 children: [Text({ wrap: 'wrap', children: [label] })],
861 }),
862 ],
863 })
864
865 // One meter in the given layout, and how wide it is: pixels on the Desktop app, cells on the terminal
866 const meter = (m, layout) => {
867 if (canDrawSvg) {
868 const { source, width, height } = meterSvg(m, layout)
869 const picture = Svg({ source, alt: m.name + ': ' + m.hint, width, height })
870 return { width, element: withTip('meter-' + m.key, picture, m.name + ': ' + m.hint) }
871 }
872 const hasBar = typeof m.percent === 'number' && layout.bar > 0
873 const tone = typeof m.percent === 'number' ? colorFor(m.percent).name : undefined
874 const marks = { reset: '↻ ', alert: '⚠ ', plain: '' }
875 // Each part's style and text. With no bar to carry the color, the figure does.
876 const parts = [
877 [{ dimColor: true }, iconFor(m.key).glyph],
878 ...(hasBar ? [[{ color: tone }, barText(m.percent, layout)]] : []),
879 ...(m.figure ? [[{ bold: true, ...(tone && !hasBar ? { color: tone } : {}) }, m.figure]] : []),
880 ...(m.flows ?? []).map((flow) => [{ bold: true, color: flow.color }, flow.glyph + flow.text]),
881 ...(showsDetail(m, layout) ? [[m.detail.kind === 'alert' ? { color: 'red' } : { dimColor: true }, marks[m.detail.kind] + m.detail.text]] : []),
882 ]
883 const width = parts.reduce((sum, [, string]) => sum + string.length, 0) + parts.length - 1
884 const children = parts.map(([style, string]) => Text({ ...style, children: [string] }))
885 return { width, element: Box({ flexDirection: 'row', alignItems: 'center', columnGap: 1, children }) }
886 }
887
888 // The roomiest layout whose meters fit side by side in the band, beside the button; the
889 // tightest when none does, and then the meters wrap onto further rows
890 const unit = canDrawSvg ? CELL_PX : 1
891 const room = typeof e.props.bodyColumns === 'number' ? (e.props.bodyColumns - BUTTON_CELLS - GAP) * unit : Infinity
892 let drawn = []
893 for (const layout of LAYOUTS) {
894 drawn = items.map((m) => meter(m, layout))
895 const width = drawn.reduce((sum, d) => sum + d.width, 0) + GAP * unit * (drawn.length - 1)
896 if (width <= room) break
897 }
898 const elements = drawn.map((d) => d.element)
899 if (!usage?.rateLimits.length) elements.push(Text({ dimColor: true, children: ['plan limits appear after the first reply'] }))
900 // The usage pane and the settings pane, each behind a one-character button
901 elements.push(
902 Box({
903 flexDirection: 'row',
904 columnGap: 1,
905 children: [
906 withTip('tip-usage', Button({ key: 'open-usage', label: USAGE_GLYPH, onPress: () => openUsage($) }), 'Details'),
907 withTip('tip-settings', Button({ key: 'open-settings', label: SETTINGS_GLYPH, onPress: () => $.ui.open({ id: SETTINGS_PANE, title: 'Settings' }) }), 'Settings'),
908 ],
909 }),
910 )
911
912 const mine = Box({ flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'center', columnGap: GAP, children: elements })
913
914 // The repository row, under the meters, when the session is in a repository
915 const rows = [mine]
916 if (repo && isOn('showRepo')) {
917 const hint = repoHint(repo)
918 if (canDrawSvg) {
919 // The divider runs the width of the meters row above it
920 const metersWidth = drawn.reduce((sum, d) => sum + d.width, 0) + GAP * CELL_PX * drawn.length + BUTTON_CELLS * CELL_PX
921 const picture = repoSvg(repo, typeof e.props.bodyColumns === 'number' ? (e.props.bodyColumns - 4) * CELL_PX : 2000, metersWidth)
922 rows.push(withTip('tip-repo', Svg({ source: picture.source, alt: hint, width: picture.width, height: picture.height }), hint))
923 } else {
924 const words = repoPieces(repo).map((piece) => piece.map((part) => (part.icon ? iconFor(part.icon).glyph : part.text)).join(' '))
925 rows.push(Text({ dimColor: true, wrap: 'truncate', children: [words.join(' ')] }))
926 }
927 }
928
929 // What other mods draw here stays above, and the rows sit centred, closest to the prompt
930 return Box({
931 flexDirection: 'column',
932 children: [...(theirs ? [theirs] : []), ...rows.map((row) => Box({ flexDirection: 'row', justifyContent: 'center', width: '100%', children: [row] }))],
933 })
934 })
935}
936hooks/font.js 345 lines1// Reads a TrueType variable font: enough of it to turn a line of text into outlines at a
2// chosen weight. Returns null for a file it cannot use.
3export function parseFont(bytes) {
4 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
5 const u8 = (o) => view.getUint8(o)
6 const i8 = (o) => view.getInt8(o)
7 const u16 = (o) => view.getUint16(o)
8 const i16 = (o) => view.getInt16(o)
9 const u32 = (o) => view.getUint32(o)
10
11 const tables = {}
12 for (let i = 0; i < u16(4); i++) {
13 const record = 12 + i * 16
14 tables[String.fromCharCode(u8(record), u8(record + 1), u8(record + 2), u8(record + 3))] = u32(record + 8)
15 }
16 const { head, maxp, hhea, hmtx, loca, glyf, cmap, fvar, gvar } = tables
17 if ([head, maxp, hhea, hmtx, loca, glyf, cmap].includes(undefined)) return null
18
19 const unitsPerEm = u16(head + 18)
20 const hasLongOffsets = i16(head + 50) === 1
21 const glyphCount = u16(maxp + 4)
22 const metricCount = u16(hhea + 34)
23
24 // The table that maps characters to glyphs, in the segment format every font carries
25 let segments = null
26 for (let i = 0; i < u16(cmap + 2); i++) {
27 const subtable = cmap + u32(cmap + 4 + i * 8 + 4)
28 if (u16(subtable) === 4) segments = subtable
29 }
30 if (segments === null) return null
31 const segmentCount = u16(segments + 6) / 2
32
33 function glyphFor(code) {
34 const ends = segments + 14
35 const starts = ends + segmentCount * 2 + 2
36 const deltas = starts + segmentCount * 2
37 const offsets = deltas + segmentCount * 2
38 for (let i = 0; i < segmentCount; i++) {
39 if (code > u16(ends + i * 2)) continue
40 const start = u16(starts + i * 2)
41 if (code < start) return 0
42 const offset = u16(offsets + i * 2)
43 if (offset === 0) return (code + u16(deltas + i * 2)) & 0xffff
44 const id = u16(offsets + i * 2 + offset + (code - start) * 2)
45 return id === 0 ? 0 : (id + u16(deltas + i * 2)) & 0xffff
46 }
47 return 0
48 }
49
50 const glyphOffset = (id) => glyf + (hasLongOffsets ? u32(loca + id * 4) : u16(loca + id * 2) * 2)
51 const advanceOf = (id) => u16(hmtx + Math.min(id, metricCount - 1) * 4)
52
53 // A glyph's points as the font stores them, for its default weight.
54 // Null for a glyph assembled from other glyphs, which this reader does not draw.
55 function outline(id) {
56 const start = glyphOffset(id)
57 const xs = []
58 const ys = []
59 const isOnCurve = []
60 const ends = []
61 if (glyphOffset(id + 1) > start) {
62 const contourCount = i16(start)
63 if (contourCount < 0) return null
64 let at = start + 10
65 for (let i = 0; i < contourCount; i++, at += 2) ends.push(u16(at))
66 const pointCount = contourCount ? ends[contourCount - 1] + 1 : 0
67 at += 2 + u16(at)
68 const flags = []
69 while (flags.length < pointCount) {
70 const flag = u8(at++)
71 flags.push(flag)
72 if (flag & 8) for (let repeat = u8(at++); repeat > 0; repeat--) flags.push(flag)
73 }
74 // Each coordinate is a step from the one before, stored in one or two bytes
75 const readAxis = (isShort, isSameOrPositive, into) => {
76 let value = 0
77 for (const flag of flags) {
78 if (flag & isShort) value += flag & isSameOrPositive ? u8(at++) : -u8(at++)
79 else if (!(flag & isSameOrPositive)) {
80 value += i16(at)
81 at += 2
82 }
83 into.push(value)
84 }
85 }
86 readAxis(2, 16, xs)
87 readAxis(4, 32, ys)
88 for (const flag of flags) isOnCurve.push((flag & 1) === 1)
89 }
90 return { xs, ys, isOnCurve, ends, advance: advanceOf(id) }
91 }
92
93 // The weight axis: where it starts, and how far it runs
94 let weightAxis = null
95 if (fvar !== undefined && gvar !== undefined) {
96 const axes = fvar + u16(fvar + 4)
97 for (let i = 0; i < u16(fvar + 8); i++) {
98 const axis = axes + i * u16(fvar + 10)
99 const tag = String.fromCharCode(u8(axis), u8(axis + 1), u8(axis + 2), u8(axis + 3))
100 if (tag === 'wght') {
101 weightAxis = { index: i, min: view.getInt32(axis + 4) / 65536, initial: view.getInt32(axis + 8) / 65536, max: view.getInt32(axis + 12) / 65536 }
102 }
103 }
104 }
105
106 // Moves a glyph's points from the default weight to the one asked for
107 function vary(id, shape, weight) {
108 if (!weightAxis) return shape
109 const { index, min, initial, max } = weightAxis
110 const target = Math.max(min, Math.min(max, weight))
111 const position = target < initial ? (target - initial) / (initial - min) : target > initial ? (target - initial) / (max - initial) : 0
112 if (position === 0) return shape
113
114 const axisCount = u16(gvar + 4)
115 const sharedTuples = gvar + u32(gvar + 8)
116 const hasLongDataOffsets = (u16(gvar + 14) & 1) === 1
117 const dataOffset = (n) => (hasLongDataOffsets ? u32(gvar + 20 + n * 4) : u16(gvar + 20 + n * 2) * 2)
118 const data = gvar + u32(gvar + 16) + dataOffset(id)
119 if (dataOffset(id + 1) === dataOffset(id)) return shape
120
121 // The glyph's own points, then four more the font uses for its width
122 const pointCount = shape.xs.length + 4
123 const sumX = new Array(pointCount).fill(0)
124 const sumY = new Array(pointCount).fill(0)
125
126 const readPoints = (from) => {
127 let at = from
128 let count = u8(at++)
129 if (count & 0x80) count = ((count & 0x7f) << 8) | u8(at++)
130 if (count === 0) return { points: null, at }
131 const points = []
132 let point = 0
133 while (points.length < count) {
134 const control = u8(at++)
135 for (let run = (control & 0x7f) + 1; run > 0 && points.length < count; run--) {
136 if (control & 0x80) {
137 point += u16(at)
138 at += 2
139 } else point += u8(at++)
140 points.push(point)
141 }
142 }
143 return { points, at }
144 }
145
146 const readDeltas = (from, count) => {
147 let at = from
148 const deltas = []
149 while (deltas.length < count) {
150 const control = u8(at++)
151 for (let run = (control & 0x3f) + 1; run > 0 && deltas.length < count; run--) {
152 if (control & 0x80) deltas.push(0)
153 else if (control & 0x40) {
154 deltas.push(i16(at))
155 at += 2
156 } else deltas.push(i8(at++))
157 }
158 }
159 return { deltas, at }
160 }
161
162 // Fills in the points a variation leaves out, from their neighbours on the same contour
163 const fillGaps = (deltas, isSet, coords) => {
164 let first = 0
165 for (const end of shape.ends) {
166 const set = []
167 for (let i = first; i <= end; i++) if (isSet[i]) set.push(i)
168 for (let n = 0; n < set.length && set.length < end - first + 1; n++) {
169 const a = set[n]
170 const b = set[(n + 1) % set.length]
171 const [low, high] = coords[a] <= coords[b] ? [a, b] : [b, a]
172 for (let i = a === end ? first : a + 1; i !== b; i = i === end ? first : i + 1) {
173 const c = coords[i]
174 if (coords[low] === coords[high]) deltas[i] = deltas[low] === deltas[high] ? deltas[low] : 0
175 else if (c <= coords[low]) deltas[i] = deltas[low]
176 else if (c >= coords[high]) deltas[i] = deltas[high]
177 else deltas[i] = deltas[low] + ((c - coords[low]) / (coords[high] - coords[low])) * (deltas[high] - deltas[low])
178 }
179 if (set.length === 1) for (let i = first; i <= end; i++) deltas[i] = deltas[a]
180 }
181 first = end + 1
182 }
183 }
184
185 const tupleCount = u16(data)
186 let header = data + 4
187 let serialized = data + u16(data + 2)
188 let sharedPoints = null
189 if (tupleCount & 0x8000) {
190 const read = readPoints(serialized)
191 sharedPoints = read.points
192 serialized = read.at
193 }
194
195 for (let t = 0; t < (tupleCount & 0x0fff); t++) {
196 const size = u16(header)
197 const tupleIndex = u16(header + 2)
198 header += 4
199 let peak = sharedTuples + (tupleIndex & 0x0fff) * axisCount * 2
200 if (tupleIndex & 0x8000) {
201 peak = header
202 header += axisCount * 2
203 }
204 let range = null
205 if (tupleIndex & 0x4000) {
206 range = header
207 header += axisCount * 4
208 }
209
210 // How much of this variation applies at the weight asked for. Other axes stay at
211 // their defaults, so a variation that needs one of them moved does not apply.
212 let scalar = 1
213 for (let axis = 0; axis < axisCount; axis++) {
214 const top = i16(peak + axis * 2) / 16384
215 if (top === 0) continue
216 const value = axis === index ? position : 0
217 if (range !== null) {
218 const from = i16(range + axis * 2) / 16384
219 const to = i16(range + (axisCount + axis) * 2) / 16384
220 if (value < from || value > to) scalar = 0
221 else if (value < top) scalar *= (value - from) / (top - from)
222 else if (value > top) scalar *= (to - value) / (to - top)
223 } else if (value === 0 || value < Math.min(0, top) || value > Math.max(0, top)) scalar = 0
224 else scalar *= value / top
225 }
226
227 if (scalar !== 0) {
228 let at = serialized
229 let points = sharedPoints
230 if (tupleIndex & 0x2000) {
231 const read = readPoints(at)
232 points = read.points
233 at = read.at
234 }
235 const count = points ? points.length : pointCount
236 const dx = readDeltas(at, count)
237 const dy = readDeltas(dx.at, count)
238 const moveX = new Array(pointCount).fill(0)
239 const moveY = new Array(pointCount).fill(0)
240 if (points) {
241 const isSet = new Array(pointCount).fill(false)
242 points.forEach((point, n) => {
243 if (point >= pointCount) return
244 isSet[point] = true
245 moveX[point] = dx.deltas[n]
246 moveY[point] = dy.deltas[n]
247 })
248 fillGaps(moveX, isSet, shape.xs)
249 fillGaps(moveY, isSet, shape.ys)
250 } else {
251 for (let i = 0; i < pointCount; i++) {
252 moveX[i] = dx.deltas[i]
253 moveY[i] = dy.deltas[i]
254 }
255 }
256 for (let i = 0; i < pointCount; i++) {
257 sumX[i] += moveX[i] * scalar
258 sumY[i] += moveY[i] * scalar
259 }
260 }
261 serialized += size
262 }
263
264 const n = shape.xs.length
265 return {
266 ...shape,
267 xs: shape.xs.map((x, i) => x + sumX[i] - sumX[n]),
268 ys: shape.ys.map((y, i) => y + sumY[i]),
269 advance: shape.advance + sumX[n + 1] - sumX[n],
270 }
271 }
272
273 // One glyph as path commands, placed at x on a baseline at y, with `scale` pixels per font unit
274 function pathOf(shape, x, y, scale) {
275 const round = (value) => +value.toFixed(2)
276 const point = (i) => [round(x + shape.xs[i] * scale), round(y - shape.ys[i] * scale)]
277 const between = (a, b) => [round((a[0] + b[0]) / 2), round((a[1] + b[1]) / 2)]
278 let d = ''
279 let first = 0
280 for (const end of shape.ends) {
281 const order = []
282 for (let i = first; i <= end; i++) order.push(i)
283 first = end + 1
284 // Start on a point that lies on the curve, or halfway between two that do not
285 const onCurve = order.findIndex((i) => shape.isOnCurve[i])
286 const start = onCurve < 0 ? between(point(order.at(-1)), point(order[0])) : point(order[onCurve])
287 const rest = onCurve < 0 ? order : [...order.slice(onCurve + 1), ...order.slice(0, onCurve)]
288 d += `M${start[0]} ${start[1]}`
289 let control = null
290 for (const i of rest) {
291 const here = point(i)
292 if (shape.isOnCurve[i]) {
293 d += control ? `Q${control[0]} ${control[1]} ${here[0]} ${here[1]}` : `L${here[0]} ${here[1]}`
294 control = null
295 continue
296 }
297 // Two control points in a row have a curve point halfway between them
298 if (control) {
299 const middle = between(control, here)
300 d += `Q${control[0]} ${control[1]} ${middle[0]} ${middle[1]}`
301 }
302 control = here
303 }
304 if (control) d += `Q${control[0]} ${control[1]} ${start[0]} ${start[1]}`
305 d += 'Z'
306 }
307 return d
308 }
309
310 const shapes = new Map()
311 const shapeFor = (id, weight) => {
312 const key = id + '@' + weight
313 if (!shapes.has(key)) {
314 const base = id < glyphCount ? outline(id) : null
315 shapes.set(key, base && vary(id, base, weight))
316 }
317 return shapes.get(key)
318 }
319
320 return {
321 unitsPerEm,
322 // One character's outline in the font's own units, with y running down as a drawing's
323 // does, and how far the next character sits from it; null when the font lacks it
324 glyph(char, weight) {
325 const id = glyphFor(char.codePointAt(0))
326 const shape = id === 0 ? null : shapeFor(id, weight)
327 return shape ? { id, d: pathOf(shape, 0, 0, 1), advance: shape.advance } : null
328 },
329 // The text as one path and its width in pixels, or null when the font lacks a character
330 draw(text, x, y, size, weight) {
331 const scale = size / unitsPerEm
332 let d = ''
333 let cursor = x
334 for (const char of text) {
335 const id = glyphFor(char.codePointAt(0))
336 const shape = id === 0 ? null : shapeFor(id, weight)
337 if (!shape) return null
338 d += pathOf(shape, cursor, y, scale)
339 cursor += shape.advance * scale
340 }
341 return { d, width: cursor - x }
342 },
343 }
344}
345hooks/draw.js 183 lines1// Drawing helpers shared by the band and the side panes: the typeface, colors, icons
2// and the pieces an SVG is put together from.
3
4// What text falls back to when the Desktop app's typeface was not found
5const FALLBACK_FONT = "system-ui, -apple-system, 'Segoe UI', sans-serif"
6
7export const WEIGHT_NORMAL = 400
8export const WEIGHT_STRONG = 600
9
10// The parsed typeface, or null while it has not been found
11let font = null
12export const setFont = (parsed) => {
13 font = parsed
14}
15
16// One icon per meter: the name shown for it, the glyph the terminal shows, and the icon's
17// strokes on a 16 by 16 grid for the Desktop app
18export const ICONS = {
19 five_hour: {
20 name: '5-hour limit',
21 glyph: '◷',
22 paths: '<circle cx="8" cy="8" r="6.25"/><path d="M8 4.5V8l2.5 1.5"/>',
23 },
24 seven_day: {
25 name: 'Weekly limit',
26 glyph: '▦',
27 paths: '<rect x="2.25" y="3.25" width="11.5" height="10.5" rx="2"/><path d="M2.25 6.75h11.5M5.5 1.75v3M10.5 1.75v3"/>',
28 },
29 spend_limit: {
30 name: 'Spend limit',
31 glyph: '◎',
32 paths:
33 '<circle cx="8" cy="8" r="6.25"/>' +
34 '<path d="M9.9 6.4c-.3-.6-1-.95-1.9-.95-1.1 0-1.9.5-1.9 1.3 0 1.8 3.8.7 3.8 2.6 0 .8-.8 1.3-1.9 1.3-.9 0-1.6-.35-1.9-.95M8 4.25v7.5"/>',
35 },
36 context: {
37 name: 'Context window',
38 glyph: '◧',
39 paths: '<path d="M8 2.25 2 5.5l6 3.25 6-3.25zM2 8l6 3.25L14 8M2 10.5l6 3.25 6-3.25"/>',
40 },
41 cache: {
42 name: 'Prompt cache',
43 glyph: '◍',
44 paths: '<ellipse cx="8" cy="4.25" rx="5.25" ry="2"/><path d="M2.75 4.25v7.5c0 1.1 2.35 2 5.25 2s5.25-.9 5.25-2v-7.5M2.75 8c0 1.1 2.35 2 5.25 2s5.25-.9 5.25-2"/>',
45 },
46 cost: {
47 name: 'Session cost',
48 glyph: '$',
49 paths: '<path d="M3.75 1.75h8.5v12.5l-2.1-1.3-2.15 1.3-2.15-1.3-2.1 1.3zM6.25 5.5h3.5M6.25 8.5h3.5"/>',
50 },
51 repo: {
52 name: 'Repository',
53 glyph: '▣',
54 paths: '<path d="M3.25 2.25h9.5v11.5h-9.5zM3.25 10.75h9.5M6 2.25v8.5"/>',
55 },
56 branch: {
57 name: 'Branch',
58 glyph: '⎇',
59 paths: '<circle cx="5" cy="3.75" r="1.75"/><circle cx="5" cy="12.25" r="1.75"/><circle cx="11.25" cy="5.75" r="1.75"/><path d="M5 5.5v5M11.25 7.5c0 2.2-2.4 2.6-4.6 2.9"/>',
60 },
61 folder: {
62 name: 'Folder',
63 glyph: '▸',
64 paths: '<path d="M1.75 4.25c0-.8.7-1.5 1.5-1.5h3l1.5 1.75h5c.8 0 1.5.7 1.5 1.5v5.75c0 .8-.7 1.5-1.5 1.5h-9.5c-.8 0-1.5-.7-1.5-1.5z"/>',
65 },
66 session: {
67 name: 'Session',
68 glyph: '›',
69 paths: '<rect x="1.75" y="2.75" width="12.5" height="10.5" rx="2"/><path d="M4.75 6.25 6.75 8l-2 1.75M8.5 10h2.75"/>',
70 },
71}
72
73// For a limit kind this mod has no icon for
74const OTHER_ICON = { glyph: '◔', paths: '<circle cx="8" cy="8" r="6.25"/><path d="M8 8V1.75M8 8l4.4 4.4"/>' }
75
76export const iconFor = (key) => ICONS[key] ?? { ...OTHER_ICON, name: key.replaceAll('_', ' ') }
77
78// Drawn before a detail, on a 10 by 10 grid: a circular arrow before a reset time,
79// a warning triangle before a warning
80const DETAIL_ICONS = {
81 reset: '<path d="M8.4 5a3.4 3.4 0 1 1-1-2.4M8.5 1.4v2.2H6.3"/>',
82 alert: '<path d="M5 1.5 9.2 8.6H.8zM5 4.2v2.1M5 7.5v.1"/>',
83 // Arrows for the prompt cache: down for what was read out of it, up for what was written in
84 down: '<path d="M5 1.25v7.25M2 5.75 5 8.75l3-3"/>',
85 up: '<path d="M5 8.75V1.5M2 4.25 5 1.25l3 3"/>',
86}
87
88export const ALERT_HEX = '#e5534b'
89export const ACCENT_HEX = '#d97757'
90export const ADDED_HEX = '#4caf7d'
91export const WRITTEN_HEX = '#e0a23c'
92
93// Bar colors by how full a meter is. Mid-tones, so they read on light and dark themes.
94const COLORS = [
95 { from: 90, hex: ALERT_HEX, name: 'red' },
96 { from: 70, hex: '#e0a23c', name: 'yellow' },
97 { from: 0, hex: '#4caf7d', name: 'green' },
98]
99
100export const colorFor = (percent) => COLORS.find((c) => percent >= c.from)
101export const clamp = (percent) => Math.max(0, Math.min(100, percent))
102export const escapeXml = (text) => text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')
103
104// Text and icon colors. The fill attributes are mid-tones that read on either theme, and
105// these rules sharpen them for the theme in use. The first rule says the drawing suits both
106// themes: without it the frame it is drawn in gets a white backdrop on a dark theme.
107const STYLE =
108 '<style>' +
109 ':root{color-scheme:light dark}' +
110 '.strong{fill:#3d3d3a}.dim{fill:#73726c}.icon{stroke:#73726c}.card{fill:#3d3d3a;fill-opacity:.05}.track{fill:#3d3d3a;fill-opacity:.12}' +
111 '@media (prefers-color-scheme:dark){.strong{fill:#e8e6dc}.dim{fill:#9c9a92}.icon{stroke:#9c9a92}.card{fill:#e8e6dc;fill-opacity:.06}.track{fill:#e8e6dc;fill-opacity:.14}}' +
112 '</style>'
113
114const STROKES = 'fill="none" stroke-linecap="round" stroke-linejoin="round"'
115
116// The outlines the drawing in progress has used, each kept once and stamped wherever its
117// character appears, which keeps a drawing with a lot of text small. svg() writes them out.
118let outlines = new Map()
119
120// A piece of text: outlines in the app's typeface when it was found, otherwise ordinary text
121// in the system font with its width estimated. `anchor` places x at its start, middle or end.
122export function text(string, x, y, { size = 12, weight = WEIGHT_NORMAL, tone = 'strong', color, anchor = 'start' } = {}) {
123 // A color of its own, or the theme's through the class
124 const paint = color ? `fill="${color}"` : `class="${tone}" fill="#8a8880"`
125 const glyphs = font ? [...string].map((char) => font.glyph(char, weight)) : [null]
126 if (glyphs.every(Boolean)) {
127 const scale = size / font.unitsPerEm
128 const width = glyphs.reduce((sum, g) => sum + g.advance, 0) * scale
129 const left = anchor === 'end' ? x - width : anchor === 'middle' ? x - width / 2 : x
130 let stamps = ''
131 let cursor = 0
132 for (const g of glyphs) {
133 const key = g.id + '-' + weight
134 if (!outlines.has(key)) outlines.set(key, { name: 'g' + outlines.size, d: g.d })
135 stamps += `<use href="#${outlines.get(key).name}" x="${Math.round(cursor)}"/>`
136 cursor += g.advance
137 }
138 return { markup: `<g ${paint} transform="translate(${+left.toFixed(2)} ${y}) scale(${+scale.toFixed(5)})">${stamps}</g>`, width }
139 }
140 return {
141 markup: `<text ${paint} x="${x}" y="${y}" text-anchor="${anchor}" font-family="${FALLBACK_FONT}" font-size="${size}" font-weight="${weight}">${escapeXml(string)}</text>`,
142 width: string.length * size * (weight > WEIGHT_NORMAL ? 0.62 : 0.58),
143 }
144}
145
146// A meter's icon, 16 pixels square with its corner at x, y
147export const icon = (key, x, y) =>
148 `<g class="icon" stroke="#8a8880" ${STROKES} stroke-width="1.4" transform="translate(${+x.toFixed(2)} ${+y.toFixed(2)})">${iconFor(key).paths}</g>`
149
150// A detail's icon, 10 pixels square
151export const detailIcon = (kind, x, y, hex) => {
152 const paint = hex ? `stroke="${hex}"` : kind === 'alert' ? `stroke="${ALERT_HEX}"` : 'class="icon" stroke="#8a8880"'
153 return `<g ${paint} ${STROKES} stroke-width="${kind === 'up' || kind === 'down' ? 1.5 : 1.1}" transform="translate(${+x.toFixed(2)} ${+y.toFixed(2)})">${DETAIL_ICONS[kind]}</g>`
154}
155
156// A rounded bar: the track, then the filled part in `hex`
157export function bar(x, y, width, height, percent, hex) {
158 const filled = Math.max(percent > 0 ? height : 0, (clamp(percent) / 100) * width)
159 return (
160 `<rect class="track" x="${x}" y="${y}" width="${width}" height="${height}" rx="${height / 2}" fill="#888888" fill-opacity="0.3"/>` +
161 `<rect x="${x}" y="${y}" width="${+filled.toFixed(2)}" height="${height}" rx="${height / 2}" fill="${hex}"/>`
162 )
163}
164
165export const rect = (x, y, width, height, radius, tone = 'card') =>
166 `<rect class="${tone}" x="${+x.toFixed(2)}" y="${+y.toFixed(2)}" width="${+Math.max(0, width).toFixed(2)}" height="${+Math.max(0, height).toFixed(2)}" rx="${radius}" fill="#888888" fill-opacity="0.1"/>`
167
168// A whole SVG document. With a tooltip, hovering anywhere on it shows that text: the title
169// sits in a group, since one directly under the root names the document instead, and an
170// unseen sheet makes the gaps between the parts count as part of the group.
171export function svg(width, height, body, tooltip) {
172 const inner = tooltip
173 ? `<g><title>${escapeXml(tooltip)}</title><rect width="${width}" height="${height}" fill="#000000" fill-opacity="0"/>${body}</g>`
174 : body
175 const defs = outlines.size ? `<defs>${[...outlines.values()].map((o) => `<path id="${o.name}" d="${o.d}"/>`).join('')}</defs>` : ''
176 outlines = new Map()
177 return {
178 source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${STYLE}${defs}${inner}</svg>`,
179 width,
180 height,
181 }
182}
183hooks/panes.js 325 lines1// The side panes' drawings for the Desktop app: each card is one SVG, laid out for the
2// width it is given, and the same content as markdown for the terminal.
3
4import { ACCENT_HEX, ADDED_HEX, ALERT_HEX, WEIGHT_STRONG, WRITTEN_HEX, bar, clamp, colorFor, detailIcon, icon, rect, svg, text } from './draw.js'
5
6const PAD = 14
7const RADIUS = 10
8
9const money = (usd) => '$' + usd.toFixed(2)
10
11// A card: a rounded sheet with a title, and a body drawn by `draw(x, y, innerWidth)`
12// that answers its markup and its height
13function card(width, title, draw) {
14 const inner = width - PAD * 2
15 const body = draw(PAD, 38, inner)
16 const height = Math.ceil(38 + body.height + PAD)
17 return svg(
18 width,
19 height,
20 rect(0, 0, width, height, RADIUS) + text(title, PAD, 23, { size: 12, weight: WEIGHT_STRONG, tone: 'dim' }).markup + body.markup,
21 )
22}
23
24// Plan limits: one block per limit, two side by side where the pane is wide
25function limitsCard(width, limits) {
26 return card(width, 'Plan limits', (x, y, inner) => {
27 if (!limits.length) return { markup: text('Plan limits appear after the first reply.', x, y + 10, { tone: 'dim' }).markup, height: 16 }
28 const columns = inner >= 520 ? 2 : 1
29 const gap = 24
30 const blockWidth = (inner - gap * (columns - 1)) / columns
31 let markup = ''
32 let rowHeight = 0
33 let top = y
34 limits.forEach((limit, i) => {
35 const column = i % columns
36 if (column === 0 && i > 0) {
37 top += rowHeight + 18
38 rowHeight = 0
39 }
40 const left = x + column * (blockWidth + gap)
41 const right = left + blockWidth
42 markup += icon(limit.key, left, top)
43 markup += text(limit.name, left + 24, top + 12.5, { size: 13, weight: WEIGHT_STRONG }).markup
44 markup += text(Math.round(limit.percent) + '%', right, top + 12.5, { size: 15, weight: WEIGHT_STRONG, anchor: 'end' }).markup
45 markup += bar(left, top + 24, blockWidth, 8, limit.percent, colorFor(limit.percent).hex)
46 // The full date where there is room for it, the countdown alone where there is not
47 const resets = limit.resetLong ? (blockWidth >= 300 ? 'Resets ' + limit.resetLong + ' · in ' + limit.resetShort : 'Resets in ' + limit.resetShort) : ''
48 let height = 32
49 if (resets) {
50 markup += text(resets, left, top + 50, { size: 11.5, tone: 'dim' }).markup
51 height = 54
52 }
53 if (limit.pace) {
54 const isAlert = limit.pace.kind === 'alert'
55 if (isAlert) markup += detailIcon('alert', left, top + height + 9)
56 markup += text(limit.pace.text, left + (isAlert ? 14 : 0), top + height + 18, { size: 11.5, tone: 'dim', color: isAlert ? ALERT_HEX : undefined }).markup
57 height += 20
58 }
59 rowHeight = Math.max(rowHeight, height)
60 })
61 return { markup, height: top + rowHeight - y }
62 })
63}
64
65// Context window: the fill, where it is summarised, and what is taking the room
66function contextCard(width, context) {
67 return card(width, 'Context window', (x, y, inner) => {
68 const right = x + inner
69 let markup = icon('context', x, y)
70 markup += text(Math.round(context.percent) + '% full', x + 24, y + 12.5, { size: 13, weight: WEIGHT_STRONG }).markup
71 if (context.tokens) markup += text(context.tokens.toLocaleString() + ' of ' + context.window.toLocaleString() + ' tokens', right, y + 12.5, { size: 11.5, tone: 'dim', anchor: 'end' }).markup
72 markup += bar(x, y + 24, inner, 8, context.percent, colorFor(context.percent).hex)
73 let height = 32
74 if (context.compactAt !== null) {
75 // A tick on the bar where the conversation is summarised
76 const at = x + (clamp(context.compactAt) / 100) * inner
77 markup += `<rect class="strong" x="${+(at - 1).toFixed(2)}" y="${y + 21}" width="2" height="14" rx="1" fill="#8a8880"/>`
78 markup += text('Summarised at about ' + context.compactAt + '%', x, y + 50, { size: 11.5, tone: 'dim' }).markup
79 height = 54
80 }
81 if (context.categories.length) {
82 let top = y + height + 12
83 const most = Math.max(...context.categories.map((c) => c.share))
84 for (const c of context.categories) {
85 markup += text(c.name, x, top + 11, { size: 12 }).markup
86 markup += text(c.tokens.toLocaleString() + ' · ' + c.share.toFixed(1) + '%', right, top + 11, { size: 11.5, tone: 'dim', anchor: 'end' }).markup
87 markup += bar(x, top + 17, inner, 3, (c.share / most) * 100, ACCENT_HEX)
88 top += 30
89 }
90 markup += text('Estimated on this computer.', x, top + 8, { size: 11, tone: 'dim' }).markup
91 height = top + 12 - y
92 }
93 return { markup, height }
94 })
95}
96
97// Prompt cache: the tokens read out of the cache and written into it, with the rest by kind
98function cacheCard(width, cache) {
99 return card(width, 'Prompt cache', (x, y, inner) => {
100 // What was read out of the cache and written into it, each behind its arrow
101 let markup = detailIcon('down', x, y + 3, ADDED_HEX)
102 const read = text(cache.read, x + 14, y + 13, { size: 15, weight: WEIGHT_STRONG })
103 const readLabel = text('read', x + 14 + read.width + 5, y + 13, { size: 11.5, tone: 'dim' })
104 const next = x + 14 + read.width + 5 + readLabel.width + 16
105 markup += read.markup + readLabel.markup + detailIcon('up', next, y + 3, WRITTEN_HEX)
106 const written = text(cache.written, next + 14, y + 13, { size: 15, weight: WEIGHT_STRONG })
107 markup += written.markup + text('written', next + 14 + written.width + 5, y + 13, { size: 11.5, tone: 'dim' }).markup
108 markup += text(Math.round(cache.share) + '% of input from cache', x + inner, y + 13, { size: 11.5, tone: 'dim', anchor: 'end' }).markup
109 const row = tiles(
110 [
111 ['Sent uncached', cache.fresh],
112 ['Output', cache.output],
113 ],
114 x,
115 y + 30,
116 inner,
117 400,
118 )
119 markup += row.markup
120 markup += text(cache.span + (cache.lastShare !== null ? ' Latest turn ' + Math.round(cache.lastShare) + '% from cache.' : ''), x, y + 30 + row.height + 8, { size: 11, tone: 'dim' }).markup
121 return { markup, height: 30 + row.height + 12 }
122 })
123}
124
125// Cost: the session's total, four figures as tiles, the last week by day, and recent turns
126function costCard(width, cost) {
127 return card(width, 'Cost at API prices', (x, y, inner) => {
128 let markup = text(money(cost.session), x, y + 22, { size: 26, weight: WEIGHT_STRONG }).markup
129 markup += text('this session', x, y + 40, { size: 11.5, tone: 'dim' }).markup
130 let top = y + 56
131
132 const row = tiles(
133 [
134 ['Latest turn', money(cost.turn)],
135 ['Today', money(cost.today)],
136 ['Last 7 days', money(cost.week)],
137 ['Last 30 days', money(cost.month)],
138 ],
139 x,
140 top,
141 inner,
142 400,
143 )
144 markup += row.markup
145 top += row.height + 12
146
147 // The last seven days as columns, each with its total above and its weekday below
148 markup += text('Last 7 days', x, top + 10, { size: 11.5, weight: WEIGHT_STRONG, tone: 'dim' }).markup
149 top += 22
150 const chart = 64
151 const most = Math.max(0.01, ...cost.days.map((d) => d.usd))
152 const slot = inner / cost.days.length
153 const columnWidth = Math.min(28, slot * 0.6)
154 cost.days.forEach((day, i) => {
155 const middle = x + slot * i + slot / 2
156 const tall = Math.max(day.usd > 0 ? 3 : 1, (day.usd / most) * chart)
157 markup += `<rect x="${+(middle - columnWidth / 2).toFixed(2)}" y="${+(top + 14 + chart - tall).toFixed(2)}" width="${+columnWidth.toFixed(2)}" height="${+tall.toFixed(2)}" rx="3" fill="${ACCENT_HEX}"${day.usd > 0 ? '' : ' fill-opacity="0.3"'}/>`
158 if (day.usd > 0 && slot >= 44) markup += text(money(day.usd), middle, top + 9 + chart - tall, { size: 10, tone: 'dim', anchor: 'middle' }).markup
159 markup += text(day.label, middle, top + 14 + chart + 14, { size: 10.5, tone: day.isToday ? 'strong' : 'dim', anchor: 'middle' }).markup
160 })
161 top += 14 + chart + 22
162
163 if (cost.turns.length > 1) {
164 markup += text('Recent turns', x, top + 14, { size: 11.5, weight: WEIGHT_STRONG, tone: 'dim' }).markup
165 markup += text('largest ' + money(Math.max(...cost.turns)), x + inner, top + 14, { size: 11, tone: 'dim', anchor: 'end' }).markup
166 top += 24
167 const tallest = Math.max(...cost.turns)
168 const step = Math.min(14, inner / cost.turns.length)
169 cost.turns.forEach((usd, i) => {
170 const tall = Math.max(2, (usd / tallest) * 28)
171 markup += `<rect x="${+(x + i * step).toFixed(2)}" y="${+(top + 28 - tall).toFixed(2)}" width="${+Math.max(2, step - 3).toFixed(2)}" height="${+tall.toFixed(2)}" rx="1.5" fill="${ACCENT_HEX}" fill-opacity="0.75"/>`
172 })
173 top += 36
174 // The latest few as figures, newest first
175 markup += text(cost.turns.slice(-5).reverse().map(money).join(' '), x, top + 8, { size: 11.5, tone: 'dim' }).markup
176 top += 14
177 }
178 return { markup, height: top - y }
179 })
180}
181
182// A row of small tiles, wrapping to as many rows as the width needs; answers its markup and height
183function tiles(items, x, top, inner, wide) {
184 const columns = inner >= wide ? items.length : 2
185 const gap = 8
186 const tileWidth = (inner - gap * (columns - 1)) / columns
187 let markup = ''
188 items.forEach(([label, value, sub], i) => {
189 const left = x + (i % columns) * (tileWidth + gap)
190 const tileTop = top + Math.floor(i / columns) * (sub === undefined ? 54 : 68)
191 markup += rect(left, tileTop, tileWidth, sub === undefined ? 46 : 60, 8)
192 markup += text(label, left + 10, tileTop + 17, { size: 11, tone: 'dim' }).markup
193 markup += text(value, left + 10, tileTop + 36, { size: 15, weight: WEIGHT_STRONG }).markup
194 if (sub) markup += text(sub, left + 10, tileTop + 51, { size: 10.5, tone: 'dim' }).markup
195 })
196 return { markup, height: Math.ceil(items.length / columns) * (items.some((item) => item[2] !== undefined) ? 68 : 54) }
197}
198
199// Cost history: the whole record kept, as totals, a column for every day, and the latest days listed
200function historyCard(width, history) {
201 return card(width, 'Cost history, last ' + history.days.length + ' days', (x, y, inner) => {
202 const row = tiles(
203 [
204 ['Total', money(history.total), history.activeDays + (history.activeDays === 1 ? ' day' : ' days') + ' with usage'],
205 ['Average day', money(history.average), 'on days with usage'],
206 ['Busiest day', history.busiest ? money(history.busiest.usd) : money(0), history.busiest ? history.busiest.label : 'none yet'],
207 ['Sessions', String(history.sessions), 'tracked'],
208 ],
209 x,
210 y,
211 inner,
212 440,
213 )
214 let markup = row.markup
215 let top = y + row.height + 10
216
217 // One column for every day, oldest on the left, with the months marked beneath
218 const chart = 56
219 const most = Math.max(0.01, ...history.days.map((d) => d.usd))
220 const slot = inner / history.days.length
221 markup += `<rect class="track" x="${x}" y="${top + chart}" width="${inner}" height="1" fill="#888888" fill-opacity="0.3"/>`
222 history.days.forEach((day, i) => {
223 const left = x + slot * i
224 if (day.usd > 0) {
225 const tall = Math.max(2, (day.usd / most) * chart)
226 markup += `<rect x="${+left.toFixed(2)}" y="${+(top + chart - tall).toFixed(2)}" width="${+Math.max(1, slot - 1).toFixed(2)}" height="${+tall.toFixed(2)}" rx="1" fill="${ACCENT_HEX}"/>`
227 }
228 // A month that starts in the last few days would sit under the 'today' mark
229 if (day.month && i < history.days.length * 0.85) markup += text(day.month, left, top + chart + 14, { size: 10.5, tone: 'dim' }).markup
230 })
231 markup += text('today', x + inner, top + chart + 14, { size: 10.5, anchor: 'end' }).markup
232 top += chart + 28
233
234 if (history.recent.length) {
235 markup += text('Latest days with usage', x, top + 10, { size: 11.5, weight: WEIGHT_STRONG, tone: 'dim' }).markup
236 top += 22
237 for (const day of history.recent) {
238 markup += text(day.label, x, top + 11, { size: 12 }).markup
239 markup += text(money(day.usd), x + inner, top + 11, { size: 12, weight: WEIGHT_STRONG, anchor: 'end' }).markup
240 markup += bar(x, top + 17, inner, 3, (day.usd / most) * 100, ACCENT_HEX)
241 top += 28
242 }
243 }
244 markup += text(history.note, x, top + 10, { size: 11, tone: 'dim' }).markup
245 return { markup, height: top + 14 - y }
246 })
247}
248
249// Session: plain facts, name on the left and value on the right
250function sessionCard(width, rows) {
251 return card(width, 'Session', (x, y, inner) => {
252 let markup = ''
253 rows.forEach(([name, value], i) => {
254 markup += text(name, x, y + 11 + i * 24, { size: 12, tone: 'dim' }).markup
255 markup += text(value, x + inner, y + 11 + i * 24, { size: 12, weight: WEIGHT_STRONG, anchor: 'end' }).markup
256 })
257 return { markup, height: rows.length * 24 - 8 }
258 })
259}
260
261// The usage pane's cards, top to bottom, for a pane `width` pixels across
262export function usageCards(model, width) {
263 const cards = [{ ...limitsCard(width, model.limits), alt: 'Plan limits' }]
264 if (model.context) cards.push({ ...contextCard(width, model.context), alt: 'Context window' })
265 if (model.cache) cards.push({ ...cacheCard(width, model.cache), alt: 'Prompt cache' })
266 if (model.cost) cards.push({ ...costCard(width, model.cost), alt: 'Cost at API prices' })
267 if (model.history) cards.push({ ...historyCard(width, model.history), alt: 'Cost history' })
268 cards.push({ ...sessionCard(width, model.session), alt: 'Session' })
269 return cards
270}
271
272// A heading in a pane
273export const heading = (label, width) => svg(width, 26, text(label, 0, 18, { size: 12, weight: WEIGHT_STRONG, tone: 'dim' }).markup)
274
275// A setting's name and what it does, beside its switch
276export function settingLabel(name, description, width) {
277 return svg(width, 36, text(name, 0, 14, { size: 13, weight: WEIGHT_STRONG }).markup + text(description, 0, 30, { size: 11.5, tone: 'dim' }).markup)
278}
279
280// A bar of blocks for the terminal's tables
281const blocks = (percent, cells = 12) => {
282 const filled = Math.round((clamp(percent) / 100) * cells)
283 return '█'.repeat(filled) + '░'.repeat(cells - filled)
284}
285
286// The usage pane as markdown, for a surface that cannot draw the cards
287export function usageMarkdown(model) {
288 const out = ['## Plan limits', '']
289 if (model.limits.length) {
290 out.push('| Limit | Used | | Resets | Pace |', '|:--|--:|:--|:--|:--|')
291 for (const l of model.limits) out.push(`| ${l.name} | ${Math.round(l.percent)}% | ${blocks(l.percent)} | ${l.resetLong ?? ''} | ${l.pace?.text ?? ''} |`)
292 } else out.push('Plan limits appear after the first reply.')
293 if (model.context) {
294 out.push('', '## Context window', '', `${Math.round(model.context.percent)}% full` + (model.context.compactAt !== null ? `, summarised at about ${model.context.compactAt}%.` : '.'), '')
295 if (model.context.categories.length) {
296 out.push('| Category | Tokens | Share |', '|:--|--:|--:|')
297 for (const c of model.context.categories) out.push(`| ${c.name} | ${c.tokens.toLocaleString()} | ${c.share.toFixed(1)}% |`)
298 }
299 }
300 if (model.cache) {
301 const k = model.cache
302 out.push('', '## Prompt cache', '', `${Math.round(k.share)}% of input read from cache this session.`, '', '| | |', '|:--|--:|', `| Read from cache | ${k.read} |`, `| Written to cache | ${k.written} |`, `| Sent uncached | ${k.fresh} |`, `| Output | ${k.output} |`)
303 }
304 if (model.cost) {
305 const c = model.cost
306 out.push('', '## Cost at API prices', '', '| | |', '|:--|--:|')
307 out.push(`| This session | ${money(c.session)} |`, `| Latest turn | ${money(c.turn)} |`, `| Today | ${money(c.today)} |`, `| Last 7 days | ${money(c.week)} |`, `| Last 30 days | ${money(c.month)} |`)
308 out.push('', '| Day | Spent | |', '|:--|--:|:--|')
309 const most = Math.max(0.01, ...c.days.map((d) => d.usd))
310 for (const d of [...c.days].reverse()) out.push(`| ${d.label} | ${money(d.usd)} | ${blocks((d.usd / most) * 100)} |`)
311 }
312 if (model.history) {
313 const h = model.history
314 out.push('', '## Cost history, last ' + h.days.length + ' days', '', '| | |', '|:--|--:|')
315 out.push(`| Total | ${money(h.total)} |`, `| Average day with usage | ${money(h.average)} |`, `| Busiest day | ${h.busiest ? money(h.busiest.usd) + ' on ' + h.busiest.label : 'none yet'} |`, `| Sessions tracked | ${h.sessions} |`)
316 if (h.recent.length) {
317 out.push('', '| Day | Spent |', '|:--|--:|')
318 for (const d of h.recent) out.push(`| ${d.label} | ${money(d.usd)} |`)
319 }
320 out.push('', h.note)
321 }
322 out.push('', '## Session', '', '| | |', '|:--|:--|', ...model.session.map(([name, value]) => `| ${name} | ${value} |`))
323 return out.join('\n')
324}
325