Shows the subscription usage limits under the prompt or in the shared sidebar with a reset countdown and a projection, opens a /limit-watch pane, logs once…

A Claude subscription has a 5-hour limit and a 7-day limit, and a Claude gateway can add a spend limit. Claude Code shows them only in a notice when a limit is almost full, so you learn where you stand when it is already late. This mod keeps them on screen for the whole session, counts down to each reset, forecasts when the current pace fills a limit, and logs a warning when a limit passes 80% and 95%. /limit-watch off stops all of it until /limit-watch on starts it again.
A status line under the prompt, updated after every turn and every 60 seconds:
limit-watch: 5h 9%, reset in 2h 36m · 7d 15%, reset in 5d 10h · measuring the pace
The last part is one of these:
5h hits 100% in ~1h 40m: at the current pace this limit fills before its reset. When more than one limit fills, the first one is named.no limit fills before its reset: every limit resets before the current pace fills it, or its pace is flat.measuring the pace: no limit has a long enough span yet.5h limit reached: a limit is at 100%.An API key session reports no limits. The status line then reads no usage limits reported yet. A new session also shows this until Claude answers once.
A section in the sidebar instead of that status line while the sidebar is open: the same parts, one line per limit, with only the percentage coloured (green under 80%, yellow from 80%, red from 95%, the same steps as the pane's bar) and the reset countdown faint, and the pace line under them: limit reached red, the ~<time> of hits 100% in yellow, the whole line green when no limit fills and faint while the pace is still measured. The status line is cleared then. With the sidebar closed, or without that mod installed, the status line stays as above.
A pane, opened and closed with /limit-watch, with one block per limit:
5-hour limit · 9% used ██████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ resets 22:40, in 2h 36m pace +4.2%/h over the last 38m
The bar fills the width of the pane. It is green below 80%, yellow from 80% and red from 95%. While the pace is measured, the pace line says how much more sampling it needs.
/limit-watch on and /limit-watch off stop and start the whole watcher. While it is off nothing is sampled: no status line, no sidebar section, no pane and no warnings, and an open pane and the standing section are dropped. The setting lives in the store every window shares, so an off in one window also stops the others at their next hook that acts on it, and it survives a restart. While the watcher is off, a bare /limit-watch answers off. instead of opening the pane.
A warning in the transcript when a limit passes 80% and again when it passes 95%:
limit-watch: 5-hour limit passed 80% (now 82%), resets 22:40 (in 1h 5m)
Each warning comes once per limit cycle. A new session in the same cycle does not repeat it, and neither does a second session open at the same time: each sample reads the warned levels from the store again before it warns. Two sessions that sample in the same instant can still both warn. After the limit resets, the warnings come again.
$.session.usage() gives each limit as { kind, percentUsed, resetsAt }, read from the last API response. limit-watch reads it at session start, after every main-loop turn, every 60 seconds in an interactive session, and when /limit-watch opens the pane. A read that fails at session start or on the timer is logged once as cannot read the usage limits: <error>, and the 60 second timer keeps running.{ at, percent }, kept in $.store so that a restart keeps the pace.resetsAt minus 7 days. Nights and idle hours are part of that time, and so is the time no session ran, so the pace needs no samples. It is shown from the cycle's second day on. Measured before this rule: 4% after 2.4 busy hours read as 7d hits 100% in ~2d 8h, because the pace of those hours was stretched over two days without a break; the cycle average of the same reading fills the limit in about 6 days.(100 - percent) / pace as the time to 100%. A limit that resets before that time does not count as filling.resetsAt moves by more than 5 minutes, or, for a limit without resetsAt, when the percentage falls by more than half a point. A new cycle clears the samples and the warnings of that limit.claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install limit-watch@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
Load it from a local checkout for one session:
claude --plugin-dir plugins/limit-watch
/login). A session on an API key reports no limits, and the status line stays at no usage limits reported yet./limit-watch.Validated with claude plugin validate on Claude Code 2.1.288:
❯ ./register.tsx hooks: session.start, turn.complete, command.run{command=limit-watch}, ui.render{component=Pane} ❯ ./register.tsx calls: $.clock.every, $.clock.now, $.command.register, $.session.usage (via sample), $.sidebar.clear (via clearDrawings), $.sidebar.set (via toSidebar), $.store.get, $.store.set (via runCommand, sample), $.ui.close (via clearDrawings, runCommand), $.ui.invalidate (via sample), $.ui.log, $.ui.open (via runCommand), $.ui.panes (via clearDrawings, runCommand), $.ui.resolve, $.ui.status (via clearDrawings, sample)
Reach L0, draws and remembers.
resetsAt./limit-watch toggles one pane. The second run closes it. While the watcher is off, the bare command opens nothing; /limit-watch on starts the sampling again.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.tsx 228 lines1import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
2import {
3 barCells,
4 barColor,
5 clockText,
6 durationText,
7 markWarned,
8 mergeWarned,
9 newThresholds,
10 limitPace,
11 paceText,
12 parseTracks,
13 percentText,
14 profile,
15 record,
16 resetTime,
17 sidebarLines,
18 statusLine,
19 warningText,
20 type Tracks,
21} from './limits.ts'
22
23const PANE_ID = 'limit-watch'
24const TRACKS_KEY = 'tracks'
25const ENABLED_KEY = 'enabled'
26const TICK_MS = 60_000
27/** Body rows one limit takes in the pane: heading, bar, reset, pace, blank line. */
28const ROWS_PER_LIMIT = 5
29
30const USAGE = 'expects nothing (opens or closes the pane), on or off'
31const OFF_TEXT = 'off. /limit-watch on starts it again.'
32
33type Elements = ReturnType<EngineInterface['ui']['resolve']>
34
35/**
36 * What the hooks share: the last reading, the tracks kept in the store, the stored on/off setting with
37 * whether a drawing of it stands, and the last timer error.
38 */
39type State = { limits: readonly SessionRateLimit[]; tracks: Tracks; enabled: boolean; drawn: boolean; lastError?: string }
40
41/** Logs each threshold the last sample passed for the first time in its cycle, and marks it. */
42function warn($: EngineInterface, state: State, now: number): void {
43 for (const limit of state.limits) {
44 const track = state.tracks[limit.kind]
45 const levels = track === undefined ? [] : newThresholds(track, limit.percentUsed)
46 const top = levels.at(0)
47 if (top === undefined) continue
48 $.ui.log(warningText(limit, top, now))
49 state.tracks = markWarned(state.tracks, limit.kind, levels)
50 }
51}
52
53/** Writes the reading into the shared sidebar; false when the sidebar mod is absent or closed. */
54async function toSidebar($: EngineInterface, state: State, now: number): Promise<boolean> {
55 try {
56 return await $.sidebar.set({
57 consumer: 'limit-watch',
58 key: 'limits',
59 title: 'usage limits',
60 lines: sidebarLines(state.limits, state.tracks, now),
61 until: 'session',
62 order: 10,
63 })
64 } catch {
65 return false
66 }
67}
68
69/**
70 * Reads the on/off setting from the store, which every window shares, so a change made in another
71 * window applies here at the next hook that acts on it.
72 */
73async function readSettings($: EngineInterface, state: State): Promise<void> {
74 state.enabled = (await $.store.get(ENABLED_KEY)) !== false
75}
76
77/** Drops everything the mod drew: the sidebar section, the status line and an open pane. */
78async function clearDrawings($: EngineInterface): Promise<void> {
79 try {
80 await $.sidebar.clear({ consumer: 'limit-watch', key: 'limits' })
81 } catch {
82 // The sidebar mod is not installed.
83 }
84 $.ui.status(undefined)
85 if ((await $.ui.panes()).some(pane => pane.id === PANE_ID)) await $.ui.close({ id: PANE_ID })
86}
87
88/**
89 * Reads the limits, records a sample, raises new warnings, stores the tracks and redraws. The stored
90 * tracks are read again first, so a warning another session of the account wrote is not repeated.
91 * The stored on/off setting is read first too: while it says off nothing is sampled and what stands
92 * is cleared, so a watcher another window turned off stops here as well.
93 */
94async function sample($: EngineInterface, state: State): Promise<void> {
95 await readSettings($, state)
96 if (!state.enabled) {
97 if (state.drawn) await clearDrawings($)
98 state.drawn = false
99 return
100 }
101 const usage = await $.session.usage()
102 const now = await $.clock.now()
103 state.limits = usage.rateLimits
104 state.tracks = record(state.tracks, state.limits, now)
105 state.tracks = mergeWarned(state.tracks, parseTracks(await $.store.get(TRACKS_KEY)) ?? {})
106 warn($, state, now)
107 await $.store.set(TRACKS_KEY, state.tracks)
108 // The sidebar takes the reading while it is open; otherwise the status line shows it, as before.
109 $.ui.status((await toSidebar($, state, now)) ? undefined : statusLine(state.limits, state.tracks, now))
110 $.ui.invalidate('ui.render')
111 state.drawn = true
112}
113
114/**
115 * Samples and logs a failed read instead of throwing: a timer sample has no hook to fail, and a failed
116 * first read must not stop the hook that arms the timer. The same error is logged once.
117 */
118async function sampleOrLog($: EngineInterface, state: State): Promise<void> {
119 try {
120 await sample($, state)
121 state.lastError = undefined
122 } catch (err) {
123 const text = err instanceof Error ? err.message : String(err)
124 if (text !== state.lastError) $.ui.log(`cannot read the usage limits: ${text}`)
125 state.lastError = text
126 }
127}
128
129/**
130 * The /limit-watch command. A bare run opens or closes the pane. `on` and `off` stop and start the
131 * whole watcher; the setting is stored, so it holds for every window at its next hook that acts on it.
132 */
133async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
134 const word = args.trim()
135 if (word === 'on') {
136 await $.store.set(ENABLED_KEY, true)
137 state.enabled = true
138 await sample($, state)
139 if (!state.enabled) return OFF_TEXT
140 return 'on. /limit-watch opens the pane.'
141 }
142 if (word === 'off') {
143 await $.store.set(ENABLED_KEY, false)
144 state.enabled = false
145 if (state.drawn) await clearDrawings($)
146 state.drawn = false
147 return OFF_TEXT
148 }
149 if (word !== '') return USAGE
150 if (!state.enabled) return OFF_TEXT
151 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE_ID)
152 if (isOpen) {
153 await $.ui.close({ id: PANE_ID })
154 return 'pane closed'
155 }
156 await sample($, state)
157 if (!state.enabled) return OFF_TEXT
158 await $.ui.open({ id: PANE_ID, title: 'Usage limits', rows: Math.max(2, state.limits.length * ROWS_PER_LIMIT) })
159 return 'pane open. /limit-watch closes it.'
160}
161
162export const register: Register = on => {
163 const state: State = { limits: [], tracks: {}, enabled: true, drawn: false }
164
165 on('session.start', async ($, e, next) => {
166 const r = await next(e)
167 const stored = parseTracks(await $.store.get(TRACKS_KEY))
168 if (stored === undefined) $.ui.log('the stored samples have an unknown shape, so the pace starts over')
169 state.tracks = stored ?? {}
170 await readSettings($, state)
171 await $.command.register({
172 name: 'limit-watch',
173 description: 'Open or close the usage limits pane, or turn the watcher off and on (limit-watch)',
174 argumentHint: '[on | off]',
175 immediate: true,
176 })
177 // A -p run draws nothing, so only an interactive session samples on a timer. The timer is armed
178 // before the first read, so a first read that fails does not leave the session without one.
179 if (e.isInteractive) $.clock.every(TICK_MS, () => void sampleOrLog($, state))
180 if (state.enabled) await sampleOrLog($, state)
181 return r
182 })
183
184 on('turn.complete', async ($, e, next) => {
185 const r = await next(e)
186 if (e.agentId === undefined) await sample($, state)
187 return r
188 })
189
190 // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
191 on('command.run', { command: 'limit-watch' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
192
193 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
194 if (e.requestId !== PANE_ID) return next(e)
195 const els = $.ui.resolve(e)
196 const now = await $.clock.now()
197 const width = Math.max(10, e.props.bodyColumns - 2)
198 if (state.limits.length === 0) {
199 return <els.Text dimColor>No usage limits reported yet. An API key session reports none.</els.Text>
200 }
201 return (
202 <els.Box flexDirection="column">
203 {state.limits.map(limit => limitBlock(els, limit, state.tracks, now, width))}
204 </els.Box>
205 )
206 })
207}
208
209function limitBlock(els: Elements, limit: SessionRateLimit, tracks: Tracks, now: number, width: number) {
210 const { Box, Text } = els
211 const p = limitPace(limit, tracks[limit.kind], now)
212 const bar = barCells(limit.percentUsed, width)
213 const reset = resetTime(limit)
214 return (
215 <Box key={limit.kind} flexDirection="column" marginBottom={1}>
216 <Text bold>{`${profile(limit.kind).name} · ${percentText(limit.percentUsed)} used`}</Text>
217 <Text>
218 <Text color={barColor(limit.percentUsed)}>{bar.filled}</Text>
219 <Text dimColor>{bar.empty}</Text>
220 </Text>
221 <Text dimColor>
222 {reset === undefined ? 'no reset time reported' : `resets ${clockText(reset, now)}, in ${durationText(reset - now)}`}
223 </Text>
224 <Text dimColor>{paceText(p)}</Text>
225 </Box>
226 )
227}
228hooks/limits.ts 297 lines1import type { SessionRateLimit } from 'claude-code'
2
3const MINUTE = 60_000
4const HOUR = 60 * MINUTE
5const DAY = 24 * HOUR
6
7/** The usage percentages that raise a warning, once per limit cycle each. */
8export const THRESHOLDS: readonly number[] = [80, 95]
9
10/** Samples kept per limit. At one sample a minute this covers the 24 hour lookback of the 7-day limit. */
11const MAX_SAMPLES = 1500
12
13/** A reset time that moves by more than this starts a new cycle. Smaller moves are treated as jitter. */
14const RESET_JITTER = 5 * MINUTE
15
16/** A fall in the percentage larger than this starts a new cycle when the limit reports no reset time. */
17const PERCENT_JITTER = 0.5
18
19export type Sample = { at: number; percent: number }
20
21/** What limit-watch remembers about one limit during its current cycle. */
22export type Track = { resetsAt?: string; samples: Sample[]; warned: number[] }
23
24export type Tracks = Record<string, Track>
25
26/**
27 * How one kind of limit is named and measured. `lookback` is the span of recent samples the pace
28 * is read from. `minSpan` is the shortest span that gives a pace worth showing. A limit with a
29 * `cycle` reads its pace as the average since the cycle began, `resetsAt` minus `cycle`, instead.
30 */
31export type Profile = { short: string; name: string; lookback: number; minSpan: number; cycle?: number }
32
33const PROFILES: Record<string, Profile> = {
34 five_hour: { short: '5h', name: '5-hour limit', lookback: HOUR, minSpan: 10 * MINUTE },
35 seven_day: { short: '7d', name: '7-day limit', lookback: DAY, minSpan: DAY, cycle: 7 * DAY },
36 spend_limit: { short: 'spend', name: 'Spend limit', lookback: DAY, minSpan: 2 * HOUR },
37}
38
39export function profile(kind: string): Profile {
40 return PROFILES[kind] ?? { short: kind, name: kind.replaceAll('_', ' '), lookback: DAY, minSpan: 2 * HOUR }
41}
42
43/** The time a limit resets, in milliseconds since the epoch, or undefined when the limit reports none. */
44export function resetTime(limit: SessionRateLimit): number | undefined {
45 if (limit.resetsAt === undefined) return undefined
46 const at = Date.parse(limit.resetsAt)
47 return Number.isFinite(at) ? at : undefined
48}
49
50function isNewCycle(track: Track, limit: SessionRateLimit): boolean {
51 const last = track.samples.at(-1)
52 if (last !== undefined && limit.percentUsed < last.percent - PERCENT_JITTER) return true
53 if (track.resetsAt === undefined || limit.resetsAt === undefined) return false
54 return Math.abs(Date.parse(limit.resetsAt) - Date.parse(track.resetsAt)) > RESET_JITTER
55}
56
57function recordOne(track: Track | undefined, limit: SessionRateLimit, now: number): Track {
58 const current = track === undefined || isNewCycle(track, limit) ? { samples: [], warned: [] } : track
59 const oldest = now - profile(limit.kind).lookback
60 const kept = current.samples.filter(s => s.at >= oldest).slice(-(MAX_SAMPLES - 1))
61 return { resetsAt: limit.resetsAt, samples: [...kept, { at: now, percent: limit.percentUsed }], warned: current.warned }
62}
63
64function isRecord(value: unknown): value is Record<string, unknown> {
65 return typeof value === 'object' && value !== null && !Array.isArray(value)
66}
67
68function isSample(value: unknown): value is Sample {
69 return isRecord(value) && typeof value.at === 'number' && typeof value.percent === 'number'
70}
71
72function isTrack(value: unknown): value is Track {
73 if (!isRecord(value)) return false
74 const resetsAtOk = value.resetsAt === undefined || typeof value.resetsAt === 'string'
75 const warnedOk = Array.isArray(value.warned) && value.warned.every(w => typeof w === 'number')
76 return resetsAtOk && warnedOk && Array.isArray(value.samples) && value.samples.every(isSample)
77}
78
79/**
80 * Reads the tracks kept in the store. Nothing stored reads as no tracks.
81 * A stored value of another shape returns undefined, so the caller can report it.
82 */
83export function parseTracks(value: unknown): Tracks | undefined {
84 if (value === undefined) return {}
85 if (!isRecord(value) || !Object.values(value).every(isTrack)) return undefined
86 return value as Tracks
87}
88
89/** Adds one sample per reported limit. A limit that is not reported keeps its track unchanged. */
90export function record(tracks: Tracks, limits: readonly SessionRateLimit[], now: number): Tracks {
91 const next: Tracks = { ...tracks }
92 for (const limit of limits) next[limit.kind] = recordOne(tracks[limit.kind], limit, now)
93 return next
94}
95
96/** A pace in percent per hour, or the span still missing before a pace can be read. */
97export type Pace = { perHour: number; span: number } | { missing: number }
98
99/**
100 * The least-squares slope of the samples, in percent per millisecond. Every sample weighs in, so one
101 * jump of the whole-number percentage at either end does not set the pace alone, as it did when the
102 * pace was read from the first and last sample.
103 */
104function slope(samples: readonly Sample[]): number {
105 const meanAt = samples.reduce((a, s) => a + s.at, 0) / samples.length
106 const meanPercent = samples.reduce((a, s) => a + s.percent, 0) / samples.length
107 let cov = 0
108 let variance = 0
109 for (const s of samples) {
110 cov += (s.at - meanAt) * (s.percent - meanPercent)
111 variance += (s.at - meanAt) ** 2
112 }
113 return cov / variance
114}
115
116/** Reads the pace of a limit from the samples of its lookback. */
117export function pace(track: Track | undefined, kind: string, now: number): Pace {
118 const { lookback, minSpan } = profile(kind)
119 const recent = (track?.samples ?? []).filter(s => s.at >= now - lookback)
120 const first = recent.at(0)
121 const last = recent.at(-1)
122 const span = first !== undefined && last !== undefined ? last.at - first.at : 0
123 if (first === undefined || last === undefined || span < minSpan) return { missing: minSpan - span }
124 return { perHour: slope(recent) * HOUR, span }
125}
126
127/**
128 * The pace a forecast uses. A limit with a `cycle` and a reset time averages its percentage over
129 * the whole cycle so far, nights and idle hours included, because a few busy hours of samples
130 * stretched over days put the 7-day limit at 100% days too early (measured: 4% after 2.4 busy hours
131 * read as full in 2d 8h). Any other limit reads the pace from its samples.
132 */
133export function limitPace(limit: SessionRateLimit, track: Track | undefined, now: number): Pace {
134 const { cycle, minSpan } = profile(limit.kind)
135 const reset = resetTime(limit)
136 if (cycle === undefined || reset === undefined) return pace(track, limit.kind, now)
137 const span = now - (reset - cycle)
138 if (span < minSpan) return { missing: minSpan - span }
139 return { perHour: (limit.percentUsed / span) * HOUR, span }
140}
141
142export type Forecast =
143 | { kind: 'reached' }
144 | { kind: 'measuring' }
145 | { kind: 'flat' }
146 | { kind: 'reset-first' }
147 | { kind: 'full-at'; at: number }
148
149/** When the limit reaches 100% at the current pace, if it does before its reset. */
150export function forecast(limit: SessionRateLimit, p: Pace, now: number): Forecast {
151 if (limit.percentUsed >= 100) return { kind: 'reached' }
152 if (!('perHour' in p)) return { kind: 'measuring' }
153 if (p.perHour <= 0) return { kind: 'flat' }
154 const at = now + ((100 - limit.percentUsed) / p.perHour) * HOUR
155 const reset = resetTime(limit)
156 if (reset !== undefined && at >= reset) return { kind: 'reset-first' }
157 return { kind: 'full-at', at }
158}
159
160/**
161 * The thresholds a limit passed and has not warned about in this cycle, highest first.
162 * The caller logs the first one and marks all of them as warned.
163 */
164export function newThresholds(track: Track, percent: number): number[] {
165 return THRESHOLDS.filter(t => percent >= t && !track.warned.includes(t)).sort((a, b) => b - a)
166}
167
168/** Two tracks of one limit belong to one cycle when their reset times differ by no more than the jitter. */
169function isSameCycle(a: Track, b: Track): boolean {
170 if (a.resetsAt === undefined || b.resetsAt === undefined) return a.resetsAt === b.resetsAt
171 return Math.abs(Date.parse(a.resetsAt) - Date.parse(b.resetsAt)) <= RESET_JITTER
172}
173
174/**
175 * Adds the warnings another session stored to the tracks of this one, per limit and only within one
176 * cycle, so a threshold that session already warned about is not warned about here again.
177 */
178export function mergeWarned(tracks: Tracks, stored: Tracks): Tracks {
179 const next: Tracks = { ...tracks }
180 for (const [kind, track] of Object.entries(tracks)) {
181 const other = stored[kind]
182 if (other === undefined || !isSameCycle(track, other)) continue
183 next[kind] = { ...track, warned: [...new Set([...track.warned, ...other.warned])] }
184 }
185 return next
186}
187
188export function markWarned(tracks: Tracks, kind: string, levels: readonly number[]): Tracks {
189 const track = tracks[kind]
190 if (track === undefined || levels.length === 0) return tracks
191 return { ...tracks, [kind]: { ...track, warned: [...track.warned, ...levels] } }
192}
193
194// Formatting. Nothing below reads the engine or the clock.
195
196export function percentText(percent: number): string {
197 return `${Number(percent.toFixed(1))}%`
198}
199
200/** A duration as `<1m`, `45m`, `2h 5m` or `5d 11h`. */
201export function durationText(ms: number): string {
202 const minutes = Math.floor(Math.max(0, ms) / MINUTE)
203 if (minutes < 1) return '<1m'
204 if (minutes < 60) return `${minutes}m`
205 const hours = Math.floor(minutes / 60)
206 if (hours < 24) return minutes % 60 > 0 ? `${hours}h ${minutes % 60}m` : `${hours}h`
207 return `${Math.floor(hours / 24)}d ${hours % 24}h`
208}
209
210const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
211
212/** A local clock time as `15:00`, with the weekday in front when it is not today. */
213export function clockText(at: number, now: number): string {
214 const date = new Date(at)
215 const time = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
216 return date.toDateString() === new Date(now).toDateString() ? time : `${WEEKDAYS[date.getDay()]} ${time}`
217}
218
219/** How the sidebar colours a line or a part of one. */
220type Tone = 'ok' | 'warn' | 'error' | 'dim'
221export type Part = { text: string; kind?: Tone }
222/** A line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
223export type Line = { text: string; kind?: Tone; parts?: Part[] }
224
225const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
226
227/** A line made of parts, its `text` their texts joined. */
228const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
229
230/** The colour of one limit: red over the top threshold, yellow over the first, green under both. */
231function limitTone(percent: number): Tone {
232 if (percent >= (THRESHOLDS[1] ?? 95)) return 'error'
233 return percent >= (THRESHOLDS[0] ?? 80) ? 'warn' : 'ok'
234}
235
236/** One limit as `5h 23%, reset in 46m`: only the percentage coloured, the reset faint. */
237function limitLine(limit: SessionRateLimit, now: number): Line {
238 const reset = resetTime(limit)
239 const head = [part(`${profile(limit.kind).short} `, undefined), part(percentText(limit.percentUsed), limitTone(limit.percentUsed))]
240 return partsLine(reset === undefined ? head : [...head, part(`, reset in ${durationText(reset - now)}`, 'dim')])
241}
242
243/**
244 * The tail names the limit that fills first, or says why none does: a reached limit red, the time
245 * to fill yellow, a pace still being measured faint, and no limit filling green.
246 */
247function tailLine(limits: readonly SessionRateLimit[], tracks: Tracks, now: number): Line {
248 const forecasts = limits.map(l => ({ limit: l, f: forecast(l, limitPace(l, tracks[l.kind], now), now) }))
249 const reached = forecasts.find(x => x.f.kind === 'reached')
250 if (reached !== undefined) return partsLine([part(`${profile(reached.limit.kind).short} `, undefined), part('limit reached', 'error')])
251 const filling = forecasts
252 .flatMap(x => (x.f.kind === 'full-at' ? [{ limit: x.limit, at: x.f.at }] : []))
253 .sort((a, b) => a.at - b.at)
254 .at(0)
255 if (filling !== undefined) {
256 return partsLine([part(`${profile(filling.limit.kind).short} hits 100% in `, undefined), part(`~${durationText(filling.at - now)}`, 'warn')])
257 }
258 if (forecasts.every(x => x.f.kind === 'measuring')) return { text: 'measuring the pace', kind: 'dim' }
259 return { text: 'no limit fills before its reset', kind: 'ok' }
260}
261
262export function statusLine(limits: readonly SessionRateLimit[], tracks: Tracks, now: number): string {
263 if (limits.length === 0) return 'no usage limits reported yet'
264 return [...limits.map(l => limitLine(l, now).text), tailLine(limits, tracks, now).text].join(' · ')
265}
266
267/** The same reading as the status line, one line per limit, for the shared sidebar. */
268export function sidebarLines(limits: readonly SessionRateLimit[], tracks: Tracks, now: number): Line[] {
269 if (limits.length === 0) return [{ text: 'no usage limits reported yet', kind: 'dim' }]
270 return [...limits.map(l => limitLine(l, now)), tailLine(limits, tracks, now)]
271}
272
273/** A bar of `width` cells, filled up to the percentage. A percentage above 100 fills the whole bar. */
274export function barCells(percent: number, width: number): { filled: string; empty: string } {
275 const cells = Math.max(1, width)
276 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * cells)
277 return { filled: '█'.repeat(filled), empty: '░'.repeat(cells - filled) }
278}
279
280/** The bar color for a percentage: green below the first threshold, yellow below the last, red above. */
281export function barColor(percent: number): string {
282 const [low = 80, high = 95] = THRESHOLDS
283 if (percent >= high) return 'red'
284 return percent >= low ? 'yellow' : 'green'
285}
286
287export function paceText(p: Pace): string {
288 if ('perHour' in p) return `pace ${p.perHour >= 0 ? '+' : ''}${p.perHour.toFixed(1)}%/h over the last ${durationText(p.span)}`
289 return `pace: measuring, ${durationText(p.missing)} of samples still needed`
290}
291
292export function warningText(limit: SessionRateLimit, level: number, now: number): string {
293 const reset = resetTime(limit)
294 const when = reset === undefined ? '' : `, resets ${clockText(reset, now)} (in ${durationText(reset - now)})`
295 return `${profile(limit.kind).name} passed ${level}% (now ${percentText(limit.percentUsed)})${when}`
296}
297