SLOPSHOPPER

limit-watch

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…

newpanecommandstatustimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limit-watch
│ ┃ Usage limits ✕ › fix the failing auth test and add an audit log call │ ┃ 5-hour limit · 31% used │ ┃ █████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░ ⏺ Read(src/auth.ts) │ ┃ ░░░░░░░░░░ ⎿ Read 6 lines │ ┃ no reset time reported ⏺ Update(src/auth.ts) │ ┃ pace: measuring, 9m of samples still needed ⎿ 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 │ │ › /limit-watch │ ⎿ limit-watch: pane open. /limit-watch closes it. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ limit-watch: 5h 31% · measuring the pace

Draws

Pane · Usage limits
5-hour limit · 31% used █████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ no reset time reported pace: measuring, 9m of samples still needed
README

limit-watch

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.

What it shows

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.

How the numbers are made

  • $.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.
  • Every reading is one sample { at, percent }, kept in $.store so that a restart keeps the pace.
  • The 5-hour and spend limits read the pace from their samples: the slope of a least-squares line through every sample of a recent span, in percent per hour. Every sample weighs in, so one step of the whole-number percentage at either end does not set the pace alone. The span is the last hour for the 5-hour limit and the last 24 hours for the spend limit, so the pace follows how you work now. A pace is shown only when its samples span at least 10 minutes (5-hour limit) or 2 hours (spend limit). A shorter span gives a pace that one step of the percentage can double.
  • The 7-day limit reads the pace as the average of its whole cycle so far: the percentage divided by the time since the cycle began, 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.
  • The status line tail uses (100 - percent) / pace as the time to 100%. A limit that resets before that time does not count as filling.
  • A new cycle starts when 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.
  • A stored value of an unknown shape is reported with one log line, and the samples start over.

Install

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

After installing

  1. Restart Claude Code.
  2. Sign in with a Claude subscription (/login). A session on an API key reports no limits, and the status line stays at no usage limits reported yet.
  3. Send one prompt. The limits come from the last API response, so the status line fills after the first answer. Open the pane with /limit-watch.

What it can reach

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.

  1. Reads: the rate-limit windows of $.session.usage (kind, percent used, reset time); the event payloads of its four hooks
  2. Runs: nothing; one 60 second timer in an interactive session
  3. Sends: nothing leaves the machine
  4. Persists: the samples and the warned levels of each limit in $.store, at most 1500 samples per limit, and the stored on/off setting
  5. Hostile input: the only outside input is the usage figures; a stored value of an unknown shape is reported and replaced, never trusted

Limits

  • A new session has no reading until Claude answers once, because the figures come from the last API response.
  • The 7-day limit shows no pace in the first 24 hours of its cycle.
  • The 7-day pace assumes the cycle began exactly 7 days before resetsAt.
  • A spend limit can pass 100%. The bar stops at full; the percentage does not.
  • /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.

Development

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

Source 2 files
hooks/register.tsx 228 lines
1import 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}
228
hooks/limits.ts 297 lines
1import 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