SLOPSHOPPER

ccburn

ccburn's burn-up chart for your Claude Code session and weekly limits, in a pane inside Claude Code.

newpanecommandprocesstimer
v0.1.0MITupdated 2026-10-06JuanjoFuchs/ccburn-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ccburn
│ ┃ ccburn ✕ › fix the failing auth test and add an audit log call │ ┃ 🔥 ccburn - Session (5h) w: Show Weekly ⏳ │ ┃ 📊 Usage ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ⏺ Read(src/auth.ts) │ ┃ ⏳ Elapsed ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ⎿ Read 6 lines │ ┃ Waiting for Claude Code's first rate-limit ⏺ Update(src/auth.ts) │ ┃ reading; it arrives with the next reply. ⎿ 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 │ │ › /ccburn │ ⎿ ccburn: Session (5h) chart open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ccburn
🔥 ccburn - Session (5h) w: Show Weekly ⏳ Loading... 📊 Usage ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ --% ⏳ Elapsed ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ --% Waiting for Claude Code's first rate-limit reading; it arrives with the next reply.
README

🔥 ccburn-mod

CI GitHub Release Claude Code mod Works with ccburn Last commit License

<img src="docs/cash1.png" alt="Burning tokens" width="60">

<strong>Watch your tokens burn, right where you spend them.</strong>

ccburn's burn-up chart, inside Claude Code: your 5-hour and weekly limits, your pace, and when you would run out, in a pane beside your conversation.

<img src="docs/screenshot.png" alt="ccburn-mod in a Claude Code pane">

Install

claude plugin marketplace add JuanjoFuchs/ccburn-mod
claude plugin install ccburn@ccburn-mod

Then, in any Claude Code session:

/ccburn          # the 5-hour session window
/ccburn weekly   # the 7-day window

Features

  • The real ccburn chart: budget pace, your usage, the projection to the reset, Now and the moment you would run out. A TypeScript port held to ccburn's own output cell for cell by golden tests.
  • Pace at a glance: 🧊 Cool. 🔥 On pace. 🚨 Too hot. Plus Usage and Elapsed gauges.
  • No setup: Claude Code hands the mod its rate-limit readings, so there are no API calls, no credentials and nothing else to install.
  • History: readings are kept between sessions, so the chart and the burn rate reach back past the current one.
  • Better with ccburn: with ccburn installed, the chart follows every session on your account, even while this one is idle (below).
  • Fits the room it gets: docked full height beside the transcript in fullscreen mode, or inline above the prompt. The w: Show Weekly toggle sits in the title row.

With ccburn installed

Claude Code only tells a session about its own replies, so on its own the pane's usage line moves when that session is working. If you also run ccburn 0.8.0 or later with ccburn collect in your status line, ccburn records every session's readings, and the mod reads that history (ccburn history --json) once a minute: the chart then follows your whole account even while this session is idle. The mod also adds its own readings to ccburn's history through ccburn collect, so ccburn's chart benefits too. Turn it off with the useCcburn setting.

Requirements

  • A Claude Code build with mods (built against 2.1.289). The mod API is early access, so a future Claude Code release may need an update here.
  • A Pro, Max or Team subscription. Claude Code reports no rate-limit windows for Enterprise or API accounts, so the pane says there is nothing to chart. For Enterprise monthly credits, use ccburn itself.

Settings

SettingDefault
openOnStarttrueOpen the pane when a session starts. Claude Code only seats a pane nobody asked for on a wide terminal (144+ columns); otherwise run /ccburn.
useCcburntrueShare history with the ccburn CLI when it is installed (see above).

Change them in /config or with claude plugin configure ccburn.

Development

claude --plugin-dir .        # run Claude Code with this checkout loaded (hot-reloads on save)
claude plugin validate .     # what the engine sees and would refuse
claude plugin test .         # unit, golden, pane and ccburn-sharing tests

The golden charts in tests/fixtures/golden/ are rendered by the real ccburn. Regenerate them with ccburn checked out next to this repo:

PYTHONHASHSEED=0 ../ccburn/.venv/bin/python tools/make-goldens.py

Specs live in specs/; agents start from AGENTS.md.

Related

  • ccburn: the TUI and CLI this chart comes from.

License

MIT

Source 13 files
hooks/register.tsx 237 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { Reading, WindowKind } from '../types'
5import { machineOffset } from './core/clock'
6import { collectStdin, COLLECT_ARGV, HISTORY_ARGV, parseHistory } from './core/ccburn'
7import { DISPLAY_NAME } from './core/gauges'
8import { fromStore, limitOf, merge, readingsOf, snapshotsOf } from './core/readings'
9import { drawPane, INLINE_ROWS } from './views/pane'
10
11const PANE = 'ccburn'
12const TITLE = 'ccburn'
13const STORE_KEY = 'readings'
14const TICK_MS = 60_000
15const CCBURN_TIMEOUT_MS = 10_000
16/**
17 * Body rows to ask for when the pane sits inline above the prompt (a terminal
18 * that is not fullscreen): the header, gauges, chart and toggle row. A docked
19 * pane ignores it and runs floor to ceiling.
20 */
21const OPEN = { id: PANE, title: TITLE, rows: INLINE_ROWS } as const
22
23const windowAtom = atom({ plugin: 'ccburn', key: 'window' } as const, 'five_hour' as WindowKind)
24const readingsAtom = atom({ plugin: 'ccburn', key: 'readings' } as const, [] as Reading[])
25const nowAtom = atom({ plugin: 'ccburn', key: 'now' } as const, 0)
26const withoutLimitsAtom = atom({ plugin: 'ccburn', key: 'isWithoutLimits' } as const, false)
27
28/** `weekly` (or `week`, `7d`) picks the weekly window; nothing or `session` the 5-hour one. */
29function windowOfArgs(args: string): WindowKind | null {
30  const arg = args.trim().toLowerCase()
31
32  if (arg === '' || arg === 'session' || arg === '5h') {
33    return 'five_hour'
34  }
35
36  return arg === 'weekly' || arg === 'week' || arg === '7d' ? 'seven_day' : null
37}
38
39/** Merges new readings into the shared store, then pulls in what other sessions wrote. */
40async function save($: Engine, fresh: readonly Reading[]): Promise<void> {
41  const now = await $.clock.now()
42  const stored = fromStore(await $.store.get(STORE_KEY))
43  const merged = merge(stored, fresh, now)
44  await $.store.set(STORE_KEY, merged)
45  await update($, readingsAtom, current => merge(current, merged, now))
46}
47
48/**
49 * Inline room: the body rows Claude Code gives the pane above the prompt.
50 * Unknown (null) until the pane has drawn INLINE_ROWS once and been given
51 * fewer; reset whenever the pane is opened, so a new layout is measured again.
52 */
53let inlineRoom: number | null = null
54let inlineDrawn: number | null = null
55
56/** How tall to draw inline, given the body rows the engine reports now. */
57function inlineHeight(bodyRows: number): number {
58  if (inlineDrawn !== null && bodyRows < inlineDrawn) {
59    inlineRoom = bodyRows
60  } else if (inlineRoom !== null && bodyRows > inlineRoom) {
61    inlineRoom = bodyRows
62  }
63
64  inlineDrawn = inlineRoom ?? INLINE_ROWS
65
66  return inlineDrawn
67}
68
69/** Forgets the measured room and redraws shortly, so the pane measures it again. */
70function remeasure($: Engine): void {
71  inlineRoom = null
72  inlineDrawn = null
73  $.clock.after(250, () => {
74    void tick($).catch(() => undefined)
75  })
76}
77
78/**
79 * Spec 002: share history with ccburn while it answers. Set from `useCcburn`
80 * at session start; a failed call turns it off until the next session.
81 */
82let isCcburnUp = true
83
84/** Pulls ccburn's history (every session's readings) into what the pane draws. */
85async function readCcburn($: Engine): Promise<void> {
86  if (!isCcburnUp) {
87    return
88  }
89
90  const result = await $.process.run(HISTORY_ARGV, { timeoutMs: CCBURN_TIMEOUT_MS })
91  const shared = result.exitCode === 0 ? parseHistory(result.stdout) : null
92
93  if (shared === null) {
94    isCcburnUp = false
95    return
96  }
97
98  const now = await $.clock.now()
99  await update($, readingsAtom, current => merge(current, shared, now))
100}
101
102/** Hands this session's readings to ccburn's history, as the status line would. */
103async function writeCcburn($: Engine, fresh: readonly Reading[]): Promise<void> {
104  if (!isCcburnUp) {
105    return
106  }
107
108  const result = await $.process.run(COLLECT_ARGV, { stdin: collectStdin(fresh), timeoutMs: CCBURN_TIMEOUT_MS })
109  if (result.exitCode !== 0) {
110    isCcburnUp = false
111  }
112}
113
114/** The minute tick: ccburn's history first, then the clock the chart is drawn against. */
115async function tick($: Engine): Promise<void> {
116  try {
117    await readCcburn($)
118  } catch {
119    isCcburnUp = false
120  }
121
122  const at = await $.clock.now()
123  await update($, nowAtom, () => at)
124}
125
126/** After a measured reading: this plugin's store, then ccburn's history. */
127async function persist($: Engine, fresh: readonly Reading[]): Promise<void> {
128  await save($, fresh)
129
130  try {
131    await writeCcburn($, fresh)
132  } catch {
133    isCcburnUp = false
134  }
135}
136
137export const register: Register = (on, options) => {
138  on('session.start', async ($, e, next) => {
139    isCcburnUp = options.useCcburn !== false
140
141    await $.command.register({
142      name: 'ccburn',
143      description: "Show ccburn's burn-up chart for your rate limits (add 'weekly' for the 7-day window)",
144      argumentHint: '[weekly]',
145    })
146
147    const now = await $.clock.now()
148    const stored = fromStore(await $.store.get(STORE_KEY))
149    const usage = await $.session.usage()
150    const fresh = readingsOf(usage.rateLimits, now)
151    await update($, readingsAtom, () => merge(stored, fresh, now))
152    await update($, nowAtom, () => now)
153
154    if (fresh.length > 0) {
155      void save($, fresh).catch(() => undefined)
156    }
157
158    // The first read of ccburn runs at once, off this hook; then every minute with the clock.
159    $.clock.after(0, () => {
160      void tick($).catch(() => undefined)
161    })
162    $.clock.every(TICK_MS, () => {
163      void tick($).catch(() => undefined)
164    })
165
166    if (options.openOnStart !== false) {
167      remeasure($)
168      void $.ui.open(OPEN).catch(() => undefined)
169    }
170
171    return next(e)
172  })
173
174  on('session.measure', async ($, e, next) => {
175    const now = await $.clock.now()
176    const fresh = readingsOf(e.rateLimits, now)
177
178    if (fresh.length > 0) {
179      await update($, withoutLimitsAtom, () => false)
180      await update($, readingsAtom, current => merge(current, fresh, now))
181      // The store write runs on a timer so this hook never waits on it.
182      $.clock.after(0, () => {
183        void persist($, fresh).catch(() => undefined)
184      })
185    } else if (e.rateLimits.length === 0) {
186      await update($, withoutLimitsAtom, () => true)
187    }
188
189    await update($, nowAtom, () => now)
190
191    return next(e)
192  })
193
194  on('command.run', { command: 'ccburn' }, async ($, e) => {
195    const kind = windowOfArgs(e.args)
196
197    if (kind === null) {
198      return { text: 'Usage: /ccburn [weekly]' }
199    }
200
201    await update($, windowAtom, () => kind)
202    remeasure($)
203    const opened = await $.ui.open(OPEN)
204
205    // The engine already prefixes a command's output with the plugin's name.
206    return {
207      text: opened.isPlaced ? `${DISPLAY_NAME[kind]} chart open.` : `The pane is waiting for room (${opened.reason}).`,
208    }
209  })
210
211  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
212    const kind = await read($, windowAtom)
213    const readings = await read($, readingsAtom)
214    const isWithoutLimits = await read($, withoutLimitsAtom)
215    await read($, nowAtom)
216    const now = await $.clock.now()
217
218    return drawPane($.ui.resolve(e), {
219      kind,
220      limit: limitOf(readings, kind),
221      snapshots: snapshotsOf(readings, kind),
222      isWithoutLimits,
223      now,
224      columns: Math.floor(e.props.bodyColumns),
225      rows:
226        e.props.placement === 'inline'
227          ? inlineHeight(Math.floor(e.props.scroll.bodyRows))
228          : Math.floor(e.props.scroll.bodyRows),
229      placement: e.props.placement,
230      offsetAt: machineOffset,
231      onToggle: () => {
232        void update($, windowAtom, current => (current === 'five_hour' ? 'seven_day' : 'five_hour'))
233      },
234    })
235  })
236}
237
hooks/core/clock.ts 72 lines
1/**
2 * Local-time formatting as ccburn's `strftime` calls produce it. Every
3 * function takes the UTC offset in minutes, so a test pins the time zone and
4 * the mod passes the machine's.
5 */
6
7const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
8
9/** Minutes east of UTC at `ms`, for the machine the mod runs on. */
10export type OffsetAt = (ms: number) => number
11
12/** The machine's own offset, as Python's `astimezone()` would apply it. */
13export const machineOffset: OffsetAt = ms => -new Date(ms).getTimezoneOffset()
14
15export type LocalTime = {
16  year: number
17  month: number
18  day: number
19  hour: number
20  minute: number
21  second: number
22  weekday: string
23}
24
25export function localTime(ms: number, offsetAt: OffsetAt): LocalTime {
26  const shifted = new Date(ms + offsetAt(ms) * 60_000)
27
28  return {
29    year: shifted.getUTCFullYear(),
30    month: shifted.getUTCMonth() + 1,
31    day: shifted.getUTCDate(),
32    hour: shifted.getUTCHours(),
33    minute: shifted.getUTCMinutes(),
34    second: shifted.getUTCSeconds(),
35    weekday: DAYS[shifted.getUTCDay()] ?? 'Sun',
36  }
37}
38
39const pad2 = (n: number): string => String(n).padStart(2, '0')
40
41/** `%H:%M` */
42export const hourMinute = (t: LocalTime): string => `${pad2(t.hour)}:${pad2(t.minute)}`
43
44/** `%a %Hh` */
45export const dayHour = (t: LocalTime): string => `${t.weekday} ${pad2(t.hour)}h`
46
47/** `{month}/{day}` */
48export const monthDay = (t: LocalTime): string => `${t.month}/${t.day}`
49
50/** `{month}/{day} {hour}h` */
51export const monthDayHour = (t: LocalTime): string => `${t.month}/${t.day} ${t.hour}h`
52
53const hour12 = (t: LocalTime): number => (t.hour % 12 === 0 ? 12 : t.hour % 12)
54const meridiem = (t: LocalTime): string => (t.hour < 12 ? 'AM' : 'PM')
55
56/** `strftime("%I:%M %p").lstrip("0")` */
57export const clockTime = (t: LocalTime): string => `${hour12(t)}:${pad2(t.minute)} ${meridiem(t)}`
58
59/** `strftime("%I%p").lstrip("0")` */
60export const clockHour = (t: LocalTime): string => `${hour12(t)}${meridiem(t)}`
61
62/**
63 * ccburn's tick rounding: drop seconds, adding a minute when the seconds
64 * were 30 or more. Returns the rounded instant in epoch milliseconds.
65 */
66export function roundToMinute(ms: number): number {
67  const seconds = Math.floor((((ms % 60_000) + 60_000) % 60_000) / 1000)
68  const floored = Math.floor(ms / 60_000) * 60_000
69
70  return seconds >= 30 ? floored + 60_000 : floored
71}
72
hooks/core/ccburn.ts 69 lines
1/**
2 * The two contracts the mod shares history through when ccburn is installed
3 * (spec 002): `ccburn history --json` to read, `ccburn collect` to write.
4 */
5
6import type { Reading } from '../../types'
7import { PROVIDER } from './readings'
8
9export const HISTORY_ARGV = ['ccburn', 'history', '--json', '--changes-only', '--since-hours', '168'] as const
10export const COLLECT_ARGV = ['ccburn', 'collect'] as const
11
12const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
13
14/**
15 * Readings from `ccburn history --json` output, or null when the output is not
16 * that contract (wrong version, not JSON): the caller then treats ccburn as
17 * unavailable. Malformed snapshots and limits are skipped.
18 */
19export function parseHistory(stdout: string): Reading[] | null {
20  let data: unknown
21
22  try {
23    data = JSON.parse(stdout)
24  } catch {
25    return null
26  }
27
28  if (!isObject(data) || data.version !== 1 || !Array.isArray(data.snapshots)) {
29    return null
30  }
31
32  const readings: Reading[] = []
33
34  for (const snapshot of data.snapshots) {
35    if (!isObject(snapshot) || typeof snapshot.timestamp !== 'string' || !isObject(snapshot.limits)) {
36      continue
37    }
38
39    const timestamp = Date.parse(snapshot.timestamp)
40    if (!Number.isFinite(timestamp)) {
41      continue
42    }
43
44    for (const [kind, limit] of Object.entries(snapshot.limits)) {
45      if (!isObject(limit) || typeof limit.used_percentage !== 'number' || typeof limit.resets_at !== 'string') {
46        continue
47      }
48
49      const resetsAt = Date.parse(limit.resets_at)
50      if (Number.isFinite(resetsAt)) {
51        readings.push({ provider: PROVIDER, kind, timestamp, percentUsed: limit.used_percentage, resetsAt })
52      }
53    }
54  }
55
56  return readings
57}
58
59/** Status-line JSON for `ccburn collect`: `resets_at` in epoch seconds, as Claude Code sends it. */
60export function collectStdin(readings: readonly Reading[]): string {
61  const rateLimits: Record<string, { used_percentage: number; resets_at: number }> = {}
62
63  for (const r of readings) {
64    rateLimits[r.kind] = { used_percentage: r.percentUsed, resets_at: Math.round(r.resetsAt / 1000) }
65  }
66
67  return JSON.stringify({ rate_limits: rateLimits })
68}
69
hooks/core/gauges.ts 138 lines
1/**
2 * Ports of ccburn's header and gauge rows (`display/gauges.py`), as runs of
3 * styled text the pane draws with `Text`. Rich's named colours are given in
4 * the Windows Terminal "Campbell" palette, which is how ccburn reads there.
5 */
6
7import type { OffsetAt } from './clock'
8import { formatResetTime, utilizationColor, type UtilizationColor } from './format'
9import { budgetPace, effectiveUtilization, paceEmoji, type LimitData } from './metrics'
10import { toFixed } from './py'
11
12export type Run = { text: string; color?: string; bold?: boolean; dim?: boolean }
13
14export const RICH = {
15  magenta: '#881798',
16  cyan: '#3a96dd',
17  yellow: '#c19c00',
18  blue: '#0037da',
19  green: '#13a10e',
20  red: '#c50f1f',
21  bright_red: '#e74856',
22  grey37: '#5f5f5f',
23  /** Rich's default `bar.finished`, which ccburn's bars fall back to at 100 %. */
24  finished: '#729c1f',
25} as const
26
27const usageHex = (color: UtilizationColor): string => RICH[color]
28
29export const DISPLAY_NAME = { five_hour: 'Session (5h)', seven_day: 'Weekly' } as const
30
31/** Display width of a run of text: the emoji ccburn uses take two cells. */
32export function cellWidth(text: string): number {
33  let width = 0
34
35  for (const char of text) {
36    const code = char.codePointAt(0) ?? 0
37    width += code >= 0x1f000 || char === '⏰' || char === '⏳' ? 2 : 1
38  }
39
40  return width
41}
42
43export type Header = { left: Run[]; right: Run[] }
44
45/** `create_header` */
46export function header(name: string, limit: LimitData | null, now: number, offsetAt: OffsetAt): Header {
47  const emoji = limit ? paceEmoji(effectiveUtilization(limit, now), budgetPace(limit.resetsAt, limit.windowHours, now)) : '🔥'
48  const left: Run[] = [
49    { text: `${emoji} ` },
50    { text: 'ccburn', color: RICH.magenta, bold: true },
51    { text: ' - ', dim: true },
52    { text: name, color: RICH.cyan, bold: true },
53  ]
54  const right: Run[] = limit
55    ? [{ text: '⏰ ' }, { text: formatResetTime(limit.resetsAt, now, offsetAt), color: RICH.yellow }]
56    : [{ text: '⏳ Loading...', dim: true }]
57
58  return { left, right }
59}
60
61/** Rich's `ProgressBar` at `width` cells: `━` filled, `╸` half, `╺` boundary. */
62export function bar(width: number, completed: number, complete: string, back = RICH.grey37): Run[] {
63  const clamped = Math.min(100, Math.max(0, completed))
64  const halves = Math.trunc((width * 2 * clamped) / 100)
65  const bars = Math.floor(halves / 2)
66  const half = halves % 2
67  const fill = completed >= 100 ? RICH.finished : complete
68  const runs: Run[] = []
69
70  if (bars) {
71    runs.push({ text: '━'.repeat(bars), color: fill })
72  }
73
74  if (half) {
75    runs.push({ text: '╸', color: fill })
76  }
77
78  let remaining = width - bars - half
79  if (remaining > 0) {
80    if (!half && bars) {
81      runs.push({ text: '╺', color: back })
82      remaining -= 1
83    }
84
85    if (remaining > 0) {
86      runs.push({ text: '━'.repeat(remaining), color: back })
87    }
88  }
89
90  return runs
91}
92
93export type GaugeRow = { label: Run[]; bar: Run[]; value: Run[] }
94
95/** The label column, as ccburn draws it. */
96export const LABEL_WIDTH = 14
97
98/**
99 * The value column. ccburn reserves 18 for monthly dollar amounts
100 * (`$74.75 / $300.00`); the mod only shows percentages (`100%`), so it keeps
101 * 4 and gives the rest to the bars.
102 */
103export const VALUE_WIDTH = 4
104
105/** The width of the bar column in a gauge row `width` cells wide (one-cell gaps either side). */
106export const barWidth = (width: number): number => Math.max(1, width - LABEL_WIDTH - VALUE_WIDTH - 2)
107
108/** `create_gauge_section`: the Usage and Elapsed rows. */
109export function gauges(limit: LimitData | null, now: number, width: number): [GaugeRow, GaugeRow] {
110  const w = barWidth(width)
111
112  if (!limit) {
113    return [
114      { label: [{ text: '📊 Usage', dim: true }], bar: bar(w, 0, RICH.grey37), value: [{ text: '--%', dim: true }] },
115      { label: [{ text: '⏳ Elapsed', dim: true }], bar: bar(w, 0, RICH.grey37), value: [{ text: '--%', dim: true }] },
116    ]
117  }
118
119  const pace = budgetPace(limit.resetsAt, limit.windowHours, now)
120  const utilization = effectiveUtilization(limit, now)
121  const usagePercent = utilization * 100
122  const pacePercent = pace * 100
123  const color = usageHex(utilizationColor(utilization, pace))
124
125  return [
126    {
127      label: [{ text: '📊 ' }, { text: 'Usage', color, bold: true }],
128      bar: bar(w, usagePercent, color),
129      value: [{ text: `${toFixed(usagePercent, 0)}%`, color }],
130    },
131    {
132      label: [{ text: '⏳ ' }, { text: 'Elapsed', color: RICH.blue, bold: true }],
133      bar: bar(w, pacePercent, RICH.blue),
134      value: [{ text: `${toFixed(pacePercent, 0)}%`, color: RICH.blue }],
135    },
136  ]
137}
138
hooks/core/readings.ts 111 lines
1/**
2 * Readings: what the engine reports for each rate-limit window, kept as
3 * history so the chart and the burn rate reach back past this session.
4 */
5
6import type { SessionRateLimit } from 'claude-code'
7
8import type { Reading, WindowKind } from '../../types'
9import type { LimitData, Snapshot } from './metrics'
10
11export const PROVIDER = 'claude-code'
12
13/** Hours in each window the pane draws. */
14export const WINDOW_HOURS: Record<WindowKind, number> = { five_hour: 5, seven_day: 168 }
15
16/** How long a reading of a window the pane does not draw is kept. */
17const OTHER_KEEP_MS = 7 * 24 * 3_600_000
18
19export const MAX_READINGS = 5_000
20
21/** A repeat of the last reading of a window within this long is not stored again. */
22const REPEAT_MS = 60_000
23
24const keyOf = (r: Reading): string => `${r.provider}|${r.kind}|${r.timestamp}`
25
26/** The engine's rate limits at `now`, as readings. A window with no reset time is skipped. */
27export function readingsOf(rateLimits: readonly SessionRateLimit[], now: number): Reading[] {
28  return rateLimits.flatMap(limit => {
29    const resetsAt = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
30
31    return Number.isFinite(resetsAt) && Number.isFinite(limit.percentUsed)
32      ? [{ provider: PROVIDER, kind: limit.kind, timestamp: now, percentUsed: limit.percentUsed, resetsAt }]
33      : []
34  })
35}
36
37const windowMsOf = (kind: string): number =>
38  kind in WINDOW_HOURS ? WINDOW_HOURS[kind as WindowKind] * 3_600_000 : OTHER_KEEP_MS
39
40/**
41 * Merges two histories by `(provider, kind, timestamp)`, drops readings older
42 * than one window length (they cannot fall in the window being drawn), drops
43 * repeats, and keeps the newest `MAX_READINGS`. Oldest first.
44 */
45export function merge(a: readonly Reading[], b: readonly Reading[], now: number): Reading[] {
46  const byKey = new Map<string, Reading>()
47  for (const r of [...a, ...b]) {
48    byKey.set(keyOf(r), r)
49  }
50
51  const sorted = [...byKey.values()].sort((x, y) => x.timestamp - y.timestamp || x.kind.localeCompare(y.kind))
52  const lastOf = new Map<string, Reading>()
53  const kept: Reading[] = []
54
55  for (const r of sorted) {
56    if (r.timestamp < now - windowMsOf(r.kind)) {
57      continue
58    }
59
60    const last = lastOf.get(`${r.provider}|${r.kind}`)
61    const isRepeat =
62      last !== undefined &&
63      last.percentUsed === r.percentUsed &&
64      last.resetsAt === r.resetsAt &&
65      r.timestamp - last.timestamp < REPEAT_MS
66
67    if (isRepeat) {
68      continue
69    }
70
71    lastOf.set(`${r.provider}|${r.kind}`, r)
72    kept.push(r)
73  }
74
75  return kept.slice(-MAX_READINGS)
76}
77
78/** Parses whatever the store holds into readings, dropping anything malformed. */
79export function fromStore(value: unknown): Reading[] {
80  if (!Array.isArray(value)) {
81    return []
82  }
83
84  return value.filter(
85    (r): r is Reading =>
86      typeof r === 'object' &&
87      r !== null &&
88      typeof r.provider === 'string' &&
89      typeof r.kind === 'string' &&
90      typeof r.timestamp === 'number' &&
91      typeof r.percentUsed === 'number' &&
92      typeof r.resetsAt === 'number',
93  )
94}
95
96/** The latest reading of `kind` as the chart's limit, or null when there is none. */
97export function limitOf(readings: readonly Reading[], kind: WindowKind): LimitData | null {
98  for (let i = readings.length - 1; i >= 0; i -= 1) {
99    const r = readings[i]
100    if (r && r.kind === kind && r.provider === PROVIDER) {
101      return { utilization: r.percentUsed / 100, resetsAt: r.resetsAt, windowHours: WINDOW_HOURS[kind] }
102    }
103  }
104
105  return null
106}
107
108/** Every reading of `kind` as chart snapshots, oldest first. */
109export const snapshotsOf = (readings: readonly Reading[], kind: WindowKind): Snapshot[] =>
110  readings.filter(r => r.kind === kind && r.provider === PROVIDER).map(r => ({ timestamp: r.timestamp, utilization: r.percentUsed / 100 }))
111
hooks/views/pane.tsx 191 lines
1/**
2 * The pane, laid out as ccburn's full layout (`display/layout.py`): a header
3 * row, the Usage and Elapsed gauges, then the chart filling the rest, with a
4 * row for the window toggle at the bottom.
5 */
6
7import type { Elements, RenderElement, RenderNode } from 'claude-code'
8
9import type { WindowKind } from '../../types'
10import { chartCells } from '../core/chart'
11import type { OffsetAt } from '../core/clock'
12import { barWidth, cellWidth, DISPLAY_NAME, gauges, header, LABEL_WIDTH, VALUE_WIDTH, type Header, type Run } from '../core/gauges'
13import { burnRate, windowStart, type LimitData, type Snapshot } from '../core/metrics'
14import { packCells } from '../core/raster'
15
16/** The elements the pane draws with; `Raster` only where the surface has it. */
17export type Kit = Pick<Elements['mobile'], 'Box' | 'Text' | 'Button'> & Partial<Pick<Elements['terminal'], 'Raster'>>
18
19/** ccburn drops the chart below this size (its compact layout). */
20export const MIN_COLUMNS = 40
21export const MIN_ROWS = 15
22
23/** The header (which carries the window toggle) and the two gauges. */
24const CHROME_ROWS = 3
25
26/** The toggle's hotkey; a plain Button draws as `w: <label>`. */
27const TOGGLE_KEY = 'w'
28
29/**
30 * Inline above the prompt, a pane's body is never taller than what it draws.
31 * So the pane first draws this many rows (padding below the chart); the body
32 * rows it is then given are the room, and from then on it draws exactly that,
33 * leaving nothing to scroll.
34 */
35export const INLINE_CHART_ROWS = 20
36export const INLINE_ROWS = INLINE_CHART_ROWS + CHROME_ROWS
37
38export type PaneModel = {
39  kind: WindowKind
40  limit: LimitData | null
41  snapshots: readonly Snapshot[]
42  isWithoutLimits: boolean
43  now: number
44  columns: number
45  rows: number
46  /** `inline` above the prompt, or `dock`ed beside a fullscreen transcript. */
47  placement: 'dock' | 'inline'
48  offsetAt: OffsetAt
49  onToggle: () => void
50}
51
52export const MESSAGES = {
53  waiting: "Waiting for Claude Code's first rate-limit reading; it arrives with the next reply.",
54  withoutLimits: "This account reports no rate-limit windows to Claude Code (Enterprise and API plans don't), so there is nothing to chart.",
55  tooSmall: 'Pane too small for chart. Widen or heighten it.',
56} as const
57
58function runs(kit: Kit, list: readonly Run[]): RenderNode[] {
59  const { Text } = kit
60
61  return list.map(run => (
62    <Text color={run.color} bold={run.bold} dimColor={run.dim}>
63      {run.text}
64    </Text>
65  ))
66}
67
68/** Pads a run list on the right to `width` cells, as a Rich table column does. */
69function padded(list: readonly Run[], width: number): Run[] {
70  const used = list.reduce((sum, run) => sum + cellWidth(run.text), 0)
71
72  return used >= width ? [...list] : [...list, { text: ' '.repeat(width - used) }]
73}
74
75/** Pads on the left, for a right-justified column. */
76function rightAligned(list: readonly Run[], width: number): Run[] {
77  const used = list.reduce((sum, run) => sum + cellWidth(run.text), 0)
78
79  return used >= width ? [...list] : [{ text: ' '.repeat(width - used) }, ...list]
80}
81
82/** The toggle's short label for a narrow pane. */
83const SHORT_NAME: Record<WindowKind, string> = { five_hour: '5h', seven_day: 'Weekly' }
84
85const widthOf = (list: readonly Run[]): number => list.reduce((sum, run) => sum + cellWidth(run.text), 0)
86
87type FittedHeader = { title: Run[]; toggle: string; right: Run[]; leftWidth: number }
88
89/**
90 * The header's parts at the most detail that fits `width` on one row: the full
91 * title and toggle; then a short toggle; then the title without `ccburn - `;
92 * then without the reset countdown.
93 */
94export function fitHeader(head: Header, width: number, toggle: string, shortToggle: string): FittedHeader {
95  const shortTitle = [head.left[0] ?? { text: '' }, head.left[3] ?? { text: '' }]
96  const tries: [Run[], string, Run[]][] = [
97    [head.left, toggle, head.right],
98    [head.left, shortToggle, head.right],
99    [shortTitle, shortToggle, head.right],
100    [shortTitle, shortToggle, []],
101  ]
102
103  for (const [title, label, right] of tries) {
104    const leftWidth = widthOf(title) + 2 + `${TOGGLE_KEY}: ${label}`.length
105    const rightWidth = widthOf(right)
106
107    if (leftWidth + (rightWidth > 0 ? 1 + rightWidth : 0) <= width) {
108      return { title, toggle: label, right, leftWidth }
109    }
110  }
111
112  const [title, label] = [shortTitle, shortToggle]
113
114  return { title, toggle: label, right: [], leftWidth: widthOf(title) + 2 + `${TOGGLE_KEY}: ${label}`.length }
115}
116
117export function drawPane(kit: Kit, model: PaneModel): RenderElement {
118  const { Box, Text, Button, Raster } = kit
119  const width = Math.max(1, model.columns)
120  const name = DISPLAY_NAME[model.kind]
121  const head = header(name, model.limit, model.now, model.offsetAt)
122  const [usage, elapsed] = gauges(model.limit, model.now, width)
123  const bar = barWidth(width)
124
125  const gaugeRow = (row: typeof usage) => (
126    <Box flexDirection="row">
127      {runs(kit, padded(row.label, LABEL_WIDTH))}
128      <Text> </Text>
129      {runs(kit, padded(row.bar, bar))}
130      <Text> </Text>
131      {runs(kit, rightAligned(row.value, VALUE_WIDTH))}
132    </Box>
133  )
134
135  const isInline = model.placement === 'inline'
136  // Inline, rows under INLINE_ROWS mean the room is short, not that the content was.
137  const chartRows = model.rows - CHROME_ROWS
138  const isTooSmall = model.columns < MIN_COLUMNS || chartRows < (isInline ? 8 : MIN_ROWS - CHROME_ROWS)
139  let body: RenderNode[] = []
140  let bodyRows = 1
141
142  if (!model.limit) {
143    body = [<Text dimColor>{model.isWithoutLimits ? MESSAGES.withoutLimits : MESSAGES.waiting}</Text>]
144  } else if (isTooSmall) {
145    body = [<Text dimColor>{MESSAGES.tooSmall}</Text>]
146  } else if (Raster !== undefined) {
147    const percentPerHour = burnRate(model.snapshots, windowStart(model.limit), model.limit.windowHours)
148    const cells = chartCells({
149      limit: model.limit,
150      snapshots: model.snapshots,
151      percentPerHour,
152      now: model.now,
153      width,
154      height: chartRows,
155      offsetAt: model.offsetAt,
156    })
157    // chartCells never draws under 40 × 8; crop to the pane so nothing overflows.
158    const fitted = cells.slice(0, chartRows).map(row => row.slice(0, width))
159    const packed = packCells(fitted)
160    body = [<Raster key="chart" columns={packed.columns} rows={packed.rows} cells={packed.cells} />]
161    bodyRows = packed.rows
162  } else {
163    bodyRows = 0
164  }
165
166  // Inline, the pane draws exactly `rows` tall (the caller's measured room, or
167  // INLINE_ROWS while it is still finding out), padding under a short body.
168  const padding = isInline ? Math.max(0, model.rows - CHROME_ROWS - bodyRows) : 0
169
170  // The window toggle rides in the header after the title, so it costs no row.
171  // A header that overflows wraps onto a second row and makes the pane scroll,
172  // so in a narrow pane it sheds detail until it fits on one.
173  const other: WindowKind = model.kind === 'five_hour' ? 'seven_day' : 'five_hour'
174  const fit = fitHeader(head, width, `Show ${DISPLAY_NAME[other]}`, SHORT_NAME[other])
175
176  return (
177    <Box flexDirection="column">
178      <Box flexDirection="row">
179        {runs(kit, fit.title)}
180        <Text>{'  '}</Text>
181        <Button key="window" label={fit.toggle} hotkey={TOGGLE_KEY} plain onPress={model.onToggle} />
182        {runs(kit, rightAligned(fit.right, width - fit.leftWidth))}
183      </Box>
184      {gaugeRow(usage)}
185      {gaugeRow(elapsed)}
186      {body}
187      {padding > 0 ? <Box height={padding} /> : []}
188    </Box>
189  )
190}
191
hooks/core/format.ts 89 lines
1/** Ports of ccburn's `utils/formatting.py`. */
2
3import { clockHour, clockTime, localTime, monthDay, type OffsetAt } from './clock'
4import { floorDiv, int, toFixed } from './py'
5
6/** `format_duration`: whole minutes as `45m`, `2h 14m`, `3d 5h`. */
7export function formatDuration(minutes: number): string {
8  if (minutes < 0) {
9    return '0m'
10  }
11
12  if (minutes < 60) {
13    return `${minutes}m`
14  }
15
16  const hours = floorDiv(minutes, 60)
17  const mins = minutes % 60
18
19  if (hours < 24) {
20    return mins ? `${hours}h ${mins}m` : `${hours}h`
21  }
22
23  const days = floorDiv(hours, 24)
24  const remainingHours = hours % 24
25
26  return remainingHours ? `${days}d ${remainingHours}h` : `${days}d`
27}
28
29/** `format_percentage`: a 0–1 value as `62%` or `62.5%`. */
30export function formatPercentage(value: number, decimalPlaces = 0): string {
31  return `${toFixed(value * 100, decimalPlaces)}%`
32}
33
34/** `format_reset_time`: `Resets in 2h 14m`, `Resets Tue 4:00 PM`, `Resets Tue 10/7 7PM`. */
35export function formatResetTime(resetsAt: number, now: number, offsetAt: OffsetAt): string {
36  const totalMinutes = int((resetsAt - now) / 1000 / 60)
37
38  if (totalMinutes < 0) {
39    return 'Reset pending'
40  }
41
42  if (totalMinutes < 24 * 60) {
43    return `Resets in ${formatDuration(totalMinutes)}`
44  }
45
46  const local = localTime(resetsAt, offsetAt)
47
48  if (totalMinutes >= 7 * 24 * 60) {
49    const time = local.minute === 0 ? clockHour(local) : clockTime(local)
50
51    return `Resets ${local.weekday} ${monthDay(local)} ${time}`
52  }
53
54  return `Resets ${local.weekday} ${clockTime(local)}`
55}
56
57/** The colour names `get_utilization_color` answers. */
58export type UtilizationColor = 'green' | 'yellow' | 'bright_red' | 'red'
59
60/** `get_utilization_color`: threshold and burn ratio together. */
61export function utilizationColor(utilization: number, budgetPace = 0): UtilizationColor {
62  if (utilization >= 0.9) {
63    return 'red'
64  }
65
66  let burnRatio = 1
67  if (budgetPace >= 0.05 && utilization >= 0.01) {
68    burnRatio = utilization / budgetPace
69  }
70
71  if (utilization >= 0.75) {
72    return burnRatio > 1.5 ? 'red' : 'bright_red'
73  }
74
75  if (utilization >= 0.5) {
76    if (burnRatio > 2) {
77      return 'red'
78    }
79
80    return burnRatio > 1.5 ? 'bright_red' : 'yellow'
81  }
82
83  if (burnRatio > 3) {
84    return 'bright_red'
85  }
86
87  return burnRatio > 2 ? 'yellow' : 'green'
88}
89
hooks/core/metrics.ts 145 lines
1/** Ports of ccburn's pace and burn math (`utils/calculator.py`, `get_pace_emoji`). */
2
3const HOUR = 3_600_000
4
5/** A window's current reading: utilization 0–1 and when it resets, in epoch ms. */
6export type LimitData = {
7  utilization: number
8  resetsAt: number
9  windowHours: number
10}
11
12/** One point of history for a window: when, and utilization 0–1. */
13export type Snapshot = {
14  timestamp: number
15  utilization: number
16}
17
18export const windowStart = (limit: LimitData): number => limit.resetsAt - limit.windowHours * HOUR
19
20export const isExpired = (limit: LimitData, now: number): boolean => now > limit.resetsAt
21
22/** Utilization, or 0 once the window has reset (the API can report a stale window). */
23export const effectiveUtilization = (limit: LimitData, now: number): number =>
24  isExpired(limit, now) ? 0 : limit.utilization
25
26/** `calculate_budget_pace`: the elapsed fraction of the window, clamped to 0–1. */
27export function budgetPace(resetsAt: number, windowHours: number, now: number): number {
28  const start = resetsAt - windowHours * HOUR
29  const elapsed = (now - start) / 1000
30  const windowSeconds = windowHours * 3600
31
32  if (windowSeconds <= 0) {
33    return 0
34  }
35
36  return Math.max(0, Math.min(1, elapsed / windowSeconds))
37}
38
39/**
40 * `calculate_burn_rate`: percentage points per hour by least squares over the
41 * snapshots inside the window; 0 when there are too few or they span too
42 * little of it.
43 */
44export function burnRate(
45  snapshots: readonly Snapshot[],
46  start: number,
47  windowHours: number,
48  minPoints = 3,
49  minSpanPct = 0.1,
50): number {
51  if (snapshots.length < 2) {
52    return 0
53  }
54
55  const points: [number, number][] = []
56  let first: number | null = null
57  let last: number | null = null
58
59  for (const s of snapshots) {
60    if (s.timestamp < start) {
61      continue
62    }
63
64    if (first === null) {
65      first = s.timestamp
66    }
67
68    last = s.timestamp
69    points.push([(s.timestamp - first) / 1000 / 3600, s.utilization * 100])
70  }
71
72  if (points.length < minPoints) {
73    return 0
74  }
75
76  if (first !== null && last !== null) {
77    const spanHours = (last - first) / 1000 / 3600
78    const minSpanHours = Math.min(windowHours * minSpanPct, 6)
79
80    if (spanHours < minSpanHours) {
81      return 0
82    }
83  }
84
85  if (points.length === 2) {
86    const [a, b] = points as [[number, number], [number, number]]
87    const dx = b[0] - a[0]
88
89    return dx <= 0 ? 0 : (b[1] - a[1]) / dx
90  }
91
92  const n = points.length
93  let sumX = 0
94  let sumY = 0
95  let sumXY = 0
96  let sumX2 = 0
97
98  for (const [x, y] of points) {
99    sumX += x
100    sumY += y
101    sumXY += x * y
102    sumX2 += x ** 2
103  }
104
105  const denominator = n * sumX2 - sumX ** 2
106
107  if (Math.abs(denominator) < 1e-10) {
108    return 0
109  }
110
111  return (n * sumXY - sumX * sumY) / denominator
112}
113
114/** `estimate_time_to_empty`: minutes until 100 %, or null when not burning. */
115export function minutesToEmpty(utilization: number, percentPerHour: number): number | null {
116  if (percentPerHour <= 0) {
117    return null
118  }
119
120  const remaining = 100 - utilization * 100
121
122  if (remaining <= 0) {
123    return 0
124  }
125
126  return Math.trunc((remaining / percentPerHour) * 60)
127}
128
129export type PaceEmoji = '🧊' | '🔥' | '🚨'
130
131/** `get_pace_emoji`: under 0.85 of pace is cool, over 1.15 is too hot. */
132export function paceEmoji(utilization: number, pace: number): PaceEmoji {
133  if (pace === 0) {
134    return '🔥'
135  }
136
137  const ratio = utilization / pace
138
139  if (ratio < 0.85) {
140    return '🧊'
141  }
142
143  return ratio > 1.15 ? '🚨' : '🔥'
144}
145
hooks/core/py.ts 49 lines
1/**
2 * Python semantics the port depends on, so the mod and ccburn never disagree
3 * by one on the same reading: fixed-point formatting with round-half-to-even
4 * on exact ties, floor division, and plotext's own `round`.
5 */
6
7/** `f"{x:.{digits}f}"` — correctly rounded, exact ties to even. */
8export function toFixed(x: number, digits: number): string {
9  if (!Number.isFinite(x)) {
10    return String(x)
11  }
12
13  const sign = x < 0 || Object.is(x, -0) ? '-' : ''
14  const magnitude = Math.abs(x)
15  const wide = magnitude.toFixed(Math.min(digits + 20, 100))
16  const point = wide.indexOf('.')
17  const tail = wide.slice(point + 1 + digits)
18  const isTie = tail.length > 0 && tail[0] === '5' && /^50*$/.test(tail)
19  let body: string
20
21  if (isTie) {
22    const kept = digits === 0 ? wide.slice(0, point) : wide.slice(0, point + 1 + digits)
23    const last = Number(kept[kept.length - 1])
24    body = last % 2 === 0 ? kept : magnitude.toFixed(digits)
25  } else {
26    body = magnitude.toFixed(digits)
27  }
28
29  return sign + body
30}
31
32/** Python's `a // b` for numbers. */
33export const floorDiv = (a: number, b: number): number => Math.floor(a / b)
34
35/** Python's `a % b` (the sign follows the divisor). */
36export const mod = (a: number, b: number): number => ((a % b) + b) % b
37
38/** plotext's `ut.round`: halves round up, unlike Python's builtin. */
39export function plotextRound(n: number, digits = 0): number {
40  const scaled = n * 10 ** digits
41  const floor = Math.floor(scaled)
42  const rounded = scaled - floor < 0.5 ? floor : Math.ceil(scaled)
43
44  return rounded * 10 ** -digits
45}
46
47/** Python's `int(x)` for a float: truncates toward zero. */
48export const int = (x: number): number => Math.trunc(x)
49
hooks/core/chart.ts 218 lines
1/**
2 * Port of ccburn's `BurnupChart._create_chart` (`display/chart.py`) for the
3 * default view: the whole window, from its start to its reset.
4 */
5
6import { dayHour, hourMinute, localTime, monthDay, monthDayHour, roundToMinute, type OffsetAt } from './clock'
7import { utilizationColor } from './format'
8import { effectiveUtilization, windowStart, type LimitData, type Snapshot } from './metrics'
9import { build, type Cell, type Color, type Signal } from './plotext'
10
11const HOUR = 3_600_000
12
13/** ccburn's `_get_plotext_color` map. */
14const USAGE_RGB = { green: 0x00ff00, yellow: 0xffff00, bright_red: 0xffa500, red: 0xff0000 } as const
15
16export const COLORS = {
17  ticks: 0x646464,
18  pace: 0x646464,
19  projectionHot: 0xff6400,
20  projectionSafe: 0x64c864,
21  now: 0x0078ff,
22  depleted: 0xff6400,
23} as const
24
25export type ChartInput = {
26  limit: LimitData
27  snapshots: readonly Snapshot[]
28  /** Burn rate from `burnRate`, in percentage points per hour. */
29  percentPerHour: number
30  now: number
31  width: number
32  height: number
33  offsetAt: OffsetAt
34}
35
36/** Python's `timedelta(hours=h)` rounds to the microsecond, half to even. */
37function hoursToMs(hours: number): number {
38  const whole = Math.trunc(hours)
39  const fraction = (hours - whole) * 3_600_000_000
40  const floor = Math.floor(fraction)
41  const rest = fraction - floor
42  const us = rest > 0.5 || (rest === 0.5 && floor % 2 !== 0) ? floor + 1 : floor
43
44  return whole * HOUR + us / 1000
45}
46
47const toHoursSince = (start: number) => (t: number) => (t - start) / 1000 / 3600
48
49/** The chart's cells, rows top-down, `max(width, 40)` × `max(height, 8)`. */
50export function chartCells(input: ChartInput): Cell[][] {
51  const { limit, snapshots, percentPerHour, now, offsetAt } = input
52  const width = Math.max(input.width, 40)
53  const height = Math.max(input.height, 8)
54
55  const originalStart = windowStart(limit)
56  const originalHours = limit.windowHours
57  const displayStart = originalStart
58  const displayEnd = limit.resetsAt
59  const toHours = toHoursSince(displayStart)
60  const displayHours = toHours(displayEnd)
61  const effective = effectiveUtilization(limit, now)
62
63  const relevant = snapshots.filter(s => displayStart <= s.timestamp && s.timestamp <= now)
64
65  // Budget pace, against the original window.
66  const paceX: number[] = []
67  const paceY: number[] = []
68  for (let i = 0; i < 50; i += 1) {
69    const xHours = (i * displayHours) / 49
70    paceX.push(xHours)
71    const pointTime = displayStart + hoursToMs(xHours)
72    const elapsed = (pointTime - originalStart) / 1000 / 3600
73    paceY.push(Math.min((elapsed / originalHours) * 100, 100))
74  }
75
76  const signals: Signal[] = [{ x: paceX, y: paceY, color: COLORS.pace, marker: 'braille', label: 'Budget Pace' }]
77
78  // Actual usage.
79  const values: number[] = []
80  const times: number[] = []
81  for (const s of relevant) {
82    const pct = Math.min(s.utilization * 100, 100)
83    if (s.utilization > 1) {
84      continue
85    }
86
87    times.push(toHours(s.timestamp))
88    values.push(pct)
89  }
90
91  if (times.length > 0) {
92    const elapsedHours = (now - originalStart) / 1000 / 3600
93    const pace = Math.min(elapsedHours / originalHours, 1)
94    signals.push({ x: times, y: values, color: USAGE_RGB[utilizationColor(effective, pace)], marker: 'braille', fillx: true, label: 'Usage' })
95  }
96
97  // Projection.
98  let hits100Hours: number | null = null
99  const showProjection = displayEnd > now
100  if (percentPerHour > 0 && showProjection) {
101    const currentPct = effective * 100
102    const nowHours = toHours(now)
103
104    if (currentPct < 100) {
105      const hoursTo100 = (100 - currentPct) / percentPerHour
106      const remainingWindowHours = (displayEnd - now) / 1000 / 3600
107      let endHours: number
108      let endPct: number
109      let color: Color
110
111      if (hoursTo100 <= remainingWindowHours) {
112        endHours = nowHours + hoursTo100
113        endPct = 100
114        color = COLORS.projectionHot
115        hits100Hours = endHours
116      } else {
117        endHours = nowHours + remainingWindowHours
118        endPct = currentPct + percentPerHour * remainingWindowHours
119        color = COLORS.projectionSafe
120      }
121
122      signals.push({ x: [nowHours, endHours], y: [currentPct, Math.min(endPct, 100)], color, marker: 'braille', label: 'Projection' })
123    }
124  }
125
126  // Y range: the data, padded, at least 10 points tall, inside 0–100.
127  const all = [...values, ...paceY]
128  if (hits100Hours !== null) {
129    all.push(100)
130  } else if (percentPerHour > 0) {
131    const currentPct = effective * 100
132    const remainingWindowHours = (displayEnd - now) / 1000 / 3600
133    all.push(Math.min(currentPct + percentPerHour * remainingWindowHours, 100))
134  }
135
136  let yMin = 0
137  let yMax = 100
138  if (all.length > 0) {
139    const dataMin = Math.min(...all)
140    const dataMax = Math.max(...all)
141    const padding = Math.max((dataMax - dataMin) * 0.1, 1)
142    yMin = Math.max(0, dataMin - padding)
143    yMax = Math.min(100, dataMax + padding)
144
145    if (yMax - yMin < 10) {
146      const mid = (yMin + yMax) / 2
147      yMin = Math.max(0, mid - 5)
148      yMax = Math.min(100, mid + 5)
149    }
150  }
151
152  const dots = (x: number): Signal['x'] => Array.from({ length: 20 }, () => x)
153  const dotY = Array.from({ length: 20 }, (_, i) => yMin + (i * (yMax - yMin)) / 19)
154
155  let nowTick: number | null = null
156  if (showProjection) {
157    const nowHours = toHours(now)
158    if (0 < nowHours && nowHours < displayHours) {
159      signals.push({ x: dots(nowHours), y: dotY, color: COLORS.now, marker: 'braille', label: 'Now' })
160      nowTick = nowHours
161    }
162  }
163
164  let depletedTick: number | null = null
165  if (hits100Hours !== null && 0 < hits100Hours && hits100Hours < displayHours) {
166    signals.push({ x: dots(hits100Hours), y: dotY, color: COLORS.depleted, marker: 'braille', label: 'Depleted' })
167    depletedTick = hits100Hours
168  }
169
170  // The hidden point that switches the right y axis on.
171  signals.push({ x: [displayHours], y: [yMax], color: null, marker: ' ', yside: 'right' })
172
173  // X ticks in local time.
174  const format = (ms: number, isMarker: boolean): string => {
175    const local = localTime(roundToMinute(ms), offsetAt)
176
177    if (displayHours > 168) {
178      return isMarker ? monthDayHour(local) : monthDay(local)
179    }
180
181    return displayHours > 24 ? dayHour(local) : hourMinute(local)
182  }
183
184  let positions: number[] = []
185  let labels: string[] = []
186  for (let i = 0; i < 5; i += 1) {
187    const hours = (i * displayHours) / 4
188    positions.push(hours)
189    labels.push(format(displayStart + hoursToMs(hours), false))
190  }
191
192  const addMarkerTick = (at: number, ms: number) => {
193    const minDistance = displayHours / 10
194    const keep = positions.map((p, i) => ({ p, l: labels[i] ?? '' })).filter(({ p }) => Math.abs(at - p) >= minDistance)
195    positions = [...keep.map(k => k.p), at]
196    labels = [...keep.map(k => k.l), format(ms, true)]
197  }
198
199  if (nowTick !== null) {
200    addMarkerTick(nowTick, now)
201  }
202
203  if (depletedTick !== null) {
204    addMarkerTick(depletedTick, displayStart + hoursToMs(depletedTick))
205  }
206
207  return build({
208    width,
209    height,
210    xlim: [0, displayHours],
211    ylim: [yMin, yMax],
212    xticks: positions,
213    xlabels: labels,
214    signals,
215    ticksColor: COLORS.ticks,
216  })
217}
218
hooks/core/raster.ts 62 lines
1/**
2 * Packing for the terminal's `Raster` element: `columns * rows` cells, each
3 * three little-endian u32s `[codePoint, foreground, background]`, as
4 * standard padded base64. Encoded by hand so it depends on no runtime
5 * encoder.
6 */
7
8import type { Cell } from './plotext'
9
10/** The terminal's own default colour, as `RasterProps` spells it. */
11export const DEFAULT_COLOR = 0x01000000
12
13const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
14
15export function base64(bytes: Uint8Array): string {
16  let out = ''
17  let i = 0
18
19  for (; i + 2 < bytes.length; i += 3) {
20    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
21    out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]! + ALPHABET[(n >> 6) & 63]! + ALPHABET[n & 63]!
22  }
23
24  const rest = bytes.length - i
25  if (rest === 1) {
26    const n = (bytes[i] ?? 0) << 16
27    out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]! + '=='
28  } else if (rest === 2) {
29    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8)
30    out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]! + ALPHABET[(n >> 6) & 63]! + '='
31  }
32
33  return out
34}
35
36/** Packs rows of cells (all the same length) for a `Raster`'s `cells` prop. */
37export function packCells(rows: readonly (readonly Cell[])[]): { cells: string; columns: number; rows: number } {
38  const columns = rows[0]?.length ?? 0
39  const words = new Uint32Array(columns * rows.length * 3)
40  let w = 0
41
42  for (const row of rows) {
43    for (const cell of row) {
44      words[w] = cell.glyph.codePointAt(0) ?? 0x20
45      words[w + 1] = cell.fg ?? DEFAULT_COLOR
46      words[w + 2] = DEFAULT_COLOR
47      w += 3
48    }
49  }
50
51  const bytes = new Uint8Array(words.length * 4)
52  for (let i = 0; i < words.length; i += 1) {
53    const word = words[i] ?? 0
54    bytes[i * 4] = word & 0xff
55    bytes[i * 4 + 1] = (word >>> 8) & 0xff
56    bytes[i * 4 + 2] = (word >>> 16) & 0xff
57    bytes[i * 4 + 3] = (word >>> 24) & 0xff
58  }
59
60  return { cells: base64(bytes), columns, rows: rows.length }
61}
62
hooks/core/plotext.ts 422 lines
1/**
2 * A faithful port of the slice of plotext 5.3.2 that ccburn's chart uses:
3 * `build_plot` from `_build.py`, the matrix from `_matrix.py` and the helpers
4 * from `_utility.py`. Limited to what the chart needs: a full frame, lower
5 * x ticks set by the caller, automatic y ticks on both sides, braille or
6 * single-character markers, lines between points, `fillx`, and the legend.
7 *
8 * The one deliberate difference: plotext keeps user x ticks in a set, so
9 * which of two colliding labels survives varies with Python's hash seed.
10 * Here ticks are inserted in ascending position, so the result is stable.
11 */
12
13import { floorDiv, mod, plotextRound, toFixed } from './py'
14
15/** A colour as 0xRRGGBB, or null for the terminal default. */
16export type Color = number | null
17
18export type Cell = { glyph: string; fg: Color }
19
20export type Signal = {
21  x: readonly number[]
22  y: readonly number[]
23  color: Color
24  /** `braille` packs 2 × 4 sub-pixels per cell; any other string is drawn as itself. */
25  marker: 'braille' | string
26  fillx?: boolean
27  label?: string
28  yside?: 'left' | 'right'
29}
30
31export type Figure = {
32  width: number
33  height: number
34  xlim: readonly [number, number]
35  ylim: readonly [number, number]
36  xticks: readonly number[]
37  xlabels: readonly string[]
38  signals: readonly Signal[]
39  ticksColor: Color
40}
41
42const SPACE = ' '
43const LEGEND_MARKER = '⢕'
44const Y_FREQUENCY = 7
45
46/** `ut.linspace` */
47export function linspace(lower: number, upper: number, length: number): number[] {
48  const slope = length > 1 ? (upper - lower) / (length - 1) : 0
49
50  return Array.from({ length }, (_, i) => lower + i * slope)
51}
52
53/** `ut.get_matrix_data`: data to canvas coordinates. */
54export function matrixData(data: readonly number[], lim: readonly [number, number], bins: number): number[] {
55  return data.map(el => Math.floor(plotextRound(0.5 + ((bins - 1) * (el - lim[0])) / (lim[1] - lim[0]), 8)))
56}
57
58function distinguishingDigitPair(a: number, b: number): number {
59  let d = Math.abs(a - b)
60  d = d === 0 ? 0 : -Math.log10(2 * d)
61  d = d < 0 ? 0 : Math.ceil(d)
62
63  return plotextRound(a, d) === plotextRound(b, d) ? d + 1 : d
64}
65
66/** `ut.get_labels`: the shortest labels that tell the ticks apart. */
67export function getLabels(ticks: readonly number[]): string[] {
68  const pairs = ticks.slice(0, -1).map((t, i) => distinguishingDigitPair(t, ticks[i + 1] ?? t))
69  const d = pairs.length === 0 ? 1 : Math.max(...pairs)
70  const allIntegers = ticks.every(el => el === Math.trunc(el))
71
72  if (allIntegers) {
73    return ticks.map(el => String(Math.trunc(el)))
74  }
75
76  const labels = ticks.map(el => {
77    const text = toFixed(el, d + 1)
78
79    return text.slice(0, text.indexOf('.') + d + 2)
80  })
81
82  if (labels.length <= 1) {
83    return labels
84  }
85
86  return labels.map(label => {
87    const zeros = label.length - 1 - label.indexOf(label.includes('e') ? 'e' : '.')
88
89    return zeros < d ? label + '0'.repeat(d - zeros) : label
90  })
91}
92
93/** `ut.get_line`: integer points from one coordinate pair to the next. */
94function line(x0: number, x1: number, y0: number, y1: number): [number[], number[]] {
95  const dx = Math.trunc(x1) - Math.trunc(x0)
96  const dy = Math.trunc(y1) - Math.trunc(y0)
97  const a = Math.trunc(Math.max(Math.abs(dx), Math.abs(dy)) + 1)
98
99  return [linspace(x0, x1, a).map(Math.trunc), linspace(y0, y1, a).map(Math.trunc)]
100}
101
102/** `ut.get_lines` */
103function lines(x: readonly number[], y: readonly number[]): [number[], number[]] {
104  const xl: number[] = []
105  const yl: number[] = []
106
107  for (let n = 0; n < x.length - 1; n += 1) {
108    const [xn, yn] = line(x[n] ?? 0, x[n + 1] ?? 0, y[n] ?? 0, y[n + 1] ?? 0)
109    xl.push(...xn.slice(0, -1))
110    yl.push(...yn.slice(0, -1))
111  }
112
113  if (x.length > 0) {
114    xl.push(x[x.length - 1] ?? 0)
115    yl.push(y[y.length - 1] ?? 0)
116  }
117
118  return [xl, yl]
119}
120
121/** `ut.fill_data` with a numeric level. */
122function fill(x: readonly number[], y: readonly number[], level: number): [number[], number[]] {
123  const xf: number[] = []
124  const yf: number[] = []
125  const seen = new Set<string>()
126
127  for (let i = 0; i < x.length; i += 1) {
128    const xi = x[i] ?? 0
129    const yi = y[i] ?? 0
130    const key = `${xi},${yi}`
131
132    if (seen.has(key)) {
133      continue
134    }
135
136    seen.add(key)
137
138    const from = level < yi ? level : level > yi ? yi : level
139    const to = level < yi ? yi + 1 : level > yi ? level : level + 1
140
141    for (let v = from; v < to; v += 1) {
142      xf.push(xi)
143      yf.push(v)
144    }
145  }
146
147  return [xf, yf]
148}
149
150/** `ut.brush`, keeping first-seen order (order never matters within one signal). */
151function unique(x: readonly number[], y: readonly number[]): [number[], number[]] {
152  const xs: number[] = []
153  const ys: number[] = []
154  const seen = new Set<string>()
155
156  for (let i = 0; i < Math.min(x.length, y.length); i += 1) {
157    const key = `${x[i]},${y[i]}`
158
159    if (!seen.has(key)) {
160      seen.add(key)
161      xs.push(x[i] ?? 0)
162      ys.push(y[i] ?? 0)
163    }
164  }
165
166  return [xs, ys]
167}
168
169/** Braille dot bits for sub-pixel (column 0–1, row 0–3 from the bottom). */
170const BRAILLE_BIT: readonly (readonly [number, number])[] = [
171  [0x40, 0x80],
172  [0x04, 0x20],
173  [0x02, 0x10],
174  [0x01, 0x08],
175]
176
177/** `ut.hd_group` + `get_hd_marker` for braille: cells and their glyphs. */
178function brailleCells(x: readonly number[], y: readonly number[]): { cx: number; cy: number; glyph: string }[] {
179  const bits = new Map<string, { cx: number; cy: number; bits: number }>()
180
181  for (let i = 0; i < x.length; i += 1) {
182    const xi = x[i] ?? 0
183    const yi = y[i] ?? 0
184    const cx = floorDiv(xi, 2)
185    const cy = floorDiv(yi, 4)
186    const key = `${cx},${cy}`
187    const cell = bits.get(key) ?? { cx, cy, bits: 0 }
188    cell.bits |= BRAILLE_BIT[mod(yi, 4)]?.[mod(xi, 2)] ?? 0
189    bits.set(key, cell)
190  }
191
192  return [...bits.values()].map(cell => ({
193    cx: cell.cx,
194    cy: cell.cy,
195    glyph: cell.bits === 0 ? SPACE : String.fromCharCode(0x2800 + cell.bits),
196  }))
197}
198
199/** `ut.correct_coord`: slides a label's start so it stays inside free space. */
200function correctCoord(row: readonly Cell[], label: string, coord: number): number {
201  const l = label.length
202  let b = Math.max(coord - l + 1, 0)
203  let e = Math.min(coord + l, row.length - 1)
204  const free: number[] = []
205
206  for (let i = b; i < e; i += 1) {
207    if (row[i]?.glyph === SPACE) {
208      free.push(i)
209    }
210  }
211
212  const lo = free.length === 0 ? coord - l + 1 : Math.min(...free)
213  const hi = free.length === 0 ? coord + l : Math.max(...free)
214  b = hi - l + 1
215  e = lo + l
216
217  return floorDiv(b + e - l, 2)
218}
219
220type Alignment = 'left' | 'dynamic'
221
222/** The plotext matrix: row 0 is the bottom row, as in `_matrix.py`. */
223class Matrix {
224  readonly cells: Cell[][]
225
226  constructor(
227    readonly cols: number,
228    readonly rows: number,
229  ) {
230    this.cells = Array.from({ length: rows }, () => Array.from({ length: cols }, () => ({ glyph: SPACE, fg: null })))
231  }
232
233  private legal(col: number, row: number): boolean {
234    return col >= 0 && col < this.cols && row >= 0 && row < this.rows
235  }
236
237  insert(col: number, row: number, glyph: string, fg: Color): void {
238    if (this.legal(col, row)) {
239      this.cells[row]![col] = { glyph, fg }
240    }
241  }
242
243  horizontal(col: number, row: number, text: string, fg: Color, alignment: Alignment = 'left', checkSpace = false): boolean {
244    const l = text.length
245    const start = alignment === 'left' ? col : correctCoord(this.cells[row] ?? [], text, col)
246
247    if (checkSpace) {
248      const b = Math.max(start - 1, 0)
249      const e = Math.min(start + l + 1, this.cols)
250
251      for (let c = b; c < e; c += 1) {
252        if (this.cells[row]?.[c]?.glyph !== SPACE) {
253          return false
254        }
255      }
256
257      if (start < 0 || start + l > this.cols) {
258        return false
259      }
260    }
261
262    for (let i = 0; i < l; i += 1) {
263      this.insert(start + i, row, text[i] ?? SPACE, fg)
264    }
265
266    return true
267  }
268
269  vertical(col: number, row: number, text: string, fg: Color): void {
270    for (let i = 0; i < text.length; i += 1) {
271      this.insert(col, row + i, text[i] ?? SPACE, fg)
272    }
273  }
274
275  /** Rows top-down, as the terminal shows them. */
276  topDown(): Cell[][] {
277    return [...this.cells].reverse()
278  }
279}
280
281/** `build_plot`, for the figure ccburn's chart builds. Returns rows top-down. */
282export function build(figure: Figure): Cell[][] {
283  const { width, height, xlim, ylim, ticksColor } = figure
284  const signals = figure.signals
285  const sideUsed = { left: signals.some(s => (s.yside ?? 'left') === 'left'), right: signals.some(s => s.yside === 'right') }
286
287  const yticks = linspace(ylim[0], ylim[1], Y_FREQUENCY)
288  const yLabelsRaw = getLabels(yticks)
289  const yWidth = Math.max(0, ...yLabelsRaw.map(l => l.length))
290  const leftLabels = sideUsed.left ? yLabelsRaw.map(l => SPACE.repeat(yWidth - l.length) + l) : []
291  const rightLabels = sideUsed.right ? yLabelsRaw.map(l => l + SPACE.repeat(yWidth - l.length)) : []
292  const wl = [sideUsed.left ? yWidth : 0, sideUsed.right ? yWidth : 0] as const
293
294  const ticks = figure.xticks
295    .map((pos, i) => ({ pos, label: figure.xlabels[i] ?? '' }))
296    .filter((t, i, all) => all.findIndex(o => o.pos === t.pos && o.label === t.label) === i)
297    .sort((a, b) => a.pos - b.pos)
298  const xLabelRows = ticks.length > 0 ? 1 : 0
299
300  const widthCanvas = width - 2 - wl[0] - wl[1]
301  const heightCanvas = height - 2 - xLabelRows
302  const colStart = wl[0] + 1
303  const colEnd = colStart + widthCanvas
304  const rowStart = xLabelRows + 1
305  const rowEnd = rowStart + heightCanvas
306
307  const cticks = matrixData(
308    ticks.map(t => t.pos),
309    xlim,
310    widthCanvas,
311  )
312  const rticks = matrixData(yticks, ylim, heightCanvas)
313  const matrix = new Matrix(width, height)
314
315  // Lower x tick labels, each kept only if it fits.
316  const kept: number[] = []
317  ticks.forEach((tick, i) => {
318    const c = cticks[i] ?? 0
319    if (height > 0 && matrix.horizontal(colStart + c, rowStart - 2, tick.label, ticksColor, 'dynamic', true)) {
320      kept.push(c)
321    }
322  })
323
324  // Upper x axis.
325  if (heightCanvas >= -1) {
326    matrix.horizontal(colStart, rowEnd, '─'.repeat(Math.max(0, widthCanvas)), ticksColor)
327  }
328
329  // Left y labels and axis.
330  if (width >= wl[0]) {
331    rticks.forEach((r, i) => matrix.horizontal(0, r + rowStart, leftLabels[i] ?? '', ticksColor))
332  }
333
334  const leftAxis = Array.from({ length: Math.max(0, heightCanvas) }, (_, i) => (rticks.includes(i) ? '┤' : '│')).join('')
335  if (width >= wl[0] + wl[1] + 1) {
336    matrix.vertical(wl[0], rowStart, leftAxis, ticksColor)
337  }
338
339  const rightAxis = Array.from({ length: Math.max(0, heightCanvas) }, (_, i) => (rticks.includes(i) && sideUsed.right ? '├' : '│')).join('')
340  if (width >= wl[0] + wl[1] + 2) {
341    matrix.vertical(colEnd, rowStart, rightAxis, ticksColor)
342  }
343
344  if (width >= wl[0] + wl[1] + 1) {
345    rticks.forEach((r, i) => {
346      if (rightLabels[i] !== undefined) {
347        matrix.horizontal(colEnd + 1, r + rowStart, rightLabels[i], ticksColor)
348      }
349    })
350  }
351
352  // Corners.
353  if (heightCanvas >= 0 && widthCanvas >= 0) {
354    matrix.insert(colStart - 1, rowStart - 1, '└', ticksColor)
355    matrix.insert(colEnd, rowStart - 1, '┘', ticksColor)
356    matrix.insert(colStart - 1, rowEnd, '┌', ticksColor)
357    matrix.insert(colEnd, rowEnd, '┐', ticksColor)
358  }
359
360  // Lower x axis, with a tick under every label that was kept.
361  if (heightCanvas >= -1) {
362    const axis = Array.from({ length: Math.max(0, widthCanvas) }, (_, i) => (kept.includes(i) ? '┬' : '─')).join('')
363    matrix.horizontal(colStart, rowStart - 1, axis, ticksColor)
364  }
365
366  // Data, one signal at a time; a later signal replaces whole cells.
367  const drawnColors: Color[] = []
368  for (const signal of signals) {
369    const isBraille = signal.marker === 'braille'
370    const xf = isBraille ? 2 : 1
371    const yf = isBraille ? 4 : 1
372    const widthExpanded = widthCanvas * xf
373    const heightExpanded = heightCanvas * yf
374    const ylimSignal = ylim
375
376    let x = widthExpanded !== 0 ? matrixData(signal.x, xlim, widthExpanded) : []
377    let y = widthExpanded !== 0 ? matrixData(signal.y, ylimSignal, heightExpanded) : []
378    ;[x, y] = lines(x, y)
379
380    if (signal.fillx) {
381      // `check_fill` turns `fillx=True` into a fill level of 0, clamped to the y range.
382      const value = Math.min(Math.max(0, ylimSignal[0]), ylimSignal[1])
383      const level = matrixData([value], ylimSignal, heightExpanded)[0] ?? 0
384      ;[x, y] = fill(x, y, level)
385    }
386
387    ;[x, y] = unique(x, y)
388    const cells = isBraille
389      ? brailleCells(x, y)
390      : x.map((cx, i) => ({ cx, cy: y[i] ?? 0, glyph: signal.marker }))
391
392    let drew = false
393    for (const cell of cells) {
394      if (cell.cx >= 0 && cell.cx < widthCanvas && cell.cy >= 0 && cell.cy < heightCanvas) {
395        matrix.insert(cell.cx + colStart, cell.cy + rowStart, cell.glyph, signal.color)
396        drew = true
397      }
398    }
399    drawnColors.push(drew ? signal.color : null)
400  }
401
402  // Legend, top-left of the canvas, one row per labelled signal.
403  const labelled = signals.map((s, i) => ({ s, i })).filter(({ s }) => s.label !== undefined && s.label.trim() !== '')
404  const labels = labelled.map(({ s }) => ` ${(s.label ?? '').trim()} `)
405  const longest = Math.max(0, ...labels.map(l => l.length))
406  const padded = labels.map(l => l + SPACE.repeat(longest - l.length))
407  const isLegendShown = widthCanvas >= 3 + longest && heightCanvas >= labels.length
408
409  if (isLegendShown) {
410    labelled.forEach(({ s, i }, n) => {
411      const row = rowEnd - 1 - n
412      const marker = s.marker === 'braille' ? LEGEND_MARKER : s.marker
413      matrix.insert(colStart, row, SPACE, null)
414      matrix.insert(colStart + 1, row, marker, drawnColors[i] ?? s.color)
415      matrix.insert(colStart + 2, row, marker, drawnColors[i] ?? s.color)
416    })
417    padded.forEach((label, n) => matrix.horizontal(colStart + 3, rowEnd - 1 - n, label, ticksColor))
418  }
419
420  return matrix.topDown()
421}
422