SLOPSHOPPER

limits-meter

Plan limits and context above the prompt: 5-hour and weekly windows with reset times, context fill, cache hit rate, tokens per turn

newpanebandcommandtoast
★ 2v0.3.1MITupdated 2026-10-09ice-lfernandes/claude-code-mods/limits-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limits-meter
│ ┃ Limits & context ✕ › fix the failing auth test and add an audit log call │ ┃ Figures the engine reports after each │ ┃ turn. Tokens and percent, never money. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Plan windows ⏺ Update(src/auth.ts) │ ┃ 5h ███████░░░░░░░░░░░░░░░░░ 31% ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Context ⎿ 3 pass, 1 fail │ ┃ ctx ████████████░░░░░░░░░░░░ 49% 97k │ ┃ of 200k ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Turns (main thread) ✻ Worked for 42s · done 4:20 PM │ ┃ in out cache time model │ ┃ #1 97k 1.5k 93% 42s opus › /limits │ ┃ 5.5 │ ┃ │ ┃ /limits hide · show · help [ Close ] │ 5h ██░░░░ 31% ctx ███░░░ 49% cache 93% details hide ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h ██░░░░ 31% ctx ███░░░ 49% cache 93% details hide ⟨Claude Code's own drawing⟩
Pane · Limits & context
Figures the engine reports after each turn. Tokens and percent, never money. Plan windows 5h ███████░░░░░░░░░░░░░░░░░ 31% Context ctx ████████████░░░░░░░░░░░░ 49% 97k of 200k Turns (main thread) in out cache time model #1 97k 1.5k 93% 42s opus 5.5 /limits hide · show · help [ Close ]
README

limits-meter

Plan limits and context above the prompt, in tokens and percent. Never money.

limits-meter: band above the prompt, the /limits pane and its toasts

  • Band above the prompt: the 5-hour and weekly plan windows with their reset times, the context window's fill, and the last turn's cache hit rate. Colors follow the theme: warning at 70%, error at 90% (the warnAt and dangerAt options). Below 90 columns each bar shrinks to one cell. details opens the pane, hide hides the band, and from 85% context compact puts /compact [focus] in the prompt, with the blank marked: nothing runs until you send it.
  • /limits opens a pane with the same figures at full width, context in tokens (116k of 200k), and the last main-thread turns that fit, under column heads: input, output, cache hit, duration, model. The turn with the most input stands out. Its footer has the verbs and a close button. The command runs mid-turn.
  • Pace: each window's readings since it last reset draw a line; when that line reaches 100% before the reset, the pane says so: at this pace, 5h reaches 100% in ~1h20, before it resets.
  • Context trend: a sparkline of the context fill at the end of each turn.
  • Toasts, once each: a window at 80, 90 and 100% (re-armed when the window resets), and context at 85% with a hint to /compact (re-armed once it drops below 50%).
/limits          open the pane
/limits hide     hide the band above the prompt, in later sessions too
/limits show     bring the band back
/limits help     list the commands

Options

Set them in /config, under the plugin.

OptionValuesDefault
languageauto, pt-BR, en. auto follows the system's LANG: Portuguese for pt_*, English otherwiseauto
cellsThe band's cells, comma-separated, from 5h, wk, spend, ctx, cache. all shows them allall
densityauto sizes the bars to the terminal and shrinks each to one cell (▁ to █) below 90 columns; bars always draws 10 cells; mini always one; numbers no barauto
warnAtPercent from which a window or the context turns to the warning color70
dangerAtPercent from which it turns to the error color90

Figures come from the engine's session.measure event, pushed after each turn, so nothing polls. Plan windows appear on a subscription after the first response; with an API key the band shows context only.

Install

/plugin install limits-meter --marketplace ice-lfernandes/claude-code-mods

Or for one session: claude --plugin-dir ./limits-meter

What it reaches

ModNetworkRuns processesFilesCalls a modelSends data anywhere
limits-meterNoNoNoNoNo

It stores four values in the session's plugin state (the latest figures, recent turns, whether the band is hidden, and which alerts fired). Across sessions it keeps one value in the plugin's store: whether you hid the band.

Source 5 files
hooks/register.tsx 325 lines
1// limits-meter: plan limits and context, above the prompt and in a pane.
2//
3//   band   AbovePrompt: 5-hour and weekly windows with reset times, context fill, last turn's
4//          cache hit rate. Tokens and percent only, never money. `details` opens the pane,
5//          `hide` hides the band, and from 85% context `compact` puts /compact in the prompt.
6//          The `cells`, `density`, `warnAt` and `dangerAt` options shape it; below 90 columns
7//          each bar shrinks to one cell.
8//   pace   each window's readings since its reset; when their pace reaches 100% before the
9//          reset, the pane says when.
10//   /limits  opens a pane with the same figures at full width and the last turns' tokens;
11//          /limits hide | show toggles the band, and the choice is kept across sessions.
12//   toasts once per threshold: a window at 80, 90 and 100%, context at 85%.
13//
14// Figures come from `session.measure` (pushed by the engine after each turn and when a window
15// moves a point), so nothing polls. Reads nothing from disk, runs no process, calls no model.
16
17import { atom, read, update } from 'claude-code'
18import type { EngineInterface, Register } from 'claude-code'
19
20import type { Samples, Snapshot, Turn } from '../types'
21import { addSamples, alerts, bar, contextSpark, heaviest, label, mini, needsCompact, pace, pct, resetIn, summary, toSnapshot, toTurn, tone as toneOf, TURNS_KEPT } from './meter'
22import type { Lang, Verb } from './ui'
23import { fillArgs, langOf, linesOf, shortModel, tokens, verbRow } from './ui'
24import { COMMAND, WORDS } from './words'
25
26const PANE = 'limits'
27const KEY_HIDDEN = 'hidden'
28
29const EMPTY: Snapshot = { limits: [], contextPercent: null, contextTokens: null, contextWindow: 0 }
30
31const snapshot = atom({ plugin: 'limits-meter', key: 'snapshot' } as const, EMPTY)
32const turns = atom({ plugin: 'limits-meter', key: 'turns' } as const, [] as Turn[])
33const isHidden = atom({ plugin: 'limits-meter', key: 'isHidden' } as const, false)
34const fired = atom({ plugin: 'limits-meter', key: 'fired' } as const, [] as string[])
35const samples = atom({ plugin: 'limits-meter', key: 'samples' } as const, {} as Samples)
36
37// Set by register from the options, and by session.start from the system's LANG.
38let lang: Lang = 'en'
39let warnAt = 70
40let dangerAt = 90
41/** The band's cells by id (5h, wk, spend, ctx, cache); null shows them all. */
42let cells: Set<string> | null = null
43let density: 'auto' | 'bars' | 'mini' | 'numbers' = 'auto'
44
45const tone = (percent: number | null) => toneOf(percent, warnAt, dangerAt)
46
47const CELL_IDS: Record<string, string> = { five_hour: '5h', seven_day: 'wk', spend_limit: 'spend' }
48const shows = (id: string) => cells === null || cells.has(id)
49
50/** The `cells` option: names from 5h, wk, spend, ctx, cache, comma-separated; `all` or nothing known shows them all. */
51const cellsOf = (option: unknown): Set<string> | null => {
52  const names = String(option ?? '')
53    .toLowerCase()
54    .split(/[\s,]+/)
55    .filter(n => ['5h', 'wk', 'spend', 'ctx', 'cache'].includes(n))
56  return names.length ? new Set(names) : null
57}
58
59/** Bar cells for the band: by the terminal's width on `auto`, 0 for one cell, -1 for none. */
60const barWidth = (columns: number) =>
61  density === 'bars' ? 10 : density === 'mini' ? 0 : density === 'numbers' ? -1 : columns >= 120 ? 10 : columns >= 90 ? 6 : 0
62
63const take = async ($: EngineInterface, next: Snapshot) => {
64  await update($, snapshot, () => next)
65  const now = await $.clock.now()
66  await update($, samples, list => addSamples(list, next, now))
67  const result = alerts(next, await read($, fired), now, lang)
68  if (result.raised.length === 0) return
69  await update($, fired, () => result.fired)
70  for (const a of result.raised) $.ui.toast(a.text, { timeoutMs: 8000 })
71}
72
73/** Hides or shows the band, and keeps the choice for later sessions. */
74const setHidden = async ($: EngineInterface, hidden: boolean) => {
75  await update($, isHidden, () => hidden)
76  await $.store.set(KEY_HIDDEN, hidden)
77}
78
79const open = ($: EngineInterface) => $.ui.open({ id: PANE, title: WORDS[lang].pane, focus: true, closeOnEscape: true }).catch(() => null)
80
81/** /limits and its arguments: what the command answers, and what the pane's verbs run. */
82const runCommand = async ($: EngineInterface, args: string): Promise<{ text?: string }> => {
83  const w = WORDS[lang]
84  switch (args.trim().toLowerCase()) {
85    case 'hide':
86      await setHidden($, true)
87      return { text: w.hidden }
88    case 'show':
89      await setHidden($, false)
90      return { text: w.shown }
91    case 'help':
92      return { text: w.help }
93    case '': {
94      const opened = await open($)
95      if (opened?.isPlaced) return {}
96      const list = await read($, turns)
97      return { text: summary(await read($, snapshot), list[list.length - 1], await $.clock.now(), lang) }
98    }
99    default:
100      return { text: w.help }
101  }
102}
103
104/** Puts a text in the prompt for the person to send; runs nothing. */
105const fill = async ($: EngineInterface, text: string) => {
106  await $.prompt.fill(fillArgs(text))
107}
108
109/** The pane's verbs: each one is undone by another, so all of them run. */
110const VERBS: readonly Verb[] = [{ verb: 'hide' }, { verb: 'show' }, { verb: 'help' }]
111
112/** A verb pressed in the pane: runs and writes its answer to the transcript. */
113const pressVerb = async ($: EngineInterface, v: Verb) => {
114  try {
115    if (v.fill) return await fill($, v.fill)
116    const { text } = await runCommand($, v.verb)
117    for (const line of linesOf(text)) $.ui.log(line)
118  } catch {
119    $.ui.toast(WORDS[lang].failedToRun(`/${COMMAND} ${v.verb}`))
120  }
121}
122
123export const register: Register = (on, options) => {
124  lang = langOf(options.language)
125  const num = (v: unknown, d: number) => (Number.isFinite(Number(v)) && v !== '' && v != null ? Math.max(1, Math.min(100, Number(v))) : d)
126  warnAt = num(options.warnAt, 70)
127  dangerAt = Math.max(warnAt, num(options.dangerAt, 90))
128  cells = cellsOf(options.cells)
129  density = options.density === 'bars' || options.density === 'mini' || options.density === 'numbers' ? options.density : 'auto'
130
131  on('session.start', async ($, e, next) => {
132    const result = await next(e)
133    lang = langOf(options.language, await $.env.get('LANG').catch(() => undefined))
134    await $.command.register({
135      name: COMMAND,
136      description: WORDS[lang].description,
137      argumentHint: '[hide|show|help]',
138      immediate: true,
139    })
140    const hidden = (await $.store.get(KEY_HIDDEN).catch(() => undefined)) === true
141    await update($, isHidden, () => hidden)
142    try {
143      const usage = await $.session.usage()
144      await update($, snapshot, () => toSnapshot(usage.context, usage.rateLimits))
145    } catch {
146      // No figures yet; the first session.measure brings them.
147    }
148    return result
149  })
150
151  on('session.measure', async ($, e, next) => {
152    const result = await next(e)
153    await take($, toSnapshot(e.context, e.rateLimits))
154    return result
155  })
156
157  on('turn.complete', async ($, e, next) => {
158    const result = await next(e)
159    if (!e.agentId && e.usage) {
160      const turn = { ...toTurn(e.usage, e.durationMs), contextPercent: (await read($, snapshot)).contextPercent }
161      await update($, turns, list => [...list, turn].slice(-TURNS_KEPT))
162    }
163    return result
164  })
165
166  on('command.run', { command: COMMAND }, ($, e) => runCommand($, e.args))
167
168  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
169    if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
170    const s = await read($, snapshot)
171    if (s.limits.length === 0 && s.contextPercent === null) return next(e)
172
173    const { Box, Text, Button } = $.ui.resolve(e)
174    const w = WORDS[lang]
175    const now = await $.clock.now()
176    const list = await read($, turns)
177    const last = list[list.length - 1]
178    const columns = e.props.bodyColumns || e.viewport?.columns || 80
179    const width = barWidth(columns)
180    const meter = (percent: number) =>
181      width > 0 ? <Text color={tone(percent)}>{`${bar(width, percent)} `}</Text> : width === 0 ? <Text color={tone(percent)}>{mini(percent)}</Text> : null
182
183    const band = [
184      ...s.limits
185        .filter(l => shows(CELL_IDS[l.kind] ?? l.kind))
186        .map(l => {
187          const reset = resetIn(l.resetsAt, now, lang)
188          return (
189            <Text key={l.kind}>
190              <Text dimColor>{`${label(l.kind, lang)} `}</Text>
191              {meter(l.percent)}
192              <Text color={tone(l.percent)} bold>{pct(l.percent)}</Text>
193              {reset !== '' && <Text dimColor>{` ↻${reset}`}</Text>}
194            </Text>
195          )
196        }),
197      ...(shows('ctx')
198        ? [
199            <Text key="ctx">
200              <Text dimColor>ctx </Text>
201              {s.contextPercent !== null && meter(s.contextPercent)}
202              <Text color={tone(s.contextPercent)} bold>{pct(s.contextPercent)}</Text>
203            </Text>,
204          ]
205        : []),
206      ...(last?.cacheHit != null && shows('cache')
207        ? [
208            <Text key="cache">
209              <Text dimColor>cache </Text>
210              <Text>{pct(last.cacheHit * 100)}</Text>
211            </Text>,
212          ]
213        : []),
214    ]
215
216    const mine = (
217      <Box key="limits-meter" flexDirection="row" flexWrap="wrap" gap={2} paddingX={1}>
218        {band}
219        {needsCompact(s) && <Button key="compact" label={w.compact} plain color="warning" onPress={() => fill($, w.compactFill)} />}
220        <Button key="details" label={w.details} plain dimColor onPress={() => open($)} />
221        <Button key="hide" label={w.hide} plain dimColor onPress={() => setHidden($, true)} />
222      </Box>
223    )
224    const theirs = await next(e)
225    return theirs ? (
226      <Box flexDirection="column">
227        {mine}
228        {theirs}
229      </Box>
230    ) : (
231      mine
232    )
233  })
234
235  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
236    const { Box, Text, Button } = $.ui.resolve(e)
237    const w = WORDS[lang]
238    const s = await read($, snapshot)
239    const list = await read($, turns)
240    const readings = await read($, samples)
241    const now = await $.clock.now()
242    const columns = Math.max(30, (e.props.bodyColumns || e.viewport?.columns || 80) - 2)
243    const width = Math.max(10, Math.min(40, columns - 30))
244    const paces = s.limits.map(l => {
245      const points = readings[l.kind]
246      return l.resetsAt !== null && points && points.resetsAt === l.resetsAt ? pace(points.points, now, l.resetsAt) : null
247    })
248    const trend = list.filter(t => t.contextPercent != null)
249    const top = heaviest(list)
250    // Rows besides the turns: the hint, three headings, the context row, the table head, the
251    // footer, the gaps, a pace line per window that has one and the trend line.
252    const fixed = 12 + Math.max(1, s.limits.length) + paces.filter(p => p !== null).length + (trend.length > 1 ? 1 : 0)
253    const room = Math.max(3, (e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 30) - fixed)
254    const [cIn, cOut, cCache, cTime, cModel] = w.columns
255
256    return (
257      <Box flexDirection="column" paddingX={1} gap={1}>
258        <Text dimColor>{w.hint}</Text>
259
260        <Box flexDirection="column">
261          <Text bold>{w.planWindows}</Text>
262          {s.limits.length === 0 && <Text dimColor>{w.noWindows}</Text>}
263          {s.limits.map((l, i) => {
264            const left = paces[i]
265            return (
266              <Box key={l.kind} flexDirection="column">
267                <Text>
268                  <Text dimColor>{label(l.kind, lang).padEnd(6)}</Text>
269                  <Text color={tone(l.percent)}>{bar(width, l.percent)}</Text>
270                  <Text bold>{` ${pct(l.percent).padStart(4)}`}</Text>
271                  <Text dimColor>{l.resetsAt !== null ? `  ${w.resetsIn(resetIn(l.resetsAt, now, lang))}` : ''}</Text>
272                </Text>
273                {left != null && <Text color="warning">{`      ${w.pace(label(l.kind, lang), resetIn(now + left, now, lang))}`}</Text>}
274              </Box>
275            )
276          })}
277        </Box>
278
279        <Box flexDirection="column">
280          <Text bold>{w.contextHeading}</Text>
281          <Box flexDirection="row" flexWrap="wrap" gap={2}>
282            <Text>
283              <Text dimColor>{'ctx   '}</Text>
284              <Text color={tone(s.contextPercent)}>{bar(width, s.contextPercent ?? 0)}</Text>
285              <Text bold>{` ${pct(s.contextPercent).padStart(4)}`}</Text>
286              <Text dimColor>{s.contextTokens !== null ? `  ${w.ofWindow(tokens(s.contextTokens), tokens(s.contextWindow))}` : ''}</Text>
287            </Text>
288            {needsCompact(s) && <Button key="compact" label={w.compact} plain color="warning" onPress={() => fill($, w.compactFill)} />}
289          </Box>
290          {trend.length > 1 && (
291            <Text>
292              <Text dimColor>{'      '}</Text>
293              <Text color={tone(s.contextPercent)}>{contextSpark(list.slice(-width))}</Text>
294              <Text dimColor>{`  ${w.trend(Math.min(list.length, width))}`}</Text>
295            </Text>
296          )}
297        </Box>
298
299        <Box flexDirection="column">
300          <Text bold>{w.turns}</Text>
301          {list.length === 0 && <Text dimColor>{w.noTurns}</Text>}
302          {list.length > 0 && <Text dimColor>{`    ${cIn.padStart(8)}${cOut.padStart(8)}${cCache.padStart(7)}${cTime.padStart(7)}  ${cModel}`}</Text>}
303          {list.slice(-room).map((t, i, shown) => {
304            const isTop = list.length > 1 && t === top
305            return (
306              <Text key={`turn-${list.length - shown.length + i}`}>
307                <Text dimColor>{`#${list.length - shown.length + i + 1}`.padEnd(4)}</Text>
308                <Text color={isTop ? 'claude' : undefined} bold={isTop}>{tokens(t.input).padStart(8)}</Text>
309                <Text>{tokens(t.output).padStart(8)}</Text>
310                <Text color={t.cacheHit !== null && t.cacheHit < 0.5 ? 'warning' : undefined}>{pct(t.cacheHit === null ? null : t.cacheHit * 100).padStart(7)}</Text>
311                <Text dimColor>{`${`${Math.round(t.durationMs / 1000)}s`.padStart(7)}  ${shortModel(t.model)}`}</Text>
312              </Text>
313            )
314          })}
315        </Box>
316
317        <Box flexDirection="row" flexWrap="wrap" gap={2}>
318          {verbRow({ Box, Text, Button }, COMMAND, VERBS, v => pressVerb($, v))}
319          <Button key="close" role="dismiss" label={w.close} onPress={() => $.ui.close({ id: PANE })} />
320        </Box>
321      </Box>
322    )
323  })
324}
325
hooks/meter.ts 159 lines
1import type { Samples, Snapshot, Turn } from '../types'
2import type { Lang } from './ui'
3import { WORDS } from './words'
4
5export const LIMIT_ALERTS = [80, 90, 100]
6export const CONTEXT_ALERT = 85
7/** Below this the context alert re-arms (after a compaction or /clear). */
8export const CONTEXT_REARM = 50
9export const TURNS_KEPT = 20
10/** Readings kept per plan window for the pace, and how far apart they need to be. */
11export const SAMPLES_KEPT = 60
12const SAMPLE_GAP_MS = 60_000
13/** The pace needs readings over this long before it says anything. */
14const PACE_SPAN_MS = 10 * 60_000
15
16export const label = (kind: string, lang: Lang = 'en') => WORDS[lang].labels[kind] ?? kind
17
18/** Context full enough that the band and the pane offer /compact. */
19export const needsCompact = (s: Snapshot) => s.contextPercent !== null && s.contextPercent >= CONTEXT_ALERT
20
21type RateLimitIn = { kind: string; percentUsed: number; resetsAt?: string }
22type ContextIn = { tokens?: number; window: number; percent?: number }
23
24export const toSnapshot = (context: ContextIn, rateLimits: readonly RateLimitIn[]): Snapshot => ({
25  limits: rateLimits.map(r => {
26    const at = r.resetsAt ? Date.parse(r.resetsAt) : NaN
27    return { kind: r.kind, percent: r.percentUsed, resetsAt: Number.isFinite(at) ? at : null }
28  }),
29  contextPercent: context.percent ?? null,
30  contextTokens: context.tokens ?? null,
31  contextWindow: context.window,
32})
33
34/** "3d4h", "1h12", "7m", "now"; "" when unknown. */
35export const resetIn = (at: number | null, now: number, lang: Lang = 'en') => {
36  if (at === null) return ''
37  const minutes = Math.max(0, Math.round((at - now) / 60_000))
38  if (minutes === 0) return WORDS[lang].now
39  if (minutes < 60) return `${minutes}m`
40  const hours = Math.floor(minutes / 60)
41  if (hours < 24) return `${hours}h${String(minutes % 60).padStart(2, '0')}`
42  return `${Math.floor(hours / 24)}d${hours % 24}h`
43}
44
45export const pct = (n: number | null) => (n === null ? '–' : `${Math.round(n)}%`)
46
47export const bar = (width: number, percent: number) => {
48  const filled = Math.round((Math.max(0, Math.min(100, percent)) / 100) * width)
49  return '█'.repeat(filled) + '░'.repeat(width - filled)
50}
51
52/** The theme's colors: error from `dangerAt` (90%), warning from `warnAt` (70%), success below. */
53export const tone = (percent: number | null, warnAt = 70, dangerAt = 90) =>
54  percent === null ? undefined : percent >= dangerAt ? 'error' : percent >= warnAt ? 'warning' : 'success'
55
56const CELLS = '▁▂▃▄▅▆▇█'
57
58/** One cell for a percent, ▁ empty to █ full: the bar of a narrow terminal. */
59export const mini = (percent: number) => CELLS[Math.round((Math.max(0, Math.min(100, percent)) / 100) * 7)]!
60
61/** Context fill per turn, oldest first, on the 0 to 100 scale; a turn with no reading is a space. */
62export const contextSpark = (turns: readonly Turn[]) => turns.map(t => (t.contextPercent == null ? ' ' : mini(t.contextPercent))).join('')
63
64/** The turn with the most input, the one to look at first; undefined with no turns. */
65export const heaviest = (turns: readonly Turn[]) => turns.reduce<Turn | undefined>((top, t) => (!top || t.input > top.input ? t : top), undefined)
66
67/**
68 * The readings with the snapshot's added: one per window a minute apart at least, dropped when
69 * the window resets (its reset time moves), at most SAMPLES_KEPT.
70 */
71export const addSamples = (samples: Samples, s: Snapshot, now: number): Samples => {
72  const next: Samples = { ...samples }
73  for (const l of s.limits) {
74    if (l.resetsAt === null) continue
75    const was = samples[l.kind]
76    const points = was && was.resetsAt === l.resetsAt ? was.points : []
77    const last = points[points.length - 1]
78    if (last && now - last[0] < SAMPLE_GAP_MS && last[1] === l.percent) continue
79    const kept = last && now - last[0] < SAMPLE_GAP_MS ? points.slice(0, -1) : points
80    next[l.kind] = { resetsAt: l.resetsAt, points: [...kept, [now, l.percent] as [number, number]].slice(-SAMPLES_KEPT) }
81  }
82  return next
83}
84
85/**
86 * Milliseconds until the window reaches 100% at the pace of its readings (a least-squares
87 * line), or null: too few readings, too short a span, a flat or falling pace, or a window that
88 * resets first.
89 */
90export const pace = (points: readonly (readonly [number, number])[], now: number, resetsAt: number): number | null => {
91  if (points.length < 3) return null
92  const first = points[0]![0]
93  const last = points[points.length - 1]!
94  if (last[0] - first < PACE_SPAN_MS) return null
95  const n = points.length
96  const mx = points.reduce((a, p) => a + (p[0] - first), 0) / n
97  const my = points.reduce((a, p) => a + p[1], 0) / n
98  const sxx = points.reduce((a, p) => a + (p[0] - first - mx) ** 2, 0)
99  const sxy = points.reduce((a, p) => a + (p[0] - first - mx) * (p[1] - my), 0)
100  const slope = sxx > 0 ? sxy / sxx : 0
101  if (slope <= 0) return null
102  const at = first + mx + (100 - my) / slope
103  const left = Math.max(0, at - now)
104  return now + left < resetsAt ? left : null
105}
106
107export const toTurn = (
108  usage: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number; model: string },
109  durationMs: number,
110): Turn => {
111  const input = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
112  return {
113    input,
114    output: usage.output_tokens,
115    cacheHit: input > 0 ? usage.cache_read_input_tokens / input : null,
116    model: usage.model,
117    durationMs,
118  }
119}
120
121export type Alert = { key: string; text: string }
122
123/**
124 * Alerts the snapshot raises that have not fired yet. A limit alert is keyed by its window's
125 * reset time, so a new window re-arms it; the context alert re-arms once the fill drops below
126 * CONTEXT_REARM. Returns the alerts and the fired list to store.
127 */
128export const alerts = (s: Snapshot, fired: readonly string[], now: number, lang: Lang = 'en'): { raised: Alert[]; fired: string[] } => {
129  const w = WORDS[lang]
130  let next = [...fired]
131  const raised: Alert[] = []
132  for (const limit of s.limits) {
133    const hit = LIMIT_ALERTS.filter(t => limit.percent >= t)
134    const top = hit[hit.length - 1]
135    if (top === undefined) continue
136    const keys = hit.map(t => `${limit.kind}:${limit.resetsAt ?? '-'}:${t}`)
137    if (keys.every(k => next.includes(k))) continue
138    next = [...next, ...keys.filter(k => !next.includes(k))]
139    raised.push({ key: keys[keys.length - 1]!, text: w.windowAlert(label(limit.kind, lang), pct(limit.percent), resetIn(limit.resetsAt, now, lang)) })
140  }
141  const contextKey = `context:${CONTEXT_ALERT}`
142  if (s.contextPercent !== null && s.contextPercent < CONTEXT_REARM) next = next.filter(k => k !== contextKey)
143  if (s.contextPercent !== null && s.contextPercent >= CONTEXT_ALERT && !next.includes(contextKey)) {
144    next = [...next, contextKey]
145    raised.push({ key: contextKey, text: w.contextAlert(pct(s.contextPercent)) })
146  }
147  return { raised, fired: next }
148}
149
150/** One-line text summary, for the command's answer where no pane can open. */
151export const summary = (s: Snapshot, last: Turn | undefined, now: number, lang: Lang = 'en') => {
152  const w = WORDS[lang]
153  const parts = s.limits.map(l => `${label(l.kind, lang)} ${pct(l.percent)}${l.resetsAt !== null ? ` (${w.resetsIn(resetIn(l.resetsAt, now, lang))})` : ''}`)
154  parts.push(`${w.context} ${pct(s.contextPercent)}`)
155  if (last?.cacheHit != null) parts.push(`cache ${pct(last.cacheHit * 100)}`)
156  if (s.limits.length === 0) parts.push(w.noReadings)
157  return parts.join(' · ')
158}
159
hooks/ui.tsx 114 lines
1// Shared UI helpers, after launchpad's patterns. The same file in every mod that has one: a mod
2// installs alone and cannot import another's code, so scripts/check-shared.sh keeps the copies
3// equal. Change one, copy it to the others.
4//
5// Nothing here takes `$`: the engine follows `$` only into functions of the file that uses it,
6// never across an import. A call on `$` stays in register.tsx; this file gives it its arguments.
7//
8//   language and icons  the `language` and `icons` options, else the system's LANG and terminal.
9//   prompt              the arguments of $.prompt.fill: a text with its first `[blank]` marked.
10//   lists               the window of a long list a pane shows, for ui.scroll.
11//   verbs               a row of a command's arguments, one press each.
12//   numbers             tokens, elapsed time, clipped text and short model names.
13
14import type { Elements, PromptFillArgs } from 'claude-code'
15
16export type Lang = 'pt-BR' | 'en'
17export type IconStyle = 'emoji' | 'symbol'
18
19/** The language: the `language` option when it names one, else Portuguese for a pt LANG, else English. */
20export const langOf = (option: unknown, systemLang?: string | null): Lang =>
21  option === 'en' || option === 'pt-BR' ? option : /^pt([_.@-]|$)/i.test(systemLang ?? '') ? 'pt-BR' : 'en'
22
23/**
24 * The icon style: the `icons` option when it names one; on `auto` (or none), symbols in a
25 * JetBrains IDE's terminal (TERMINAL_EMULATOR=JetBrains-JediTerm), which gives many emoji one
26 * column where Claude Code counts two, and emoji everywhere else.
27 */
28export const styleOf = (option: unknown, terminal?: string | null): IconStyle =>
29  option === 'emoji' || option === 'symbol' ? option : /^JetBrains/i.test(terminal ?? '') ? 'symbol' : 'emoji'
30
31/** An icon in the style: its emoji, or the one-cell symbol that stands in for it. */
32export const glyph = (style: IconStyle, icon: { emoji: string; symbol: string }) => icon[style]
33
34const BLANK = /\[[^\]\n]+\]/
35
36/** The first `[blank]` in a text, as offsets, so the prompt can mark what to replace. */
37export const blankIn = (text: string): { start: number; end: number } | null => {
38  const m = BLANK.exec(text)
39  return m ? { start: m.index, end: m.index + m[0].length } : null
40}
41
42/** What `$.prompt.fill` takes to put a text in the prompt, its `[blank]` marked to replace. */
43export const fillArgs = (text: string): PromptFillArgs => {
44  const blank = blankIn(text)
45  return blank ? { text, decorations: [{ ...blank, bold: true, underline: true }] } : { text }
46}
47
48/** The rows of a list of `total` a pane shows from `offset`, kept inside the list. */
49export const windowOf = (total: number, offset: number, rows: number): { start: number; end: number } => {
50  const size = Math.max(1, rows)
51  const start = Math.max(0, Math.min(offset, total - size))
52  return { start, end: Math.min(total, start + size) }
53}
54
55/**
56 * One of a command's arguments in a verb row. `fill` is the text the prompt waits with, for a
57 * verb that takes an argument or undoes something: a stray click then loses nothing. A verb with
58 * no `fill` runs, and its answer goes to the transcript a line at a time (`linesOf`).
59 */
60export type Verb = { verb: string; label?: string; fill?: string }
61
62/** A command's answer as the lines `$.ui.log` writes, one row each; blank lines dropped. */
63export const linesOf = (text: string | undefined) => (text ?? '').split('\n').filter(line => line.trim() !== '')
64
65/** `/command verb · verb · verb`, each verb a plain button; `lead` goes dim before the command. */
66export function verbRow(
67  ui: Pick<Elements[keyof Elements], 'Box' | 'Text' | 'Button'>,
68  command: string,
69  verbs: readonly Verb[],
70  onPress: (v: Verb) => void,
71  lead = '',
72) {
73  const { Box, Text, Button } = ui
74  return (
75    <Box flexDirection="row" flexWrap="wrap">
76      {lead !== '' && <Text dimColor>{`${lead} · `}</Text>}
77      <Text dimColor>{`/${command} `}</Text>
78      {verbs.map((v, i) => (
79        <Box key={`verbrow:${v.verb}`} flexDirection="row">
80          {i > 0 && <Text dimColor> · </Text>}
81          <Button key={`verb:${v.verb}`} plain dimColor label={v.label ?? v.verb} onPress={() => onPress(v)} />
82        </Box>
83      ))}
84    </Box>
85  )
86}
87
88/** 950, 1.2k, 46k, 1.2M. */
89export const tokens = (n: number) => {
90  if (n < 1000) return String(n)
91  if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
92  return `${(n / 1_000_000).toFixed(1)}M`
93}
94
95/** 42s, 6m 05s, 1h 02m. */
96export const elapsed = (ms: number) => {
97  const s = Math.max(0, Math.round(ms / 1000))
98  if (s < 60) return `${s}s`
99  const m = Math.floor(s / 60)
100  if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
101  return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
102}
103
104/** The text cut to `n` characters, an ellipsis last when it was longer. */
105export const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
106
107/** claude-haiku-4-5-20251001 -> haiku 4.5 */
108export const shortModel = (m?: string) => {
109  if (!m) return ''
110  const hit = /(opus|sonnet|haiku|fable)[-\s]?(\d+(?:[-.]\d+)?)?/i.exec(m)
111  if (!hit) return m.length > 14 ? `${m.slice(0, 13)}…` : m
112  return `${hit[1]!.toLowerCase()}${hit[2] ? ` ${hit[2].replace('-', '.')}` : ''}`
113}
114
hooks/words.ts 117 lines
1// What limits-meter says, in Portuguese and English. The language comes from the `language`
2// option, else the system's LANG (ui.tsx's langOf).
3
4import type { Lang } from './ui'
5
6export const COMMAND = 'limits'
7
8type Words = {
9  description: string
10  pane: string
11  hint: string
12  /** Short names of the plan windows, for the band and the pane's first column. */
13  labels: Record<string, string>
14  now: string
15  resetsIn: (reset: string) => string
16  /** Toasts, once per threshold. */
17  windowAlert: (label: string, percent: string, reset: string) => string
18  contextAlert: (percent: string) => string
19  /** The one-line answer where no pane can open. */
20  context: string
21  noReadings: string
22  planWindows: string
23  noWindows: string
24  contextHeading: string
25  ofWindow: (used: string, window: string) => string
26  turns: string
27  noTurns: string
28  /** The turns table's column heads: input, output, cache, time, model. */
29  columns: readonly [string, string, string, string, string]
30  /** Under a plan window: when it reaches 100% at the current pace, before it resets. */
31  pace: (label: string, span: string) => string
32  /** After the context sparkline. */
33  trend: (turns: number) => string
34  /** Band and pane buttons. */
35  compact: string
36  /** What `compact` puts in the prompt, its blank marked to replace. */
37  compactFill: string
38  details: string
39  hide: string
40  close: string
41  hidden: string
42  shown: string
43  help: string
44  failedToRun: (what: string) => string
45}
46
47export const WORDS: Record<Lang, Words> = {
48  'pt-BR': {
49    description: 'Limites do plano e contexto: /limits abre o painel; hide, show, help',
50    pane: 'Limites e contexto',
51    hint: 'Números que o engine informa a cada turno. Tokens e percentual, nunca dinheiro.',
52    labels: { five_hour: '5h', seven_day: 'sem', spend_limit: 'gasto' },
53    now: 'agora',
54    resetsIn: reset => `reinicia em ${reset}`,
55    windowAlert: (label, percent, reset) => `Janela ${label} em ${percent}${reset ? `, reinicia em ${reset}` : ''}`,
56    contextAlert: percent => `Contexto ${percent} cheio: bom momento para /compact com um foco`,
57    context: 'contexto',
58    noReadings: 'sem leituras de limite do plano (chave de API, ou antes da primeira resposta)',
59    planWindows: 'Janelas do plano',
60    noWindows: 'Sem leituras: uma chave de API não tem janelas do plano, e uma assinatura as mostra depois da primeira resposta.',
61    contextHeading: 'Contexto',
62    ofWindow: (used, window) => `${used} de ${window}`,
63    turns: 'Turnos (thread principal)',
64    noTurns: 'Nenhum turno terminado ainda.',
65    columns: ['entrada', 'saída', 'cache', 'tempo', 'modelo'],
66    pace: (label, span) => `no ritmo atual, ${label} chega a 100% em ~${span}, antes de reiniciar`,
67    trend: n => `contexto ao fim ${n === 1 ? 'do último turno' : `dos últimos ${n} turnos`}`,
68    compact: 'compactar',
69    compactFill: '/compact [foco]',
70    details: 'detalhes',
71    hide: 'ocultar',
72    close: 'Fechar',
73    hidden: 'Banda oculta. /limits show traz de volta.',
74    shown: 'Banda visível.',
75    help: [
76      '/limits          abre o painel: janelas do plano, contexto em tokens, os últimos 20 turnos',
77      '/limits hide     oculta a banda acima do prompt, também nas próximas sessões',
78      '/limits show     traz a banda de volta',
79    ].join('\n'),
80    failedToRun: what => `Não deu para rodar: ${what}`,
81  },
82  en: {
83    description: 'Plan limits and context: /limits opens the pane; hide, show, help',
84    pane: 'Limits & context',
85    hint: 'Figures the engine reports after each turn. Tokens and percent, never money.',
86    labels: { five_hour: '5h', seven_day: 'wk', spend_limit: 'spend' },
87    now: 'now',
88    resetsIn: reset => `resets in ${reset}`,
89    windowAlert: (label, percent, reset) => `${label} window at ${percent}${reset ? `, resets in ${reset}` : ''}`,
90    contextAlert: percent => `Context ${percent} full: a good moment for /compact with a focus`,
91    context: 'context',
92    noReadings: 'no plan-limit readings (API key, or before the first response)',
93    planWindows: 'Plan windows',
94    noWindows: 'No readings: an API key has no plan windows, and a subscription shows them after the first response.',
95    contextHeading: 'Context',
96    ofWindow: (used, window) => `${used} of ${window}`,
97    turns: 'Turns (main thread)',
98    noTurns: 'No finished turns yet.',
99    columns: ['in', 'out', 'cache', 'time', 'model'],
100    pace: (label, span) => `at this pace, ${label} reaches 100% in ~${span}, before it resets`,
101    trend: n => `context at the end of the last ${n} turn${n === 1 ? '' : 's'}`,
102    compact: 'compact',
103    compactFill: '/compact [focus]',
104    details: 'details',
105    hide: 'hide',
106    close: 'Close',
107    hidden: 'Band hidden. /limits show brings it back.',
108    shown: 'Band shown.',
109    help: [
110      '/limits          open the pane: plan windows, context in tokens, the last 20 turns',
111      '/limits hide     hide the band above the prompt, in later sessions too',
112      '/limits show     bring the band back',
113    ].join('\n'),
114    failedToRun: what => `Could not run: ${what}`,
115  },
116}
117
types/index.d.ts 49 lines
1/** One rate-limit window as the meter draws it. */
2export type Limit = {
3  /** `five_hour`, `seven_day`, or a gateway's `spend_limit`. */
4  kind: string
5  /** 0 to 100, past 100 on an exceeded spend limit. */
6  percent: number
7  /** When the window resets, in epoch milliseconds; null when unknown. */
8  resetsAt: number | null
9}
10
11/** The live figures the band draws, pushed by `session.measure`. */
12export type Snapshot = {
13  limits: Limit[]
14  /** Context window fill, 0 to 100; null before the first response. */
15  contextPercent: number | null
16  /** Input tokens the last response was answered over. */
17  contextTokens: number | null
18  /** The model's context window in tokens. */
19  contextWindow: number
20}
21
22/** One finished main-thread turn. */
23export type Turn = {
24  /** Input tokens: uncached, cache-read and cache-written together. */
25  input: number
26  output: number
27  /** Share of input the prompt cache served, 0 to 1; null when no input. */
28  cacheHit: number | null
29  model: string
30  durationMs: number
31  /** Context fill when the turn ended, 0 to 100; null when unknown. */
32  contextPercent?: number | null
33}
34
35/** Readings of each plan window since it last reset, for the pace: [epoch ms, percent]. */
36export type Samples = Record<string, { resetsAt: number; points: [number, number][] }>
37
38declare module 'claude-code' {
39  interface PluginState {
40    'limits-meter': {
41      snapshot: Snapshot
42      turns: Turn[]
43      isHidden: boolean
44      fired: string[]
45      samples: Samples
46    }
47  }
48}
49