SLOPSHOPPER

usage-band

Shows context, 5-hour and weekly limit usage in the band above the prompt

newbandguardtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ctx █████░░░░░ 49% 5h ███░░░░░░░ 31% NaNm 08:53 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx █████░░░░░ 49% 5h ███░░░░░░░ 31% NaNm 08:53 ⟨Claude Code's own drawing⟩
README

usage-band

プロンプト入力欄の上の帯に、コンテキストの使用率と、5 時間制限と週間制限の使用率を表示する mod です。

ctx ██░░░░░░░░ 19%   5h ████░░░░░░ 43% 2h34m   7d █░░░░░░░░░ 9% 1d21h   14:05

表示

  • ゲージ: デスクトップアプリでは SVG、ターミナルでは文字のバーで描きます
  • 幅が狭いとき: 1 行に収まるように、時刻、残り時間の順に省き、さらに狭いとバーを短くしてから省きます。ラベルと使用率は残します
  • 色: 使用率 50% 未満は緑、80% 未満は黄、それ以上は赤です
  • 残り時間: 利用制限には、リセットまでの残り時間を添えます
  • 時刻: 末尾の薄い HH:MM は、表示している利用制限の値を測った時刻です

デスクトップアプリでは、5h と 7d にアプリの使用量の表示と同じ値を出します。 5h と 7d の値は、このマシンのセッション間で共有します。別のセッションで使った分も、1 分ほどで反映されます。 ctx の値は、セッションごとのものです。

使用率の記録

5h と 7d の使用率を、あとで集計できるようにファイルに書き残せます。 既定ではオフで、オンにしたときだけ書きます。

設定既定内容
logfalsetrue にすると記録を書く
logDir~/.claude/usage-band/記録を書くフォルダ(~ はホームフォルダ。絶対パスも使える)

設定は、usage-band の設定の「使用率を記録する」(log)と「記録の置き場」(logDir)で変えます。

記録は日ごとのファイル usage-YYYYMMDD.jsonl(日付はこのマシンの日付)に、1 行に 1 つの JSON で書き足します。

{"ts":"2026-10-06T03:12:45.120Z","src":"card","five_hour":{"used":43,"resets_at":"2026-10-06T05:59:59.852Z"},"seven_day":{"used":9,"resets_at":"2026-10-07T23:59:59.852Z"}}
  • ts: 行を書いた時刻
  • src: 値を読んだ元。card はデスクトップアプリの使用量の表示、response は Claude Code が受け取った応答
  • five_hour、seven_day: 使用率(used、%)とリセットの時刻(resets_at)。値は読んだままです。利用制限の種類が増えると、その種類の名前(例 spend_limit)の項目も並びます
  • 行を書くのは、新しい値を読んだときだけです。複数のセッションを開いていても、同じ値は原則として 1 回だけ書きます
  • ctx の値は記録しません
  • 書けなかったときは、セッションごとに 1 回だけ通知を出します

使い方

Claude Code v2.1.286 以降で動作を確認しています。 インストール方法は、リポジトリの README を見てください。

Source 1 files
hooks/register.js 563 lines
1// Shows context, 5-hour limit and weekly limit usage in the band above the prompt.
2// On a narrow band the parts give way in a fixed order (fitBand): the clock first, then the
3// reset countdowns, then the bars shorten, then the bars go; the labels and percentages stay.
4// Every meter keeps its width (flexShrink 0), so none of them overlaps another or wraps.
5// The context figure is this session's own. The rate-limit figures are the account's. Where the
6// desktop app's own usage card answers (its ccd_session_mgmt server's get_usage), the 5h and weekly
7// figures come from it, so the band shows what the app shows; elsewhere (the terminal) they come
8// from the session's last API response, which lags the card by a few minutes. The newest reading
9// any session made is shared through $.store, and every session shows the newer of its own and
10// the stored.
11// With the `log` option on, each reading a session publishes to the store is also appended to
12// a daily JSON Lines file under `logDir`, so the readings can be aggregated later.
13
14// This session's context, from $.session.usage() or session.measure; never shared
15let context = null
16// This session's own rate-limit readings, { rateLimits, measuredAt, src }, one per source:
17// measuredAt is $.clock.now() ms when the figures arrived here (neither source gives a time of
18// its own), src is 'card' (the app's usage card) or 'response' (the last API response)
19let fromCard = null
20let fromResponse = null
21// Whether the last read of the card answered with figures; while it does, the card's reading is
22// this session's own and the response's is not used
23let cardOk = false
24// When the last read of the card started, for the throttle
25let cardAt = null
26// The read of the card in flight, which a second caller waits on instead of reading again
27let cardPending = null
28// A read of the card put off by the throttle, kept so a final session.end can stop it
29let cardLater = null
30// The reading drawn: own, or the stored one when that is newer
31let shown = null
32// Refreshes the countdowns; kept so a later session.start or session.end can stop it
33let ticker = null
34// register()'s options, defaults filled in
35let config = null
36// The last reading appended to the log, so a reading stored again (after another session's older
37// write landed last) is not logged twice
38let logged = null
39// Log appends run one after another, so two in one session never read the same old file
40let logQueue = Promise.resolve()
41// Whether this session has shown its one toast about the log
42let logWarned = false
43
44// session.end reasons after which the session is gone; /clear, /resume and logout keep it running
45const FINAL_REASONS = ['prompt_input_exit', 'other']
46const TICK_MS = 60_000
47// The $.store key holding the newest reading any session made
48const STORE_KEY = 'rateLimits'
49// Two resetsAt this close are the same window; the next window resets at least 5 hours later
50const SAME_WINDOW_MS = 3_600_000
51// Two resetsAt this close are the same figures: the card's carry milliseconds that may move
52// between reads
53const SAME_RESET_MS = 60_000
54
55// The desktop app's usage card, and the windows of it the band shows, by label
56const CARD = { server: 'ccd_session_mgmt', tool: 'get_usage' }
57// The same tool as the model calls it
58const CARD_TOOL = 'mcp__ccd_session_mgmt__get_usage'
59const CARD_KINDS = { '5-hour limit': 'five_hour', 'Weekly · all models': 'seven_day' }
60// The card is read at most once in this many milliseconds; a read asked for sooner runs when
61// they have passed
62const CARD_GAP_MS = 10_000
63
64const LABELS = { five_hour: '5h', seven_day: '7d', spend_limit: '$' }
65
66const DEFAULTS = { log: false, logDir: '~/.claude/usage-band/' }
67
68// Usage at or above these percentages turns the meter yellow, then red
69const WARNING_AT = 50
70const ERROR_AT = 80
71
72// The text bar's cells, at full length and shortened
73const BAR_CELLS = { full: 10, short: 5 }
74const METER_GAP = 3
75// The band's last columns, which the terminal may draw over
76const BAND_RESERVED_COLUMNS = 2
77// The SVG bar's size in pixels, at full length and shortened
78const SVG_BAR = { full: 96, short: 48, height: 10 }
79// Pixels taken as one cell, to size the SVG bar in cells (an estimate: the desktop app does not
80// report its font's advance)
81const PX_PER_CELL = 8
82const SVG_COLORS = { success: '#4caf50', warning: '#e0a526', error: '#e5534b', track: 'rgba(128,128,128,0.3)' }
83
84// How the band may give way, widest first
85const STEPS = [
86  { bar: 'full', hasResets: true, hasClock: true },
87  { bar: 'full', hasResets: true, hasClock: false },
88  { bar: 'full', hasResets: false, hasClock: false },
89  { bar: 'short', hasResets: false, hasClock: false },
90  { bar: null, hasResets: false, hasClock: false },
91]
92
93export function register(on, options) {
94  config = configOf(options)
95  // Fires again on an enable or a worker respawn, which may keep this module's variables
96  on('session.start', async ($, e, next) => {
97    ticker?.cancel()
98    await readUsage($)
99    await readCard($)
100    await sync($)
101    // Counts the resets down, polls this session's figures and picks up the stored ones
102    ticker = $.clock.every(TICK_MS, () => void refresh($))
103    $.ui.invalidate('ui.render')
104    return next(e)
105  })
106
107  // The desktop app (or another client) joined the session: read the figures again
108  on('session.attach', async ($, e, next) => {
109    await refresh($)
110    return next(e)
111  })
112
113  on('session.end', async ($, e, next) => {
114    if (FINAL_REASONS.includes(e.reason)) {
115      ticker?.cancel()
116      cardLater?.cancel()
117      cardLater = null
118    }
119    return next(e)
120  })
121
122  // session.measure reports a changed context only after the next turn, so read it now
123  on('classic.SessionStart', { source: ['clear', 'resume', 'fork', 'compact'] }, async ($, e, next) => {
124    await readUsage($)
125    await readCard($)
126    await sync($)
127    $.ui.invalidate('ui.render')
128    return next(e)
129  })
130
131  // Fires after each turn, and when a rate-limit window moves a whole point
132  on('session.measure', async ($, e, next) => {
133    context = e.context
134    fromResponse = taken(fromResponse, e.rateLimits, await $.clock.now(), 'response')
135    // The response moved, so the account's usage did: the card has likely moved too
136    if (e.changed?.includes('rateLimits')) await readCard($)
137    await sync($)
138    $.ui.invalidate('ui.render')
139    return next(e)
140  })
141
142  // The model asked the app for its usage: the band takes the same answer. The call itself is
143  // passed on and its result returned as is.
144  on('tool.call', { tool: CARD_TOOL }, async ($, e, next) => {
145    const answer = await next(e)
146    await takeCardAnswer($, answer)
147    return answer
148  })
149
150  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
151    const elements = $.ui.resolve(e)
152    const now = await $.clock.now()
153    const meters = [{ label: 'ctx', used: context?.percent, resetsAt: null }]
154    for (const limit of shown?.rateLimits ?? []) {
155      const resetsAt = limit.resetsAt == null ? null : Date.parse(limit.resetsAt)
156      // A window that reset since the last measurement starts again from zero
157      const reset = resetsAt != null && resetsAt <= now
158      meters.push({
159        label: LABELS[limit.kind] ?? limit.kind,
160        used: reset ? 0 : limit.percentUsed,
161        resetsAt: reset ? null : resetsAt,
162      })
163    }
164    for (const m of meters) {
165      m.percent = percentText(m.used)
166      m.reset = m.resetsAt == null ? null : untilReset(m.resetsAt - now)
167    }
168    const stamp = shown && shown.rateLimits.length > 0 ? clockText(shown.measuredAt) : null
169    const fit = fitBand(meters, stamp, e.surface, widthOf(e))
170
171    const children = meters.map((m) => meter(elements, e.surface, fit, m))
172    // When the shown rate-limit figures were measured, by whichever session
173    if (stamp && fit.hasClock) {
174      const time = elements.Text({ dimColor: true, wrap: 'truncate-end', children: [stamp] })
175      children.push(elements.Box({ key: 'rate-time', flexDirection: 'row', flexShrink: 0, alignItems: 'center', children: [time] }))
176    }
177    const line = elements.Box({ flexDirection: 'row', flexWrap: 'nowrap', columnGap: METER_GAP, children })
178    // Keep what later mods draw in the band
179    const rest = await next(e)
180    if (!rest) return line
181    return elements.Box({ flexDirection: 'column', children: [line, rest] })
182  })
183}
184
185async function readUsage($) {
186  const usage = await $.session.usage()
187  context = usage.context
188  fromResponse = taken(fromResponse, usage.rateLimits, await $.clock.now(), 'response')
189}
190
191// Polls this session's figures, syncs with the store and redraws; a failed read keeps the last
192// figures
193async function refresh($) {
194  try {
195    await readUsage($)
196  } catch {}
197  await readCard($)
198  await sync($)
199  $.ui.invalidate('ui.render')
200}
201
202// Reads the app's usage card into fromCard, at most once per CARD_GAP_MS: a read asked for sooner
203// is put off until then (one at a time), and a caller during a read waits on that read. Never
204// throws; a card that does not answer with figures (no such server, as in the terminal, an
205// error, or a status other than ok) leaves cardOk false, so the response's figures are used.
206function readCard($) {
207  if (cardPending) return cardPending
208  cardPending = (async () => {
209    try {
210      const now = await $.clock.now()
211      if (cardAt != null && now - cardAt < CARD_GAP_MS) {
212        cardLater ??= $.clock.after(cardAt + CARD_GAP_MS - now, () => {
213          cardLater = null
214          void refreshCard($)
215        })
216        return
217      }
218      cardAt = now
219      const limits = await cardLimits($)
220      cardOk = limits != null
221      if (cardOk) fromCard = taken(fromCard, limits, await $.clock.now(), 'card')
222    } catch {
223      cardOk = false
224    }
225  })().finally(() => {
226    cardPending = null
227  })
228  return cardPending
229}
230
231// The read the throttle put off: reads the card, syncs and redraws
232async function refreshCard($) {
233  await readCard($)
234  await sync($)
235  $.ui.invalidate('ui.render')
236}
237
238// The model called the card's tool itself: takes what it answered as a read of the card, so the
239// band shows the figures Claude reports. Silent on anything it cannot use.
240async function takeCardAnswer($, answer) {
241  try {
242    if (!answer || answer.isError || answer.deny != null) return
243    const text = typeof answer.text === 'string' ? answer.text : firstText(answer.result?.content ?? answer.result)
244    const limits = cardLimitsOf(text)
245    if (!limits) return
246    const now = await $.clock.now()
247    cardAt = now
248    cardOk = true
249    fromCard = taken(fromCard, limits, now, 'card')
250    await sync($)
251    $.ui.invalidate('ui.render')
252  } catch {}
253}
254
255// The card's 5h and weekly windows as rate limits, read with $.mcp.call; null when the card
256// cannot be read or has neither window
257async function cardLimits($) {
258  let result
259  try {
260    result = await $.mcp.call(CARD.server, CARD.tool, {})
261  } catch {
262    return null
263  }
264  if (!result || result.isError) return null
265  return cardLimitsOf(firstText(result.content))
266}
267
268// The text of the first text block among MCP content blocks; undefined when there is none
269function firstText(blocks) {
270  if (!Array.isArray(blocks)) return undefined
271  return blocks.find((b) => b?.type === 'text' && typeof b.text === 'string')?.text
272}
273
274// The card's 5h and weekly windows from get_usage's text, percentUsed and resetsAt as given; null
275// when the text is not the card's JSON, its status is not ok, or it has neither window
276function cardLimitsOf(text) {
277  if (typeof text !== 'string') return null
278  let data
279  try {
280    data = JSON.parse(text)
281  } catch {
282    return null
283  }
284  const plan = data?.plan
285  if (plan?.status !== 'ok' || !Array.isArray(plan.windows)) return null
286  const limits = []
287  for (const w of plan.windows) {
288    const kind = Object.hasOwn(CARD_KINDS, w?.label) ? CARD_KINDS[w.label] : null
289    if (!kind || typeof w.percentUsed !== 'number' || limits.some((l) => l.kind === kind)) continue
290    const limit = { kind, percentUsed: w.percentUsed }
291    if (typeof w.resetsAt === 'string') limit.resetsAt = w.resetsAt
292    limits.push(limit)
293  }
294  return limits.length > 0 ? limits : null
295}
296
297// `limits` as a new reading from `src`, or `prev` when they are empty or the same figures, so
298// measuredAt says how old the figures are, not when they were last asked for
299function taken(prev, limits, now, src) {
300  if (!Array.isArray(limits) || limits.length === 0) return prev
301  if (prev && sameFigures(prev.rateLimits, limits)) return prev
302  return { rateLimits: limits, measuredAt: now, src }
303}
304
305// Whether two lists of windows say the same: the same kinds and percentages in the same order,
306// and resets within SAME_RESET_MS of each other
307function sameFigures(a, b) {
308  if (a.length !== b.length) return false
309  return a.every((x, i) => {
310    const y = b[i]
311    if (x.kind !== y.kind || x.percentUsed !== y.percentUsed) return false
312    if (x.resetsAt == null || y.resetsAt == null) return x.resetsAt == null && y.resetsAt == null
313    return Math.abs(Date.parse(x.resetsAt) - Date.parse(y.resetsAt)) < SAME_RESET_MS
314  })
315}
316
317// This session's own reading: the card's while the card answers, else the response's
318function ownReading() {
319  return cardOk && fromCard ? fromCard : fromResponse
320}
321
322// Publishes this session's reading when it is newer than the stored one, and shows the newer of
323// the two; while the card answers, shows the card's reading whatever is stored, since the band
324// is to match the app and a stored response reading may run ahead of the card. Read, compare, write: two sessions syncing at once may both write, and the later
325// write wins even if it is the older reading. That loses at most a moment's difference, and the
326// session holding the newer reading writes it again on its next sync (within a minute).
327async function sync($) {
328  const own = ownReading()
329  let stored = null
330  try {
331    stored = asReading(await $.store.get(STORE_KEY))
332  } catch {}
333  if (own && newer(own, stored) === own) {
334    try {
335      await $.store.set(STORE_KEY, own)
336    } catch {}
337    if (config.log && own !== logged) {
338      const reading = own
339      logged = reading
340      logQueue = logQueue.then(() => appendLog($, reading))
341      await logQueue
342    }
343  }
344  shown = cardOk && fromCard ? fromCard : newer(own, stored)
345}
346
347// register()'s options; a value of the wrong type keeps the default
348function configOf(options) {
349  const logDir = options?.logDir
350  return {
351    log: typeof options?.log === 'boolean' ? options.log : DEFAULTS.log,
352    logDir: typeof logDir === 'string' && logDir.trim() !== '' ? logDir.trim() : DEFAULTS.logDir,
353  }
354}
355
356// Appends one line for `reading` to the day's file. $.fs has no append, so the day's file is
357// read whole and written back. Never throws: a failure shows one toast per session at most.
358// Two sessions appending at the same moment could lose a line; only the session holding a newer
359// reading writes, so that takes two new readings within one read and write.
360async function appendLog($, reading) {
361  try {
362    const now = await $.clock.now()
363    const dir = await logDirPath($)
364    if (dir == null) {
365      warnLog($, 'usage-band: ホームフォルダが分からないため、使用率の記録を書けません。usage-band の設定「記録の置き場」(logDir)にフォルダを指定してください')
366      return
367    }
368    const path = dir + '/' + logFileName(now)
369    let text = ''
370    // A file that exists but cannot be read is left alone rather than overwritten
371    if (await $.fs.exists(path)) text = await $.fs.read(path)
372    if (text !== '' && !text.endsWith('\n')) text += '\n'
373    await $.fs.write(path, text + logLine(reading, now) + '\n')
374  } catch (error) {
375    warnLog($, 'usage-band: 使用率の記録を書けませんでした(' + String(error?.message ?? error) + ')')
376  }
377}
378
379function warnLog($, text) {
380  if (logWarned) return
381  logWarned = true
382  $.ui.toast(text)
383}
384
385// logDir as an absolute path with forward slashes and no trailing slash; a leading `~` is the
386// home folder (USERPROFILE on Windows, else HOME). null when `~` is used and neither is set.
387async function logDirPath($) {
388  let dir = config.logDir.replace(/\\/g, '/')
389  if (dir === '~' || dir.startsWith('~/')) {
390    const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
391    if (!home) return null
392    dir = home.replace(/\\/g, '/').replace(/\/+$/, '') + dir.slice(1)
393  }
394  return dir.length > 1 ? dir.replace(/\/+$/, '') : dir
395}
396
397// usage-YYYYMMDD.jsonl, by the local date
398function logFileName(ms) {
399  const d = new Date(ms)
400  const pad = (n) => String(n).padStart(2, '0')
401  return 'usage-' + d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) + '.jsonl'
402}
403
404// One JSON object: ts (when written), src (where the figures came from: 'card' or 'response')
405// and, per window kind, the raw percentage and reset time
406function logLine(reading, now) {
407  const line = { ts: new Date(now).toISOString(), src: reading.src ?? 'response' }
408  for (const limit of reading.rateLimits) {
409    line[limit.kind] = { used: limit.percentUsed, resets_at: limit.resetsAt ?? null }
410  }
411  return JSON.stringify(line)
412}
413
414// A stored value as a reading, or null when it is not one (unset, or written by something else)
415function asReading(value) {
416  if (!value || !Array.isArray(value.rateLimits) || typeof value.measuredAt !== 'number') return null
417  return value
418}
419
420// The newer of two readings; b when they tie. The figures decide first: a percentage never falls
421// within a window, so a reading behind the other is older whatever its time says (a stale reading
422// stamped late, such as the one a re-fired session.start takes in an idle session). The time
423// decides otherwise.
424function newer(a, b) {
425  if (!a) return b
426  if (!b) return a
427  const aBehind = isBehind(a.rateLimits, b.rateLimits)
428  const bBehind = isBehind(b.rateLimits, a.rateLimits)
429  if (aBehind !== bBehind) return aBehind ? b : a
430  return a.measuredAt > b.measuredAt ? a : b
431}
432
433// Whether some window in a is an earlier window than in b, or the same window used less
434function isBehind(a, b) {
435  return a.some((x) => {
436    const y = b.find((l) => l.kind === x.kind)
437    if (!y || x.resetsAt == null || y.resetsAt == null) return false
438    const gap = Date.parse(y.resetsAt) - Date.parse(x.resetsAt)
439    if (gap > SAME_WINDOW_MS) return true
440    return Math.abs(gap) <= SAME_WINDOW_MS && x.percentUsed < y.percentUsed
441  })
442}
443
444function statusOf(used) {
445  if (used >= ERROR_AT) return 'error'
446  if (used >= WARNING_AT) return 'warning'
447  return 'success'
448}
449
450function percentText(used) {
451  return typeof used === 'number' ? Math.round(used) + '%' : '—'
452}
453
454// The cells across the band: the site's own width, else what the surface measured; null when
455// neither is known
456function widthOf(e) {
457  const n = e.props?.bodyColumns
458  if (typeof n === 'number' && n > 0) return n
459  const v = e.viewport?.columns
460  return typeof v === 'number' && v > 0 ? v : null
461}
462
463// Character cells a text takes: two for a wide (CJK, full-width) character, one otherwise
464function cells(text) {
465  let n = 0
466  for (const ch of String(text)) {
467    const c = ch.codePointAt(0)
468    n += c >= 0x1100 && (c <= 0x115f || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6)) ? 2 : 1
469  }
470  return n
471}
472
473// The cells a bar takes at a length ('full', 'short')
474function barCells(surface, length) {
475  return surface === 'desktop' ? Math.ceil(SVG_BAR[length] / PX_PER_CELL) : BAR_CELLS[length]
476}
477
478// The first step of STEPS whose parts fit on one line in `columns`: everything when the width
479// is unknown, the narrowest step when nothing fits
480function fitBand(meters, stamp, surface, columns) {
481  if (columns == null) return STEPS[0]
482  const room = columns - BAND_RESERVED_COLUMNS
483  for (const step of STEPS) {
484    let width = METER_GAP * (meters.length - 1)
485    for (const m of meters) {
486      width += cells(m.label) + 1 + cells(m.percent)
487      if (step.bar) width += 1 + barCells(surface, step.bar)
488      if (step.hasResets && m.reset) width += 1 + cells(m.reset)
489    }
490    if (step.hasClock && stamp) width += METER_GAP + cells(stamp)
491    if (width <= room) return step
492  }
493  return STEPS[STEPS.length - 1]
494}
495
496// Local time as HH:MM
497function clockText(ms) {
498  const d = new Date(ms)
499  return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
500}
501
502// One meter: the label, the bar as `fit` says, and the percentage with its countdown when `fit`
503// keeps it. The Box does not shrink, so its parts never wrap or slide under the next meter.
504function meter({ Box, Text, Svg }, surface, fit, { label, used, percent, reset }) {
505  const known = typeof used === 'number'
506  const status = known ? statusOf(used) : null
507  const value = fit.hasResets && reset ? percent + ' ' + reset : percent
508  const children = [Text({ wrap: 'truncate-end', children: [label] })]
509  if (fit.bar && surface === 'desktop') {
510    const width = SVG_BAR[fit.bar]
511    children.push(
512      Svg({
513        source: svgBar(known ? used : 0, status, width),
514        alt: label + ' ' + percent + (reset ? ' ' + reset : ''),
515        width,
516        height: SVG_BAR.height,
517      }),
518    )
519  } else if (fit.bar) {
520    children.push(textBar(Text, known ? used : 0, status, BAR_CELLS[fit.bar]))
521  }
522  children.push(Text({ wrap: 'truncate-end', ...(known ? { color: status } : { dimColor: true }), children: [value] }))
523  return Box({ key: 'meter-' + label, flexDirection: 'row', flexShrink: 0, columnGap: 1, alignItems: 'center', children })
524}
525
526// Used cells in the status color, the rest dim, nested in one Text so they stay on one line
527function textBar(Text, used, status, total) {
528  const filled = Math.round((clamp(used) / 100) * total)
529  return Text({
530    wrap: 'truncate-end',
531    children: [
532      Text({ ...(status ? { color: status } : { dimColor: true }), children: ['█'.repeat(filled)] }),
533      Text({ dimColor: true, children: ['░'.repeat(total - filled)] }),
534    ],
535  })
536}
537
538function svgBar(used, status, width) {
539  const { height } = SVG_BAR
540  const fill = Math.round((clamp(used) / 100) * width)
541  return [
542    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
543    `<clipPath id="c"><rect width="${width}" height="${height}" rx="${height / 2}"/></clipPath>`,
544    `<g clip-path="url(#c)">`,
545    `<rect width="${width}" height="${height}" fill="${SVG_COLORS.track}"/>`,
546    fill > 0 ? `<rect width="${fill}" height="${height}" fill="${SVG_COLORS[status ?? 'success']}"/>` : '',
547    '</g></svg>',
548  ].join('')
549}
550
551function clamp(percent) {
552  return Math.min(Math.max(percent, 0), 100)
553}
554
555function untilReset(ms) {
556  const minutes = Math.max(0, Math.ceil(ms / 60_000))
557  const days = Math.floor(minutes / 1440)
558  const hours = Math.floor((minutes % 1440) / 60)
559  if (days > 0) return days + 'd' + hours + 'h'
560  if (hours > 0) return hours + 'h' + (minutes % 60) + 'm'
561  return (minutes % 60) + 'm'
562}
563