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

<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">
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
w: Show Weekly toggle sits in the title row.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.
| Setting | Default | |
|---|---|---|
openOnStart | true | Open the pane when a session starts. Claude Code only seats a pane nobody asked for on a wide terminal (144+ columns); otherwise run /ccburn. |
useCcburn | true | Share history with the ccburn CLI when it is installed (see above). |
Change them in /config or with claude plugin configure ccburn.
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.
MIT
hooks/register.tsx 237 lines1import { 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}
237hooks/core/clock.ts 72 lines1/**
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}
72hooks/core/ccburn.ts 69 lines1/**
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}
69hooks/core/gauges.ts 138 lines1/**
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}
138hooks/core/readings.ts 111 lines1/**
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 }))
111hooks/views/pane.tsx 191 lines1/**
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}
191hooks/core/format.ts 89 lines1/** 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}
89hooks/core/metrics.ts 145 lines1/** 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}
145hooks/core/py.ts 49 lines1/**
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)
49hooks/core/chart.ts 218 lines1/**
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}
218hooks/core/raster.ts 62 lines1/**
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}
62hooks/core/plotext.ts 422 lines1/**
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