SLOPSHOPPER

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…

newpanebandtoaststatusprompt
v0.5.2no licenseupdated 2026-10-04Pimzino/pimzinos-cool-cc-mods/usage-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-meter
│ ┃ usage-meter ✕ › fix the failing auth test and add an audit log call │ ┃ Plan limits │ ┃ ⏺ Read(src/auth.ts) │ ┃ | Limit | Used | | Resets | Pace | ⎿ Read 6 lines │ ┃ |:--|--:|:--|:--|:--| ⏺ Update(src/auth.ts) │ ┃ | 5-hour limit | 31% | ████░░░░░░░░ | ⎿ Added 2 lines, removed 1 line │ ┃ Thursday, Oct 9, 02:53 | | ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Context window │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 49% full. │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Prompt cache │ ┃ │ ┃ 93% of input read from cache this session. │ ┃ │ ┃ | | | │ ┃ |:--|--:| │ ┃ | Read from cache | 91.0K | │ ┃ | Written to cache | 4.3K | │ ┃ | Sent uncached | 2.1K | │ ┃ | Output | 1.5K | │ ┃ │ ┃ Cost at API prices │ ┃ │ ┃ | | | ⟨Claude Code's own drawing⟩ ◷ ▰▰▱▱▱▱▱▱ 31% ↻ ◧ ▰▰▰▰▱▱▱▱ 49% ◍ ↓91.0K ↑4.3K $ $0.42 [ ☰ ] [ ⚙︎ ] ▣ acme/app ⎇ feat/auth-refresh +14 −3 1 file, 1 new ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ usage-meter: Bundled CC 2.1.289 · session 30m

Draws

Band
⟨Claude Code's own drawing⟩ ◷ ▰▰▱▱▱▱▱▱ 31% ↻ ◧ ▰▰▰▰▱▱▱▱ 49% ◍ ↓91.0K ↑4.3K $ $0.42 [ ☰ ] [ ⚙︎ ] ▣ acme/app ⎇ feat/auth-refresh +14 −3 1 file, 1 new
Pane · usage-meter
Plan limits | Limit | Used | | Resets | Pace | |:--|--:|:--|:--|:--| | 5-hour limit | 31% | ████░░░░░░░░ | Thursday, Oct 9, 02:53 | | Context window 49% full. Prompt cache 93% of input read from cache this session. | | | |:--|--:| | Read from cache | 91.0K | | Written to cache | 4.3K | | Sent uncached | 2.1K | | Output | 1.5K | Cost at API prices | | | |:--|--:| | This session | $0.42 | | Latest turn | $0.00 | | Today | $0.42 | | Last 7 days | $0.42 | | Last 30 days | $0.42 | | Day | Spent | | |:--|--:|:--| | Thu | $0.42 | ████████████ | | Wed | $0.00 | ░░░░░░░░░░░░ | | Tue | $0.00 | ░░░░░░░░░░░░ | | Mon | $0.00 | ░░░░░░░░░░░░ | | Sun | $0.00 | ░░░░░░░░░░░░ | | Sat | $0.00 | ░░░░░░░░░░░░ | | Fri | $0.00 | ░░░░░░░░░░░░ | Cost history, last 90 days | | | |:--|--:| | Total | $0.42 | | Average day with usage | $0.42 | | Busiest day | $0.42 on Thu, Oct 9 | | Sessions tracked | 1 | | Day | Spent | |:--|--:| | Thu, Oct 9 | $0.42 | Counts sessions where this mod was running. Session | | | |:--|:--| | Running for | 30m | | Bundled Claude Code | 2.1.289 |
Pane · usage-meter-settings
Meters [ On ] 5-hour limit, show the 5-hour plan limit [ On ] Weekly limit, show the weekly plan limit [ On ] Spend limit, show the spend limit, where the account [ On ] Context window, show how full the context window is [ On ] Prompt cache, show how much of the session's input w from cache [ On ] Session cost, show what the session would have cost prices [ On ] Latest turn's cost, show the latest turn beside the cost Repository [ On ] Repository row, show the repository, branch and chan under the meters Footer [ On ] Claude Code version, show the bundled version under prompt [ On ] Session age, show how long the session has been runn Notices [ On ] Limit reset, say so when a plan limit starts over [ On ] Limit warning, say so when a plan limit reaches the level 90% ▾ Warning level, how full a plan limit is when the warn appears
README

Pimzino's cool CC mods

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.

Install

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.

Plugins

| 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 |

usage-meter

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.

  • Warnings in the band. A limit that is filling fast enough to run out before it resets says when it will be full. The context meter says when the conversation is close to being summarised. The cost meter shows the latest turn's cost beside the session total.
  • Prompt cache. A meter shows the tokens read from the prompt cache behind a down arrow and the tokens written to it behind an up arrow. Cached input is billed at a fraction of the price. The Details pane adds the share of input that came from cache, and the uncached input and output tokens.
  • Tooltips. Pointing at a meter says what it is and, for a limit, its full reset date. The two buttons have tooltips too.
  • Notices. One appears when a plan limit reaches the warning level (90% unless changed), and another when a limit's window starts over.
  • Repository row. When the session is inside a git repository, a second row under the meters shows the repository, the branch, lines added and removed since the last commit, and how many files changed. Outside a repository the row is not drawn.
  • Footer. The bundled Claude Code version and how long the session has been running show under the prompt.
  • Details pane. The ☰ button at the end of the band opens the Details pane, with every limit and its pace, the context window by category, cost per turn, and the full 90-day cost history: totals, the average and busiest day, a column for every day, and the latest days listed. In the desktop app it is drawn as cards that fit the pane's width.
  • Cost history. Each session's cost is remembered by day, on this computer, for 90 days. It counts only sessions where the mod was running.
  • Settings. The ⚙ button beside it opens a second pane where each meter, the footer items and the notices can be switched off, and the warning level changed. Choices are remembered between sessions.
  • Narrow windows. The bars shorten, then the reset times and the bars drop away, and after that the meters wrap onto a second row.

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.

Adding a plugin

  1. Create a folder with .claude-plugin/plugin.json and the plugin's files.
  2. Add an entry for it to .claude-plugin/marketplace.json.
  3. Run claude plugin validate . and push.
Source 4 files
hooks/register.js 936 lines
1import { 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}
936
hooks/font.js 345 lines
1// 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}
345
hooks/draw.js 183 lines
1// 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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
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}
183
hooks/panes.js 325 lines
1// 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