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

Plan limits and context above the prompt, in tokens and percent. Never money.
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.at this pace, 5h reaches 100% in ~1h20, before it resets./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
Set them in /config, under the plugin.
| Option | Values | Default |
|---|---|---|
language | auto, pt-BR, en. auto follows the system's LANG: Portuguese for pt_*, English otherwise | auto |
cells | The band's cells, comma-separated, from 5h, wk, spend, ctx, cache. all shows them all | all |
density | auto 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 bar | auto |
warnAt | Percent from which a window or the context turns to the warning color | 70 |
dangerAt | Percent from which it turns to the error color | 90 |
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.
/plugin install limits-meter --marketplace ice-lfernandes/claude-code-mods
Or for one session: claude --plugin-dir ./limits-meter
| Mod | Network | Runs processes | Files | Calls a model | Sends data anywhere |
|---|---|---|---|---|---|
| limits-meter | No | No | No | No | No |
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.
hooks/register.tsx 325 lines1// 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}
325hooks/meter.ts 159 lines1import 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}
159hooks/ui.tsx 114 lines1// 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}
114hooks/words.ts 117 lines1// 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}
117types/index.d.ts 49 lines1/** 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