SLOPSHOPPER

Runway

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

newbandtoasttimer
★ 2v0.5.0MITupdated 2026-10-07Heuwzen/claude-runway/runway
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · Runway
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ 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 5-hour 31% resets in NaNm Context 49% 97k of 200k ━━━━━━━━━━━━━━━───────────────────────────────── ━━━━━━━━━━━━━━━━━━━━━━━╸──────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5-hour 31% resets in NaNm Context 49% 97k of 200k ━━━━━━━━━━━━━━━───────────────────────────────── ━━━━━━━━━━━━━━━━━━━━━━━╸────────────────────────
README

Runway

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

Runway's three meters above the prompt in the Claude desktop app: the 5-hour limit, the weekly limit and the context window

What it shows

  • Your 5-hour and weekly limits. Each meter is blue, turns amber with a ▲ at 80% and red with a ◆ at 95%, and you get a pop-up as it crosses each of those.
  • Your pace. A tick on each meter marks how much of the window has passed. When the bar runs past the tick, you're using the limit faster than it resets.
  • A warning before you run out. If you're on course to hit a limit before it resets, the meter says when: "→ limit in ~3h 48m".
  • When each window resets, counting down.
  • How full the chat's context window is, once a reply has reported it.

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
━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━━╸──────────      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸──────┼──

The same numbers in every chat

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.

Fits any window

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.

Install

Runway installs with the other mods in this repository. See the main README.

Development

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.

License

MIT

Source 4 files
hooks/register.tsx 405 lines
1import { 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}
405
hooks/format.ts 249 lines
1import 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}
249
hooks/meter.ts 69 lines
1// 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}
69
types/index.d.ts 27 lines
1// 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