Your Claude Code 5-hour and weekly limits and the chat’s context window as live meters above the prompt

Your Claude limits and the chat's context window as live meters, right above the prompt.

In the terminal, the same meters are drawn in text:
5-hour ▲ 86% → limit in ~22m · resets in 2h 46m Weekly 75% resets in 6h 16m
━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━━╸────────── ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸──────┼──
Claude Code learns your limits from Claude's replies, and Runway reads them there. Each chat saves its newest reading, and every open chat picks up the newest one within seconds, so switching chats never shows you older numbers. A new chat shows your limits before its first reply.
Your limits also count what you use on claude.ai and in Claude's other apps, which Runway can't see. So once no chat has had a reply for an hour, it says "no recent reading" instead of showing numbers that might be wrong by now. A window that has reset in the meantime says when it reset.
The meters always line up, whatever the text above them does. In the desktop app, each meter has one fact beside its number: when it resets, or when you'll hit the limit. In the terminal, Runway fits in more where there's room, such as how old the reading is, and drops it again as the window narrows.
Runway installs with the other mods in this repository. See the main README.
claude plugin validate .
claude plugin test .
Once Claude Code has loaded the mod, .claude-plugin/types/ holds the API types, and tsc -p . type-checks it.
hooks/register.tsx 405 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Context, Limit } from '../types'
5import { OLD_AFTER, brief, contextDetailsOf, detailsOf, fitting, isReading, labelOf, oldDetailsOf, severityOf, thresholdOf, viewOf } from './format'
6import type { LimitView, Segment, Severity, Tone } from './format'
7import { METER_HEIGHT, meterRuns, meterSvg } from './meter'
8import type { MeterRole } from './meter'
9
10const limitsAtom = atom({ plugin: 'Runway', key: 'limits' } as const, [])
11const contextAtom = atom({ plugin: 'Runway', key: 'context' } as const, null)
12const readingAtAtom = atom({ plugin: 'Runway', key: 'readingAt' } as const, 0)
13const nowAtom = atom({ plugin: 'Runway', key: 'now' } as const, 0)
14const toastedAtom = atom({ plugin: 'Runway', key: 'toasted' } as const, {})
15
16// The store key every chat saves its latest reading under, for the next chat to start from.
17const LATEST = 'latest'
18
19// Checked against light and dark surfaces alike, since a mod cannot tell which
20// theme it draws on: accent, warning and critical fills, and the muted ink.
21const COLORS: Record<Severity, string> = {
22 normal: '#3987e5',
23 warning: '#c98500',
24 critical: '#d03b3b',
25}
26const MUTED = '#898781'
27
28const GAP = 3
29const MIN_TILE = 22
30const MAX_TILE = 48
31// More CSS pixels than a desktop cell holds, so a meter drawn this wide is
32// always narrowed to its tile rather than falling short of it.
33const PX_PER_CELL = 9
34
35type Elements = Pick<ElementTable<'terminal'>, 'Box' | 'Text'>
36type Meter = (view: LimitView, width: number, color: string, alt: string) => RenderElement
37
38function layoutOf(columns: number, count: number) {
39 const fit = Math.floor((columns - GAP * (count - 1)) / count)
40
41 return fit >= MIN_TILE
42 ? { direction: 'row' as const, tile: Math.min(MAX_TILE, fit) }
43 : { direction: 'column' as const, tile: Math.min(MAX_TILE, columns) }
44}
45
46function altOf(limit: Limit, view: LimitView) {
47 const used = `${labelOf(limit.kind)} limit, ${Math.round(view.percent)}% used`
48
49 return view.elapsed === undefined ? used : `${used}, ${Math.round(view.elapsed * 100)}% of the window gone`
50}
51
52function runStyle(role: MeterRole, color: string) {
53 switch (role) {
54 case 'fill':
55 return { color }
56 case 'mark-fill':
57 return { color, bold: true }
58 case 'mark-empty':
59 return { color: MUTED }
60 case 'empty':
61 return { dimColor: true }
62 }
63}
64
65const markOf = (severity: Severity) => (severity === 'critical' ? '◆ ' : '▲ ')
66
67// A label and its percentage; the label alone where the reading is too old to show.
68type FigureProps = { elements: Elements; label: string; percent?: number | undefined }
69
70function Figure({ elements, label, percent }: FigureProps) {
71 const { Box, Text } = elements
72
73 if (percent === undefined) {
74 return (
75 <Box flexShrink={0}>
76 <Text>{label}</Text>
77 </Box>
78 )
79 }
80
81 const severity = severityOf(percent)
82
83 return (
84 <Box flexShrink={0}>
85 <Text>{`${label} `}</Text>
86 {severity !== 'normal' && <Text color={COLORS[severity]}>{markOf(severity)}</Text>}
87 <Text bold>{`${Math.round(percent)}%`}</Text>
88 </Box>
89 )
90}
91
92// The cells a tile `width` across has left beside its figure, after the gap between them.
93function roomBeside(label: string, percent: number | undefined, width: number) {
94 const severity = percent === undefined ? 'normal' : severityOf(percent)
95 const figure = percent === undefined ? label : `${label} ${severity === 'normal' ? '' : markOf(severity)}${Math.round(percent)}%`
96
97 return width - figure.length - 1
98}
99
100function toneStyle(tone: Tone) {
101 switch (tone) {
102 case 'plain':
103 return {}
104 case 'dim':
105 return { dimColor: true }
106 case 'warning':
107 return { color: COLORS.warning }
108 }
109}
110
111type DetailProps = { elements: Elements; segments: readonly Segment[] }
112
113// Measured to fit rather than left to wrap. A lone run is plain text, the likeliest to be
114// cut short with an ellipsis where a surface's text runs wider than measured.
115function Detail({ elements, segments }: DetailProps) {
116 const { Box, Text } = elements
117 const [only] = segments
118
119 return (
120 <Box flexShrink={1} minWidth={0}>
121 {segments.length === 1 && only !== undefined ? (
122 <Text {...toneStyle(only.tone)} wrap="truncate-end">
123 {only.text}
124 </Text>
125 ) : (
126 <Text wrap="truncate-end">
127 {segments.map(segment => (
128 <Text {...toneStyle(segment.tone)}>{segment.text}</Text>
129 ))}
130 </Text>
131 )}
132 </Box>
133 )
134}
135
136type HeaderProps = {
137 elements: Elements
138 label: string
139 percent?: number | undefined
140 candidates: readonly Segment[][]
141 width: number
142}
143
144// The figure, and the fullest detail that fits beside it on one line.
145function Header({ elements, label, percent, candidates, width }: HeaderProps) {
146 const { Box } = elements
147 const segments = fitting(candidates, roomBeside(label, percent, width))
148
149 return (
150 <Box justifyContent="space-between" alignItems="flex-start" columnGap={1}>
151 <Figure elements={elements} label={label} percent={percent} />
152 {segments.length > 0 && <Detail elements={elements} segments={segments} />}
153 </Box>
154 )
155}
156
157// A tile's two parts: its figure and detail, and its meter, which a reading too old to
158// show goes without.
159type Column = { header: RenderElement; meter?: RenderElement }
160
161type Shape = { width: number; isBrief: boolean; isOld: boolean; meter: Meter }
162
163function limitColumn(elements: Elements, limit: Limit, view: LimitView, { width, isBrief, isOld, meter }: Shape): Column {
164 const label = labelOf(limit.kind)
165 const pick = (candidates: readonly Segment[][]) => (isBrief ? brief(candidates) : candidates)
166
167 if (isOld) {
168 return { header: <Header elements={elements} label={label} candidates={pick(oldDetailsOf(view))} width={width} /> }
169 }
170
171 return {
172 header: <Header elements={elements} label={label} percent={view.percent} candidates={pick(detailsOf(view))} width={width} />,
173 meter: meter(view, width, COLORS[severityOf(view.percent)], altOf(limit, view)),
174 }
175}
176
177function contextColumn(elements: Elements, context: Context, width: number, meter: Meter): Column {
178 return {
179 header: <Header elements={elements} label="Context" percent={context.percent} candidates={contextDetailsOf(context)} width={width} />,
180 meter: meter(
181 { percent: context.percent, hasReset: false },
182 width,
183 COLORS[severityOf(context.percent)],
184 `Context window, ${Math.round(context.percent)}% full`,
185 ),
186 }
187}
188
189type BandProps = {
190 elements: Elements
191 limits: readonly Limit[]
192 context: Context | null
193 now: number
194 readingAt: number
195 columns: number
196 isBrief: boolean
197 meter: Meter
198}
199
200function Band({ elements, limits, context, now, readingAt, columns, isBrief, meter }: BandProps) {
201 const { Box, Text } = elements
202
203 if (limits.length === 0 && !context) {
204 return (
205 <Box>
206 <Text dimColor>Rate limits show up here after the first reply</Text>
207 </Box>
208 )
209 }
210
211 const { direction, tile } = layoutOf(columns, limits.length + (context ? 1 : 0))
212 // The newest reading any chat has: too old, and its numbers are left out rather than shown wrong.
213 const shape = { width: tile, isBrief, isOld: now - readingAt >= OLD_AFTER, meter }
214 const all = [
215 ...limits.map(limit => limitColumn(elements, limit, viewOf(limit, now, readingAt), shape)),
216 ...(context ? [contextColumn(elements, context, tile, meter)] : []),
217 ]
218
219 if (direction === 'column') {
220 return (
221 <Box flexDirection="column">
222 {all.map(column => (
223 <Box flexDirection="column" width={tile}>
224 {column.header}
225 {column.meter !== undefined && column.meter}
226 </Box>
227 ))}
228 </Box>
229 )
230 }
231
232 // The figures in one row and the meters in the next, so every meter starts on the
233 // same line, whatever the text above it does. Each cell is a column, so a header
234 // stretches across its tile and its detail sits at the tile's right end.
235 return (
236 <Box flexDirection="column">
237 <Box columnGap={GAP}>
238 {all.map(column => (
239 <Box flexDirection="column" width={tile}>
240 {column.header}
241 </Box>
242 ))}
243 </Box>
244 <Box columnGap={GAP}>
245 {all.map(column => (
246 <Box flexDirection="column" width={tile}>
247 {column.meter !== undefined && column.meter}
248 </Box>
249 ))}
250 </Box>
251 </Box>
252 )
253}
254
255// Keep this chat's context fill once a reply has reported it.
256async function measureContext($: EngineInterface, usage: { tokens?: number; window: number; percent?: number }) {
257 const { tokens, window, percent } = usage
258
259 if (tokens !== undefined && percent !== undefined) {
260 await update($, contextAtom, () => ({ tokens, window, percent }))
261 }
262}
263
264async function tick($: EngineInterface) {
265 const now = await $.clock.now()
266 await update($, nowAtom, () => now)
267}
268
269// Show a reading; this chat's own readings are also saved for the next chat.
270async function adopt($: EngineInterface, limits: Limit[], at: number, isOwn: boolean) {
271 await update($, limitsAtom, () => limits)
272 await update($, readingAtAtom, () => at)
273
274 if (isOwn) {
275 await $.store.set(LATEST, { limits, at })
276 }
277}
278
279// Take the reading another chat saved when it is newer than the one on show.
280async function catchUp($: EngineInterface) {
281 const saved = await $.store.get(LATEST)
282
283 if (isReading(saved) && saved.at > (await read($, readingAtAtom))) {
284 await adopt($, saved.limits, saved.at, false)
285 }
286}
287
288async function refresh($: EngineInterface) {
289 await tick($)
290 // A store that cannot be read leaves this chat's own reading on show.
291 await catchUp($).catch(() => undefined)
292}
293
294// Toasts each window once per threshold it crosses; a reset re-arms it.
295async function alert($: EngineInterface, limits: readonly Limit[]) {
296 const toasted = await read($, toastedAtom)
297 const reached: Record<string, number> = {}
298
299 for (const limit of limits) {
300 const threshold = thresholdOf(limit.percentUsed)
301
302 if (threshold === undefined) {
303 continue
304 }
305
306 const last = toasted[limit.kind]
307
308 if (last === undefined || threshold > last) {
309 $.ui.toast(`${labelOf(limit.kind)} limit at ${Math.round(limit.percentUsed)}%`, {
310 timeoutMs: 8000,
311 })
312 }
313
314 reached[limit.kind] = threshold
315 }
316
317 await update($, toastedAtom, () => reached)
318}
319
320export const register: Register = on => {
321 on('session.start', async ($, e, next) => {
322 // The chat's own context, which is live. Its rate limits are left to replies and to
323 // the saved reading: what the engine holds at a start may be a quiet chat's last
324 // numbers, and stamping them now would pass them off as new to every chat.
325 const { context } = await $.session.usage()
326 await measureContext($, context)
327 await refresh($)
328 // Takes up a newer reading another chat saved within seconds, so every chat shows the same.
329 $.clock.every(5_000, () => void catchUp($).catch(() => undefined))
330 // Keeps the countdowns current between replies.
331 $.clock.every(60_000, () => void tick($))
332
333 return next(e)
334 })
335
336 on('session.measure', async ($, e, next) => {
337 await measureContext($, e.context)
338
339 if (e.rateLimits.length > 0) {
340 await adopt($, [...e.rateLimits], await $.clock.now(), true)
341
342 if (e.changed.includes('rateLimits')) {
343 await alert($, e.rateLimits)
344 }
345 }
346
347 await tick($)
348
349 return next(e)
350 })
351
352 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
353 if (e.props.hasSurvey) {
354 return next(e)
355 }
356
357 const limits = await read($, limitsAtom)
358 const context = await read($, contextAtom)
359 const readingAt = await read($, readingAtAtom)
360 const now = (await read($, nowAtom)) || (await $.clock.now())
361 const columns = e.props.bodyColumns
362
363 if (e.surface === 'desktop') {
364 const { Box, Text, Svg } = $.ui.resolve(e)
365 const meter: Meter = (view, width, color, alt) => (
366 <Svg
367 source={meterSvg(width * PX_PER_CELL, view.percent, view.elapsed, color, MUTED)}
368 alt={alt}
369 height={METER_HEIGHT}
370 />
371 )
372
373 return (
374 <Band
375 elements={{ Box, Text }}
376 limits={limits}
377 context={context}
378 now={now}
379 readingAt={readingAt}
380 columns={columns}
381 isBrief
382 meter={meter}
383 />
384 )
385 }
386
387 if (e.surface === 'terminal') {
388 const { Box, Text } = $.ui.resolve(e)
389 const meter: Meter = (view, width, color) => (
390 <Box>
391 {meterRuns(width, view.percent, view.elapsed).map(run => (
392 <Text {...runStyle(run.role, color)}>{run.text}</Text>
393 ))}
394 </Box>
395 )
396
397 return (
398 <Band elements={{ Box, Text }} limits={limits} context={context} now={now} readingAt={readingAt} columns={columns} isBrief={false} meter={meter} />
399 )
400 }
401
402 return next(e)
403 })
404}
405hooks/format.ts 249 lines1import type { Context, Limit, Reading } from '../types'
2
3const HOUR = 3_600_000
4
5const LABELS: Record<string, string> = {
6 five_hour: '5-hour',
7 seven_day: 'Weekly',
8 spend_limit: 'Spend',
9}
10
11export const THRESHOLDS = [80, 95]
12
13export type Severity = 'normal' | 'warning' | 'critical'
14
15const capitalize = (text: string) => text.charAt(0).toUpperCase() + text.slice(1)
16
17export function labelOf(kind: string) {
18 const label = LABELS[kind]
19
20 if (label !== undefined) {
21 return label
22 }
23
24 // Per-model weekly windows, such as seven_day_opus.
25 if (kind.startsWith('seven_day_')) {
26 return `Weekly · ${capitalize(kind.slice('seven_day_'.length))}`
27 }
28
29 return capitalize(kind.replaceAll('_', ' '))
30}
31
32// How long a window runs, where the kind says; a spend limit has no fixed length.
33export function windowMsOf(kind: string) {
34 if (kind === 'five_hour') {
35 return 5 * HOUR
36 }
37
38 return kind === 'seven_day' || kind.startsWith('seven_day_') ? 7 * 24 * HOUR : undefined
39}
40
41export function formatDuration(ms: number) {
42 const minutes = Math.max(0, Math.round(ms / 60_000))
43 const days = Math.floor(minutes / 1440)
44 const hours = Math.floor((minutes % 1440) / 60)
45 const rest = minutes % 60
46
47 if (days > 0) {
48 return hours > 0 ? `${days}d ${hours}h` : `${days}d`
49 }
50
51 if (hours > 0) {
52 return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`
53 }
54
55 return `${rest}m`
56}
57
58export function timeUntil(resetsAt: string | undefined, now: number) {
59 return resetsAt === undefined ? undefined : formatDuration(Date.parse(resetsAt) - now)
60}
61
62export const severityOf = (percentUsed: number): Severity =>
63 percentUsed >= 95 ? 'critical' : percentUsed >= 80 ? 'warning' : 'normal'
64
65// The highest threshold a window has reached, or undefined below the first.
66export const thresholdOf = (percentUsed: number) =>
67 THRESHOLDS.filter(threshold => percentUsed >= threshold).at(-1)
68
69export type Pace = {
70 // How far through its window the clock is, 0 to 1.
71 elapsed: number
72 // `early`: too soon, or too little used, to project. `limit`: at the average
73 // rate so far the window runs out `hitsLimitInMs` from now, before it resets.
74 outlook: 'early' | 'on-track' | 'limit'
75 hitsLimitInMs?: number
76}
77
78export function paceOf(limit: Limit, now: number): Pace | undefined {
79 const windowMs = windowMsOf(limit.kind)
80 const resetsAt = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
81
82 if (windowMs === undefined || Number.isNaN(resetsAt)) {
83 return undefined
84 }
85
86 const remainingMs = Math.max(0, resetsAt - now)
87 const elapsedMs = Math.min(windowMs, windowMs - remainingMs)
88 const elapsed = elapsedMs / windowMs
89
90 if (elapsed < 0.1 || limit.percentUsed < 5 || limit.percentUsed >= 100) {
91 return { elapsed, outlook: 'early' }
92 }
93
94 const hitsLimitInMs = ((100 - limit.percentUsed) / limit.percentUsed) * elapsedMs
95
96 return hitsLimitInMs < remainingMs
97 ? { elapsed, outlook: 'limit', hitsLimitInMs }
98 : { elapsed, outlook: 'on-track' }
99}
100
101// Token counts as people say them: 950, 82k, 1.2M.
102export function compactTokens(count: number) {
103 if (count < 1000) {
104 return String(count)
105 }
106
107 if (count < 1_000_000) {
108 return `${Math.round(count / 1000)}k`
109 }
110
111 return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
112}
113
114// A reading older than this is shown with its age, and without a forecast.
115export const STALE_AFTER = 2 * 60_000
116// A reading older than this is not shown as numbers at all: limits count use anywhere,
117// and only a reply brings new ones, so by now they may well be wrong.
118export const OLD_AFTER = 60 * 60_000
119
120export type LimitView = {
121 // What the figure and the meter show: 0 once the window has reset since the reading.
122 percent: number
123 // How far through its window the clock is, for the meter's mark.
124 elapsed?: number
125 // How long until the window runs out at this pace, when that comes before the reset.
126 forecastMs?: number
127 // The time left until the window resets, as text.
128 resets?: string
129 // How old the reading is, as text, once it is stale.
130 age?: string
131 hasReset: boolean
132 // How long ago the window reset, as text, once it has.
133 resetAgo?: string
134}
135
136// What one window looks like now, from a reading taken at `readingAt`.
137export function viewOf(limit: Limit, now: number, readingAt: number): LimitView {
138 const resetAt = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
139 const age = readingAt > 0 && now - readingAt >= STALE_AFTER ? formatDuration(now - readingAt) : undefined
140
141 if (resetAt <= now) {
142 return { percent: 0, hasReset: true, age, resetAgo: formatDuration(now - resetAt) }
143 }
144
145 const pace = paceOf(limit, now)
146
147 return {
148 percent: limit.percentUsed,
149 elapsed: pace?.elapsed,
150 forecastMs: age === undefined && pace?.outlook === 'limit' ? pace.hitsLimitInMs : undefined,
151 resets: timeUntil(limit.resetsAt, now),
152 age,
153 hasReset: false,
154 }
155}
156
157export type Tone = 'plain' | 'dim' | 'warning'
158
159// A run of the line beside a figure, and how it is inked.
160export type Segment = { text: string; tone: Tone }
161
162const dim = (text: string): Segment => ({ text, tone: 'dim' })
163const plain = (text: string): Segment => ({ text, tone: 'plain' })
164
165// A lead, then notes after it, dimmed and set off by dots.
166function withNotes(lead: Segment[], notes: readonly string[]): Segment[] {
167 if (notes.length === 0) {
168 return lead
169 }
170
171 const text = notes.join(' · ')
172
173 return [...lead, dim(lead.length > 0 ? ` · ${text}` : text)]
174}
175
176// The line beside a window's figure, fullest first. The band shows the first that
177// fits, so a narrow tile drops the reading's age, then the reset, never wrapping.
178export function detailsOf(view: LimitView): Segment[][] {
179 if (view.hasReset) {
180 const ago = view.resetAgo === undefined || view.resetAgo === '0m' ? 'just now' : `${view.resetAgo} ago`
181
182 return [[dim(`reset ${ago}`)], [dim('reset')]]
183 }
184
185 const resets = view.resets === undefined ? [] : [`resets in ${view.resets}`]
186 const notes = view.age === undefined ? resets : [`as of ${view.age} ago`, ...resets]
187
188 if (view.percent >= 100) {
189 // The figure already says the limit is reached; when it comes back matters more.
190 const reached = [plain('Limit reached')]
191
192 return [withNotes(reached, notes), withNotes(reached, resets), withNotes([], resets)]
193 }
194
195 if (view.forecastMs !== undefined) {
196 const forecast = formatDuration(view.forecastMs)
197 // An arrow, not a second ▲: a projection, beside the figure's own warning.
198 const arrow: Segment = { text: '→ ', tone: 'warning' }
199 const lead = [arrow, plain(`limit in ~${forecast}`)]
200
201 return [withNotes(lead, notes), lead, [arrow, plain(`~${forecast}`)]]
202 }
203
204 return [withNotes([], notes), withNotes([], resets)]
205}
206
207// The desktop counts its width in cells of its code font, and its text runs wider, so
208// there each figure gets a single fact beside it, short enough for any tile: the
209// forecast, the reset or the reached limit's return, never a second fact after a dot.
210export const brief = (candidates: readonly Segment[][]) =>
211 candidates.filter(segments => !segments.some(segment => segment.text.includes(' · ')))
212
213export const briefDetailsOf = (view: LimitView) => brief(detailsOf(view))
214
215// The line beside a window whose reading is too old to show: when it reset, which stays
216// true however old the reading, else that there is nothing recent; fullest first.
217export function oldDetailsOf(view: LimitView): Segment[][] {
218 return view.hasReset ? detailsOf(view) : [[dim('no recent reading · a reply updates it')], [dim('no recent reading')]]
219}
220
221// The line beside the context figure, fullest first.
222export function contextDetailsOf(context: Context): Segment[][] {
223 const tokens = compactTokens(context.tokens)
224
225 return [[dim(`${tokens} of ${compactTokens(context.window)}`)], [dim(tokens)]]
226}
227
228export const widthOf = (segments: readonly Segment[]) =>
229 segments.reduce((width, segment) => width + segment.text.length, 0)
230
231// The fullest of `candidates` that takes at most `room` cells; none when even the sparest is wider.
232export function fitting(candidates: readonly Segment[][], room: number): Segment[] {
233 return candidates.find(segments => widthOf(segments) <= room) ?? []
234}
235
236// Whether a value read back from the store is a reading this mod saved.
237export function isReading(value: unknown): value is Reading {
238 if (typeof value !== 'object' || value === null) {
239 return false
240 }
241
242 const { limits, at } = value as Partial<Reading>
243
244 return typeof at === 'number' && Array.isArray(limits) && limits.every(
245 limit => typeof limit === 'object' && limit !== null
246 && typeof limit.kind === 'string' && typeof limit.percentUsed === 'number',
247 )
248}
249hooks/meter.ts 69 lines1// The meter in two forms: glyph runs for the terminal, an SVG for the desktop.
2// Both mark how far through its window the clock is, so use running ahead of
3// time shows as fill past the mark.
4
5export type MeterRole = 'fill' | 'empty' | 'mark-fill' | 'mark-empty'
6
7export type MeterRun = { text: string; role: MeterRole }
8
9const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value))
10
11export function meterRuns(width: number, percent: number, elapsed?: number): MeterRun[] {
12 const cells = Math.max(1, Math.floor(width))
13 const halves = Math.round((clamp(percent, 0, 100) / 100) * cells * 2)
14 // Any use at all shows as at least half a cell.
15 const units = percent > 0 ? Math.max(1, halves) : 0
16 const full = Math.floor(units / 2)
17 const hasHalf = units % 2 === 1
18 const mark = elapsed === undefined ? -1 : Math.min(cells - 1, Math.floor(clamp(elapsed, 0, 1) * cells))
19 const runs: MeterRun[] = []
20
21 for (let cell = 0; cell < cells; cell++) {
22 const isFilled = cell < full || (cell === full && hasHalf)
23 const glyph =
24 cell === mark
25 ? { text: isFilled ? '┿' : '┼', role: isFilled ? 'mark-fill' : 'mark-empty' }
26 : cell < full
27 ? { text: '━', role: 'fill' }
28 : isFilled
29 ? { text: '╸', role: 'fill' }
30 : { text: '─', role: 'empty' }
31 const last = runs.at(-1)
32
33 if (last?.role === glyph.role) {
34 last.text += glyph.text
35 } else {
36 runs.push(glyph as MeterRun)
37 }
38 }
39
40 return runs
41}
42
43export const METER_HEIGHT = 9
44
45export function meterSvg(width: number, percent: number, elapsed: number | undefined, color: string, markColor: string) {
46 const w = Math.max(24, Math.round(width))
47 const barY = 2
48 const barH = 5
49 const radius = barH / 2
50 // Any use at all shows as at least the rounded end.
51 const fillW = percent > 0 ? Math.max(barH, (clamp(percent, 0, 100) / 100) * w) : 0
52 const markX = elapsed === undefined ? undefined : clamp(elapsed * w, 1, w - 1)
53 // A tick standing proud of the bar, so it reads over the fill and the track.
54 const mark =
55 markX === undefined
56 ? ''
57 : `<rect x="${(markX - 1).toFixed(1)}" width="2" height="${METER_HEIGHT}" rx="1" fill="${markColor}"/>`
58
59 return (
60 // Stretches to whatever width the surface gives it, the height kept.
61 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${METER_HEIGHT}" viewBox="0 0 ${w} ${METER_HEIGHT}" preserveAspectRatio="none">` +
62 `<defs><clipPath id="track"><rect y="${barY}" width="${w}" height="${barH}" rx="${radius}"/></clipPath></defs>` +
63 `<rect y="${barY}" width="${w}" height="${barH}" rx="${radius}" fill="${color}" fill-opacity="0.22"/>` +
64 `<rect clip-path="url(#track)" y="${barY}" width="${fillW.toFixed(1)}" height="${barH}" rx="${radius}" fill="${color}"/>` +
65 mark +
66 `</svg>`
67 )
68}
69types/index.d.ts 27 lines1// One rate-limit window as the engine reports it (SessionRateLimit).
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3
4// How full this chat's context window is, from the last reply.
5export type Context = { percent: number; tokens: number; window: number }
6
7// The latest limits any chat has seen and when, kept in the mod's store so a new chat can
8// show them before its own first reply.
9export type Reading = { limits: Limit[]; at: number }
10
11declare module 'claude-code' {
12 interface PluginState {
13 'Runway': {
14 // This chat's context window; null until a reply reports it.
15 context: Context | null
16 // The windows of the reading on show.
17 limits: Limit[]
18 // When that reading was taken, in milliseconds since the epoch; 0 before any.
19 readingAt: number
20 // The time the band counts down from, moved on once a minute.
21 now: number
22 // The threshold each window last toasted at, so each crossing toasts once.
23 toasted: Record<string, number>
24 }
25 }
26}
27