Context fill, session cost and pace on one line, with a warning before the red line.

<img src="../../brand/pixel-icons/svg/redline.svg" width="64" height="64" alt="">
Context fill, session cost and pace on one line, with a warning before the red line.
ctx 82% ████████░░ RED · ≈$4.12 · 3.9k tok/min · full in ~7m · 5h 46% · 7d 13%
/plugin install redline --marketplace modmyclaude/claude-code-mods
AMBER from 60%, RED from 80% (the status line has no colour, so the word carries it);≈: Claude Code's own estimate (what /cost shows) where it keeps one, otherwise redline's price table;full in ~25m);5h 46%, 7d 13%), with out in ~40m added when a window will run dry before it resets. A spent window says when it comes back (5h OUT, resets in ~1h 20m), and 5h reset once that time has passed./compact or a fresh session (/clear), once per crossing. It re-arms only after the context drops under 75% (a compaction, a fresh session), so it never nags on every redraw./redline opens the detail view in a pane, laid out to the collection's UI standard: a hero line with the context fill in its colour and the bar beneath; label and value rows (pace, throughput, tokens, cost, session, requests) with the figures at the right edge and the breakdowns dim beneath them; each plan window as a bar with its reset time and run-out; the five busiest turns; and one footnote for the caveats. The cost headline is Claude Code's own estimate (what /cost shows) when it keeps one, else the price table's, both marked ≈. Rows that would read zero before the first request are not drawn. Buttons: show or hide the band, close. Where no surface places the pane, the same view goes to the transcript as dim rows of its own, which the model does not read./redline spend opens the same pane for its Where the money went section: this session's cost split by the kind of work each model request carried, one row per kind (prompt, reading, editing, shell, search, planning, subagents, answering, an MCP server's tools by its name) with a share bar, the request count and the cache share, then the same by model. The rule, stated under the rows: a request is charged to the kinds of the tool results it carried (the previous reply's tool calls), split evenly when it carried several; a turn's first request is the prompt; a subagent's requests are subagents. Every figure is a list-price estimate, marked ≈; nothing new is stored, and the band does not change./redline band toggles a band above the prompt with the same gauge, in colour (green, amber, red), for people who hide the status line. Its Hide button turns it off. The choice is remembered across sessions. It shares the band: whatever other mods draw there stays, drawn first, with the gauge on the line after it. It steps aside while a survey holds that space.None. The thresholds (60%, 80%, re-arm under 75%) and the 10-minute window are fixed.
$.session.usage(), the session.measure event), each model request's token counts (turn.step), each turn's prompt text (turn.start, shown only in the busiest-turns list), and compactions, /clear and an in-session /resume (each starts the gauge over). Model ids, plan window names and prompt text are cleaned of control characters before anything is drawn.context.tokens and context.percent, the status line's used percentage) whenever it has measured the latest request. Between its measurements the gauge counts the main conversation's last request itself, input side (uncached + cache read + cache write), the same way, so it moves on every request. Subagent requests count toward tokens and cost, never toward the session's context.cost.usd, what /cost shows), labelled as its estimate and marked ≈: it is summed at list prices over the session's responses, not a bill (on a subscription nobody pays it). Where Claude Code keeps no ledger, redline's built-in price table prices the requests it counted, and a model the table does not know shows tokens only, never a guessed price.hooks/prices.ts): list prices per model family as of 2026-10-08, used for the per-category and per-turn estimates. It leaves out long-context premiums, fast mode, 1-hour cache writes, batch and regional pricing, so every figure from it is marked ≈.reset while you wait). The session time in the detail view refreshes with readings.This mod was built from its prompt.
More mods: the full catalogue.
hooks/register.tsx 397 lines1// redline: one glanceable gauge for how close the session is to its limits.
2//
3// Where the figures come from (all read from Claude Code, nothing guessed):
4// - each model request (`turn.step`): its four token counts and model, so the
5// gauge moves on every request, not on a timer;
6// - `$.session.usage()` and the `session.measure` event: the context fill
7// (`context.tokens` and `context.percent`, the status line's own figures),
8// the model's window (`context.window`, so redline keeps no window table),
9// the session cost (`cost.usd`, what /cost shows), and the plan's 5-hour
10// and weekly windows (`rateLimits`: kind, percentUsed, resetsAt);
11// - `turn.start` for each turn's prompt (the busiest-turns list),
12// `session.compact` and a /clear (`session.end`) to start the gauge over.
13
14import { atom, read, update } from 'claude-code'
15import type { EngineInterface, Register, SessionMeasureInput, ThemeKey, Timer } from 'claude-code'
16
17import { boltSprite } from './bolt-draw'
18import { glyphRow } from './cells'
19import { boltPose, detailChunks, detailLines } from './detail'
20import type { DetailLine, Tone } from './detail'
21import { bar, clean, EMPTY_GAUGE, gaugeLine, gaugeParts, levelWord, percentOf, RED_AT, reduce, SPAN_MS, TOAST_TEXT } from './gauge'
22import { kindsCarried } from './spend'
23import type { Level, Reading } from './gauge'
24import type { RedlineGauge } from '../types'
25
26const PANE = 'redline'
27/** Narrower than this, the detail pane keeps every column for the figures and Bolt steps out. */
28const BOLT_MIN_COLUMNS = 40
29/** The label column of a plan window's bar row. */
30const LIMIT_LABEL_CELLS = 12
31const BAND_STORE_KEY = 'isBandOn'
32const gauge = atom({ plugin: 'redline', key: 'gauge' } as const, EMPTY_GAUGE)
33const isBandOn = atom({ plugin: 'redline', key: 'isBandOn' } as const, false)
34/** The tools the main loop's last response called, per turn: what its next request carries. Cleared at each turn's start. */
35const carried = new Map<string, string[]>()
36
37const LEVEL_COLOR: Record<Level, ThemeKey> = { unknown: 'inactive', normal: 'success', amber: 'warning', red: 'error' }
38
39/** What `$.session.usage()` and `session.measure` share, as a reading. */
40const fromUsage = (u: Pick<SessionMeasureInput, 'context' | 'rateLimits' | 'cost'>) => ({
41 window: u.context.window,
42 ledgerUsd: u.cost?.usd,
43 limits: u.rateLimits,
44})
45
46/**
47 * Folds one reading into the gauge (one write per dispatch), then repaints
48 * the status line and raises the 80% toast when this write crossed it.
49 */
50/** One repaint after the 10-minute window passes with no reading (see apply). */
51let idleRepaint: Timer | undefined
52/** One repaint just after the earliest plan window resets (see apply). */
53let resetRepaint: Timer | undefined
54
55async function apply($: EngineInterface, reading: Reading): Promise<void> {
56 let fire = false
57 // `update` may run the change again on a version miss; the last run is the
58 // one written, so `fire` ends up describing the value that was stored.
59 const g = await update($, gauge, current => {
60 const next = reduce(current, reading)
61 fire = next.fire
62 return next.gauge
63 })
64 $.ui.status(gaugeLine(g, reading.now))
65 if (fire) $.ui.toast(TOAST_TEXT(percentOf(g) ?? RED_AT), { timeoutMs: 10_000 })
66 // The status line is a fixed string and the band and pane cache their
67 // drawing: two moments must repaint them with no reading at all. Once the
68 // session sits idle past the 10-minute window (pace and rate read idle),
69 // and just after the earliest plan window resets (a spent window means no
70 // replies, so nothing else would). One shot each, re-armed by every reading:
71 // nothing ticks while nothing happens.
72 idleRepaint?.cancel()
73 idleRepaint = $.clock.after(SPAN_MS + 1000, () => repaint($))
74 armReset($, g, reading.now)
75}
76
77/** Paints every view again with no reading, then waits for the next reset. */
78function repaint($: EngineInterface): void {
79 void safely($, 'repaint', async () => {
80 const g = await read($, gauge)
81 const now = await $.clock.now()
82 $.ui.status(gaugeLine(g, now))
83 $.ui.invalidate('ui.render')
84 // Each reset re-arms for the next one: the weekly after the 5-hour.
85 armReset($, g, now)
86 })
87}
88
89/** One repaint just after the earliest plan window still to reset. */
90function armReset($: EngineInterface, g: RedlineGauge, now: number): void {
91 resetRepaint?.cancel()
92 const nextReset = Math.min(...g.limits.map(l => Date.parse(l.resetsAt ?? '')).filter(t => t > now))
93 // Held to what a timer holds (2147483647 ms, ~24.8 days): a far reset (a
94 // monthly spend limit) then re-arms once on the way, never in a loop.
95 resetRepaint = Number.isFinite(nextReset)
96 ? $.clock.after(Math.min(nextReset - now + 1000, 2_147_483_647), () => repaint($))
97 : undefined
98}
99
100/** Runs gauge work so a failure is logged and never stands in a turn's way. */
101async function safely($: EngineInterface, what: string, work: () => Promise<void>): Promise<void> {
102 try {
103 await work()
104 } catch (err) {
105 $.ui.log(`redline: ${what} failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
106 }
107}
108
109async function setBand($: EngineInterface, value: (was: boolean) => boolean): Promise<boolean> {
110 const now = await update($, isBandOn, value)
111 // Remembered across sessions: the band is for people who keep it on.
112 await $.store.set(BAND_STORE_KEY, now).catch(() => undefined)
113 return now
114}
115
116export const register: Register = on => {
117 on('session.start', async ($, e, next) => {
118 await $.command.register({
119 name: 'redline',
120 description: 'Context, tokens, cost, pace and plan limits in detail; "/redline band" toggles the gauge above the prompt',
121 argumentHint: '[band]',
122 // Checking the gauge mid-turn is the point: run at once, never queued
123 // behind the turn. The hook reads only the gauge, never turn state.
124 immediate: true,
125 })
126 await safely($, 'start', async () => {
127 const stored = await $.store.get(BAND_STORE_KEY)
128 await update($, isBandOn, () => stored === true)
129 const usage = await $.session.usage()
130 await apply($, {
131 now: await $.clock.now(),
132 sessionStartedAt: usage.startedAt,
133 contextTokens: usage.context.tokens ?? null,
134 contextPercent: usage.context.percent,
135 ...fromUsage(usage),
136 })
137 })
138 return next(e)
139 })
140
141 on('turn.start', async ($, e, next) => {
142 const started = await next(e)
143 carried.clear() // an aborted turn leaves nothing behind
144 await safely($, 'turn start', async () => {
145 await apply($, { now: await $.clock.now(), turnStart: { turnId: e.turnId, text: e.text } })
146 })
147 return started
148 })
149
150 // Every model request, main loop and subagents: the gauge's heartbeat.
151 on('turn.step', async function* ($, e, next) {
152 const response = yield* next(e)
153 const usage = response.usage
154 // What this request carried: the results of the previous response's tool calls in this loop.
155 const loop = `${e.agentId ?? 'main'}:${e.turnId}`
156 const kinds = kindsCarried(e.index, e.agentId, carried.get(loop) ?? [])
157 // Kept for the main loop only: a subagent's requests are one kind whatever they carry.
158 const tools = e.agentId === undefined ? (response.toolUses ?? []).map(use => use.name) : []
159 if (tools.length > 0) carried.set(loop, tools)
160 else carried.delete(loop)
161 if (usage) {
162 await safely($, 'request', async () => {
163 const totals = await $.session.usage().catch(() => undefined)
164 await apply($, {
165 now: await $.clock.now(),
166 ...(totals ? { sessionStartedAt: totals.startedAt, ...fromUsage(totals) } : {}),
167 // Claude Code's percentage, taken only once it counts this request.
168 ...(totals && e.agentId === undefined
169 ? { contextTokens: totals.context.tokens, contextPercent: totals.context.percent }
170 : {}),
171 request: { turnId: e.turnId, agentId: e.agentId, usage, kinds },
172 })
173 })
174 }
175 return response
176 })
177
178 // After each turn, and whenever a plan window moves a whole point.
179 on('session.measure', async ($, e, next) => {
180 await safely($, 'measure', async () => {
181 await apply($, {
182 now: await $.clock.now(),
183 // No count (just compacted, until the next reply): keep what we hold.
184 ...(e.context.tokens === undefined ? {} : { contextTokens: e.context.tokens, contextPercent: e.context.percent }),
185 ...fromUsage(e),
186 })
187 })
188 return next(e)
189 })
190
191 // A compaction shrinks the context at once: show it (and re-arm the toast)
192 // without waiting for the next reply. Observes only, so its catch replays.
193 on('session.compact', async ($, e, next) => {
194 const compacted = await next(e)
195 if (e.agentId === undefined && e.trigger !== 'precompute' && compacted.messages) {
196 await safely($, 'compact', async () => {
197 await apply($, { now: await $.clock.now(), contextTokens: compacted.tokensAfter ?? null })
198 })
199 }
200 return compacted
201 }).catch(($, e, next) => next(e))
202
203 // A /clear or an in-session /resume swaps the conversation: start the gauge
204 // over with it (no session.start follows either). The window and the plan
205 // windows belong to the model and account, so they stay.
206 on('session.end', async ($, e, next) => {
207 if (e.reason === 'clear' || e.reason === 'resume') {
208 await safely($, 'clear', async () => {
209 const now = await $.clock.now()
210 const g = await update($, gauge, was => ({
211 ...EMPTY_GAUGE,
212 window: was.window,
213 limits: was.limits,
214 limitHistory: was.limitHistory,
215 sessionStartedAt: now,
216 trackingSince: now,
217 }))
218 $.ui.status(gaugeLine(g, now))
219 })
220 }
221 return next(e)
222 })
223
224 // A command's text is read by the model, so redline answers {} and speaks
225 // through toasts and its pane; where nothing can draw the pane, the view goes
226 // to the transcript as dim rows of its own, which the model never reads.
227 on('command.run', { command: 'redline' }, async ($, e) => {
228 const arg = e.args.trim().toLowerCase()
229 if (arg === 'band') {
230 const isOn = await setBand($, was => !was)
231 $.ui.toast(isOn ? 'Band is on, above the prompt. /redline band hides it.' : 'Band is off. /redline band shows it.')
232 return {}
233 }
234 if (arg !== '' && arg !== 'spend') {
235 $.ui.toast('/redline opens the detail view (with where the money went); /redline band toggles the gauge above the prompt.')
236 return {}
237 }
238 const opened = await $.ui.open({ id: PANE, title: 'Redline', closeOnEscape: true })
239 if (opened.isPlaced) return {}
240 // Nothing places the pane here: the same view as transcript rows, never as command text.
241 for (const chunk of detailChunks(await read($, gauge), await $.clock.now())) $.ui.log(chunk)
242 return {}
243 }).catch($ => {
244 $.ui.toast('Could not open its detail view (run with --debug for the reason).')
245 return {}
246 })
247
248 // The band above the prompt: the status line's gauge, in colour, drawn with
249 // whatever other mods draw there (never in their place), after theirs, so a
250 // long gauge never pushes another mod's buttons out of view.
251 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
252 if (e.props.hasSurvey || !(await read($, isBandOn))) return next(e)
253 const g = await read($, gauge)
254 const p = gaugeParts(g, await $.clock.now())
255 const { Box, Text, Button } = $.ui.resolve(e)
256 const cells = e.props.bodyColumns >= 80 ? 10 : 5
257 const word = levelWord(p.level)
258 // The plan windows' kinds are the engine's strings: cleaned before drawing.
259 const rest = clean([p.cost, p.rate, p.pace, ...p.limits].filter(Boolean).join(' · '))
260 // What the other mods (and the engine) draw in the band comes first.
261 const below = await next(e).catch(() => null)
262 return (
263 <Box flexDirection="column">
264 {below}
265 <Box flexDirection="row" gap={1}>
266 {e.surface === 'desktop' ? (
267 // The desktop draws a Text with block characters in the code font, words and all
268 //: the bar sits in its own one-cell glyphs between the words.
269 <Box key="gauge" flexDirection="row" flexShrink={1}>
270 <Text>
271 <Text bold>redline</Text> {p.context}{' '}
272 </Text>
273 {p.percent === undefined ? null : glyphRow({ Box, Text }, e.surface, bar(p.percent, cells), { color: LEVEL_COLOR[p.level] })}
274 <Text wrap="truncate-end">
275 {word ? <Text color={LEVEL_COLOR[p.level]} bold>{` ${word}`}</Text> : null}
276 <Text dimColor>{` · ${rest}`}</Text>
277 </Text>
278 </Box>
279 ) : (
280 <Box key="gauge" flexShrink={1}>
281 <Text wrap="truncate-end">
282 <Text bold>redline</Text> {p.context}{' '}
283 <Text color={LEVEL_COLOR[p.level]}>{p.percent === undefined ? '' : bar(p.percent, cells)}</Text>
284 {word ? <Text color={LEVEL_COLOR[p.level]} bold>{` ${word}`}</Text> : null}
285 <Text dimColor>{` · ${rest}`}</Text>
286 </Text>
287 </Box>
288 )}
289 <Button key="hide" label="Hide" dimColor onPress={() => setBand($, () => false)} />
290 </Box>
291 </Box>
292 )
293 })
294
295 // The /redline detail view.
296 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
297 const g = await read($, gauge)
298 const band = await read($, isBandOn)
299 const now = await $.clock.now()
300 const elements = $.ui.resolve(e)
301 const { Box, Text, Button } = elements
302 const lines = detailLines(g, now, 10)
303 const paint = (tone: Tone): { bold?: boolean; color?: ThemeKey } =>
304 tone === 'title' ? { bold: true } : tone === 'dim' ? { color: 'subtle' } : tone === 'plain' ? {} : { color: LEVEL_COLOR[tone] }
305 // Each line by its kind, after brand/UI-STANDARD.md: the hero in colour; label and value rows,
306 // the value pushed to the right edge; a bar drawn apart from its words, which wrap; sub-lines
307 // and items indented and wrapping; one footnote. The engine draws the pane's title bar, so
308 // the title line is left to the text view.
309 const draw = (line: DetailLine) => {
310 switch (line.kind) {
311 case 'title':
312 return line.key === 'title' ? null : (
313 <Box key={line.key} marginTop={1}>
314 <Text bold>{line.value ?? ''}</Text>
315 </Box>
316 )
317 case 'hero':
318 return (
319 <Box key={line.key}>
320 <Text bold {...paint(line.tone)}>
321 {line.value ?? ''}
322 </Text>
323 </Box>
324 )
325 case 'bar':
326 return (
327 <Box key={line.key} flexDirection="row" gap={1}>
328 {line.label ? (
329 <Box key="label" width={LIMIT_LABEL_CELLS} flexShrink={0}>
330 <Text color="subtle">{line.label}</Text>
331 </Box>
332 ) : null}
333 {line.bar ? glyphRow(elements, e.surface, line.bar, paint(line.tone)) : null}
334 <Box key="words" flexShrink={1}>
335 <Text wrap="wrap" {...paint(line.tone)}>
336 {line.value ?? ''}
337 </Text>
338 </Box>
339 </Box>
340 )
341 case 'row':
342 return (
343 <Box key={line.key} flexDirection="row" gap={1}>
344 <Box key="label" flexGrow={1} flexShrink={1}>
345 <Text color="subtle">{line.label ?? ''}</Text>
346 </Box>
347 <Box key="value" flexShrink={0} justifyContent="flex-end">
348 <Text {...paint(line.tone)}>{line.value ?? ''}</Text>
349 </Box>
350 </Box>
351 )
352 case 'note':
353 return (
354 <Box key={line.key} marginTop={1}>
355 <Text wrap="wrap" color="subtle">
356 {line.value ?? ''}
357 </Text>
358 </Box>
359 )
360 default:
361 return (
362 <Box key={line.key} paddingLeft={2} flexShrink={1}>
363 <Text wrap="wrap" {...paint(line.tone)}>
364 {line.value ?? ''}
365 </Text>
366 </Box>
367 )
368 }
369 }
370 // Bolt keeps watch beside the hero: a static sprite, crashed once the context is full
371 // or a plan window is spent; only where the pane has room.
372 const at = lines.findIndex(line => line.key === 'context')
373 const end = lines[at + 1]?.key === 'context-used' ? at + 2 : at + 1
374 const roomy = e.props.bodyColumns >= BOLT_MIN_COLUMNS
375 return (
376 <Box flexDirection="column">
377 {roomy
378 ? [
379 ...lines.slice(0, at).map(draw),
380 <Box key="context-row" flexDirection="row" gap={2}>
381 <Box flexDirection="column" flexShrink={1}>
382 {lines.slice(at, end).map(draw)}
383 </Box>
384 {boltSprite(elements, e.surface, { size: 'tiny', pose: boltPose(g, now) })}
385 </Box>,
386 ...lines.slice(end).map(draw),
387 ]
388 : lines.map(draw)}
389 <Box flexDirection="row" gap={1} marginTop={1}>
390 <Button key="band" label={band ? 'Hide band' : 'Show band'} onPress={() => setBand($, was => !was)} />
391 <Button key="close" label="Close" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
392 </Box>
393 </Box>
394 )
395 })
396}
397hooks/bolt-draw.tsx 86 lines1// Draws Bolt, the modmyclaude mascot (brand/bolt-sprites), on the surface a hook draws for.
2// Same file in every mod that shows Bolt: mods install on their own, so they cannot share code.
3// Terminal: one Raster, a cell grid. Desktop: one code-font cell per character, since its Text is
4// proportional and a row of half-blocks would drift. Elsewhere: coloured Text runs.
5// Bolt is static here: no timer, no redraw of its own. Never draw it over a safety hold or in a toast.
6
7import type { BoxProps, ElementConstructor, RasterProps, RenderElement, TextProps } from 'claude-code'
8
9import { boltCells, boltRuns } from './bolt'
10import type { BoltCell, BoltSmallPose, BoltTinyPose } from './bolt'
11
12type Elements = {
13 Box: ElementConstructor<BoxProps>
14 Text: ElementConstructor<TextProps>
15 Raster?: ElementConstructor<RasterProps>
16}
17
18export type BoltPick = { size: 'small'; pose: BoltSmallPose } | { size: 'tiny'; pose: BoltTinyPose }
19
20/** Columns a pose takes: small is 10, tiny is 3. */
21export const BOLT_COLUMNS = { small: 10, tiny: 3 } as const
22
23/** The cells of a pose: half-blocks, two pixel rows per cell. */
24export function boltOf(pick: BoltPick): BoltCell[][] {
25 return pick.size === 'small' ? boltCells('small', pick.pose) : boltCells('tiny', pick.pose)
26}
27
28/** Bolt as one element under `key`, drawn the way `surface` lays out cells. */
29export function boltSprite(elements: Elements, surface: string, pick: BoltPick, key = 'bolt'): RenderElement {
30 const { Box, Text, Raster } = elements
31 const cells = boltOf(pick)
32 const columns = cells[0]?.length ?? 0
33 if (surface === 'terminal' && Raster !== undefined) {
34 return <Raster key={key} columns={columns} rows={cells.length} cells={rasterCells(cells)} />
35 }
36 const paint = (cell: BoltCell): TextProps => ({
37 ...(cell.fg !== undefined ? { color: cell.fg } : {}),
38 ...(cell.bg !== undefined ? { backgroundColor: cell.bg } : {}),
39 })
40 if (surface === 'desktop') {
41 return (
42 <Box key={key} flexDirection="column" flexShrink={0}>
43 {cells.map(line => (
44 <Box flexDirection="row" flexWrap="nowrap">
45 {line.map(cell =>
46 cell.fg === undefined && cell.bg === undefined ? <Box width={1} /> : <Box width={1}><Text {...paint(cell)}>{cell.ch}</Text></Box>,
47 )}
48 </Box>
49 ))}
50 </Box>
51 )
52 }
53 return (
54 <Box key={key} flexDirection="column" flexShrink={0}>
55 {cells.map(line => (
56 <Text>
57 {boltRuns(line).map(run => {
58 const text = run.ch.replace(/ /g, ' ')
59 return run.fg === undefined && run.bg === undefined ? text : <Text {...paint(run)}>{text}</Text>
60 })}
61 </Text>
62 ))}
63 </Box>
64 )
65}
66
67/** RasterProps.cells: standard base64 of little-endian u32 [codePoint, fg, bg] per cell, row-major. */
68export function rasterCells(cells: readonly (readonly BoltCell[])[]): string {
69 const DEFAULT = 0x01000000
70 const count = cells.reduce((sum, line) => sum + line.length, 0)
71 const view = new DataView(new ArrayBuffer(count * 12))
72 let at = 0
73 for (const line of cells) {
74 for (const cell of line) {
75 view.setUint32(at, cell.ch.codePointAt(0) ?? 32, true)
76 view.setUint32(at + 4, cell.fg === undefined ? DEFAULT : parseInt(cell.fg.slice(1), 16), true)
77 view.setUint32(at + 8, cell.bg === undefined ? DEFAULT : parseInt(cell.bg.slice(1), 16), true)
78 at += 12
79 }
80 }
81 let binary = ''
82 const bytes = new Uint8Array(view.buffer)
83 for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i] ?? 0)
84 return btoa(binary)
85}
86hooks/cells.tsx 45 lines1// Glyph art (the gauge's bar) on the desktop app. Its Text is proportional, and a Text
2// that holds block characters is drawn in the code font, words and all, so a row with
3// a bar changed font and overflowed. There the bar is drawn one
4// code-font cell per character, apart from the words.
5
6import type { BoxProps, ElementConstructor, RenderElement, TextProps } from 'claude-code'
7
8type Elements = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
9
10const GLYPHS = /[▀-▟]+/gu
11
12/** One row of glyph art: a plain Text on the terminal, a row of one-cell Boxes on the desktop. */
13export function glyphRow(elements: Elements, surface: string, text: string, paint: TextProps = {}): RenderElement {
14 const { Box, Text } = elements
15 if (surface !== 'desktop') return <Text {...paint}>{text}</Text>
16 const cells: RenderElement[] = []
17 for (const run of text.match(/ +|[^ ]/gu) ?? []) {
18 cells.push(run.startsWith(' ') ? <Box width={run.length} /> : <Box width={1}><Text {...paint}>{run}</Text></Box>)
19 }
20 return (
21 <Box flexDirection="row" flexWrap="nowrap" flexShrink={0}>
22 {cells}
23 </Box>
24 )
25}
26
27/** A line of words with bars in it: one Text where the surface keeps a cell grid, else words and glyph cells side by side. */
28export function mixedRow(elements: Elements, surface: string, text: string, paint: TextProps = {}): RenderElement {
29 const { Box, Text } = elements
30 if (surface !== 'desktop' || !text.match(GLYPHS)) return <Text {...paint}>{text}</Text>
31 const parts: RenderElement[] = []
32 let at = 0
33 for (const m of text.matchAll(GLYPHS)) {
34 if (m.index > at) parts.push(<Text {...paint}>{text.slice(at, m.index)}</Text>)
35 parts.push(glyphRow(elements, surface, m[0], paint))
36 at = m.index + m[0].length
37 }
38 if (at < text.length) parts.push(<Text wrap="truncate-end" {...paint}>{text.slice(at)}</Text>)
39 return (
40 <Box flexDirection="row" flexWrap="nowrap">
41 {parts}
42 </Box>
43 )
44}
45hooks/detail.ts 227 lines1// The /redline detail view as structured lines, each keyed: a hero line, label
2// and value rows, bars, items and one footnote, after brand/UI-STANDARD.md.
3// The pane draws them by kind; where no surface places the pane, each line's
4// plain `text` prints as the command's answer, so the two can never disagree.
5
6import type { RedlineGauge, RedlineLimit, RedlineSpend } from '../types'
7import {
8 bar,
9 busiest,
10 clean,
11 contextGrowth,
12 EMPTY_GAUGE,
13 fmtDuration,
14 fmtPace,
15 fmtRunOut,
16 fmtTokens,
17 fmtUsd,
18 levelOf,
19 levelWord,
20 limitLabel,
21 paceEta,
22 percentOf,
23 runOut,
24 SPAN_MS,
25 tokensPerMinute,
26} from './gauge'
27import { priceRow, sumTokens } from './prices'
28import { modelLabel, spendLabel } from './spend'
29import type { Level } from './gauge'
30
31export type Tone = Level | 'dim' | 'title' | 'plain'
32
33/** How the pane draws a line: a section title, the hero, a label and value row, a dim line under a row, a labelled bar, a list item, the footnote. */
34export type Kind = 'title' | 'hero' | 'row' | 'sub' | 'bar' | 'item' | 'note'
35
36export type DetailLine = {
37 key: string
38 /** The line as plain text, for a surface that places no pane. */
39 text: string
40 tone: Tone
41 kind: Kind
42 /** Rows and bars: the label on the left. */
43 label?: string
44 /** The words: a row's value (drawn right-aligned), the text beside a bar, a title, an item, the note. */
45 value?: string
46 /** The hero's and a plan window's bar: glyphs, drawn apart from the words on the desktop. */
47 bar?: string
48}
49
50/** The label column of the plain-text view. */
51const LABEL = 13
52const padded = (label: string, text: string): string => `${label.padEnd(LABEL)}${text}`
53
54export function detailLines(kept: RedlineGauge, now: number, barCells = 10): DetailLine[] {
55 // A gauge kept in session state by an earlier version lacks the newer fields: they read as empty.
56 const g: RedlineGauge = { ...EMPTY_GAUGE, ...kept }
57 const lines: DetailLine[] = []
58 // Model ids, plan window kinds and prompts are outside text: cleaned here, at the one
59 // place every line passes, before the pane or the command draws it.
60 const add = (key: string, kind: Kind, tone: Tone, words: { label?: string; value?: string; bar?: string; text?: string }): void => {
61 const label = words.label === undefined ? undefined : clean(words.label)
62 const value = words.value === undefined ? undefined : clean(words.value)
63 const text =
64 words.text !== undefined
65 ? clean(words.text)
66 : kind === 'row' || kind === 'bar'
67 ? padded(label ?? '', `${words.bar ? `${words.bar} ` : ''}${value ?? ''}`)
68 : kind === 'sub' || kind === 'item'
69 ? padded('', value ?? '')
70 : (value ?? '')
71 lines.push({ key, text, tone, kind, ...(label === undefined ? {} : { label }), ...(value === undefined ? {} : { value }), ...(words.bar === undefined ? {} : { bar: words.bar }) })
72 }
73
74 // For the text view only: the pane's title bar already names the mod.
75 add('title', 'title', 'title', { value: 'Redline - context, cost and pace' })
76
77 // The hero: context fill against the window Claude Code reports for the model, its bar beneath.
78 const percent = percentOf(g)
79 const level = levelOf(percent)
80 if (percent === undefined || g.contextTokens === null || g.window === null) {
81 add('context', 'hero', 'dim', { value: 'No context reading yet', text: padded('Context', 'no reading yet') })
82 add('context-used', 'sub', 'dim', { value: 'it appears after the next reply' })
83 } else {
84 const word = levelWord(level)
85 const state = `${percent}%${word ? ` ${word}` : ''}`
86 add('context', 'hero', level, { value: `Context ${state}`, text: padded('Context', state) })
87 add('context-used', 'bar', level, { label: '', bar: bar(percent, barCells), value: `${fmtTokens(g.contextTokens)} of ${fmtTokens(g.window)} tokens${g.model ? ` · ${g.model}` : ''}` })
88 }
89
90 const growth = contextGrowth(g.samples, now)
91 const pace = fmtPace(paceEta(g.samples, g.window, now))
92 add('pace', 'row', pace && growth ? 'plain' : 'dim', { label: 'Pace', value: pace && growth ? pace : 'not enough data yet' })
93 if (pace && growth) add('pace-rate', 'sub', 'dim', { value: `the context grows +${fmtTokens(growth.perMs * 60_000)} tokens/min` })
94
95 const rate = tokensPerMinute(g.flow, now, g.trackingSince)
96 add('rate', 'row', 'plain', { label: 'Throughput', value: `${fmtTokens(rate)} new tokens/min` })
97
98 // Token counts and cost: every request redline has seen, main loop and subagents. Nothing is
99 // drawn for figures that are zero before the first request.
100 const t = g.totals
101 const requests = g.turns.reduce((n, turn) => n + turn.requests, 0)
102 if (requests > 0) {
103 add('tokens', 'row', 'plain', { label: 'Tokens', value: fmtTokens(sumTokens(t)) })
104 add('tokens-detail', 'sub', 'dim', { value: `input ${fmtTokens(t.input)} · output ${fmtTokens(t.output)} · cache read ${fmtTokens(t.cacheRead)} · cache write ${fmtTokens(t.cacheWrite)}` })
105 }
106 // One headline: Claude Code's own estimate when it keeps one, else the price table's; the rest beneath.
107 const family = g.model ? priceRow(g.model)?.label : undefined
108 const table = g.unpriced.length === 0 && requests > 0 ? fmtUsd(sumTokens(g.tableUsd)) : undefined
109 if (g.ledgerUsd !== null || requests > 0) {
110 const headline = g.ledgerUsd !== null ? fmtUsd(g.ledgerUsd) : (table ?? 'no price')
111 add('cost', 'row', headline === 'no price' ? 'dim' : 'plain', { label: 'Cost', value: headline })
112 const u = g.tableUsd
113 const parts = `input ${fmtUsd(u.input)} · output ${fmtUsd(u.output)} · cache read ${fmtUsd(u.cacheRead)} · cache write ${fmtUsd(u.cacheWrite)}`
114 const by = `redline's price table${family ? ` (${family})` : ''}`
115 if (g.unpriced.length > 0) add('cost-table', 'sub', 'dim', { value: `no price for ${g.unpriced.join(', ')} - tokens only` })
116 else if (table !== undefined && g.ledgerUsd !== null) {
117 add('cost-table', 'sub', 'dim', { value: `Claude Code's estimate for the whole session (as /cost shows it); ${table} for the requests redline counted, by ${by}: ${parts}` })
118 } else if (table !== undefined) add('cost-table', 'sub', 'dim', { value: `by ${by}: ${parts}` })
119 else add('cost-table', 'sub', 'dim', { value: "Claude Code's estimate for the whole session (as /cost shows it)" })
120 }
121
122 add('session', 'row', g.sessionStartedAt === null ? 'dim' : 'plain', { label: 'Session', value: g.sessionStartedAt === null ? 'start time unknown' : fmtDuration(now - g.sessionStartedAt) })
123 if (requests > 0) add('requests', 'row', 'plain', { label: 'Requests', value: `${requests}` })
124 else add('requests-none', 'sub', 'dim', { value: 'no model requests counted yet' })
125
126 // Plan windows, as the API's last response reported them: a bar each, its words beside it.
127 add('limits-title', 'title', 'title', { value: 'Plan limits' })
128 if (g.limits.length === 0) {
129 add('limits-none', 'sub', 'dim', { value: 'none reported for this session: Claude Code shares the 5-hour and weekly windows on a Claude plan, after the first reply' })
130 }
131 for (const l of g.limits) {
132 const pct = Math.round(l.percentUsed)
133 const reset = Date.parse(l.resetsAt ?? '')
134 // Past its reset the figure is stale: say so, and judge no run-out on it.
135 const isPast = !Number.isNaN(reset) && reset <= now
136 const resets = Number.isNaN(reset) ? '' : isPast ? ` · reset ${fmtDuration(now - reset)} ago, a new figure after the next reply` : ` · resets in ${fmtDuration(reset - now)}`
137 const r = isPast ? undefined : runOut(l, now, g.limitHistory[l.kind])
138 const basis = r !== undefined && r.kind !== 'out' ? (r.basis === 'recent' ? ' (recent rate)' : ' (average)') : ''
139 const out = r === undefined ? '' : r.kind === 'at' ? ` · ${fmtRunOut(r)}${basis}` : r.kind === 'out' ? ' · used up' : ` · resets first${basis}`
140 const tone: Tone = r?.kind === 'out' || r?.kind === 'at' ? 'red' : isPast ? 'dim' : levelOf(pct)
141 add(`limit-${l.kind}`, 'bar', tone, { label: limitLabel(l.kind), bar: bar(Math.min(100, pct), 10), value: `${pct}%${resets}${out}` })
142 }
143
144 add('busiest-title', 'title', 'title', { value: 'Busiest turns' })
145 const top = busiest(g)
146 if (top.length === 0) add('busiest-none', 'sub', 'dim', { value: 'none yet' })
147 top.forEach((turn, i) => {
148 const usd = turn.usd === null ? 'no price' : fmtUsd(turn.usd)
149 add(`busiest-${i + 1}`, 'item', 'plain', { value: `${i + 1}. ${fmtTokens(sumTokens(turn.tokens))} tok · ${turn.requests} req · ${usd} · ${turn.label}` })
150 })
151
152 // Where the money went: each kind of work the requests carried, by cost; then by model.
153 add('spend-title', 'title', 'title', { value: 'Where the money went' })
154 const kinds = Object.entries(g.spend)
155 if (kinds.length === 0) add('spend-none', 'sub', 'dim', { value: 'nothing yet' })
156 else {
157 const byCost = (a: RedlineSpend, b: RedlineSpend) => (b.usd ?? -1) - (a.usd ?? -1) || sumTokens(b.tokens) - sumTokens(a.tokens)
158 const priced = kinds.reduce((sum, [, e]) => sum + (e.usd ?? 0), 0)
159 const row = (e: RedlineSpend) => {
160 const share = e.usd !== null && priced > 0 ? Math.round((100 * e.usd) / priced) : 0
161 const inputSide = e.tokens.input + e.tokens.cacheRead + e.tokens.cacheWrite
162 const cache = inputSide > 0 ? `cache ${Math.round((100 * e.tokens.cacheRead) / inputSide)}%` : 'no input'
163 return { share, words: `${e.usd === null ? 'no price' : `${fmtUsd(e.usd)} · ${share}%`} · ${e.requests} req · ${cache}` }
164 }
165 for (const [kind, e] of kinds.sort(([, a], [, b]) => byCost(a, b))) {
166 const { label, prefix } = spendLabel(kind)
167 const { share, words } = row(e)
168 add(`spend-${kind}`, 'bar', 'plain', { label, bar: bar(e.usd === null ? 0 : share, 10), value: `${prefix}${words}` })
169 }
170 add('spend-rule', 'sub', 'dim', {
171 value: "each request is charged to the kinds of tool results it carried, split evenly when it carried several; a turn's first request is the prompt, a subagent's requests are subagents; list-price estimates",
172 })
173 add('models-title', 'title', 'title', { value: 'By model' })
174 for (const [model, e] of Object.entries(g.byModel).sort(([, a], [, b]) => byCost(a, b))) {
175 const { share, words } = row(e)
176 add(`model-${model}`, 'bar', 'plain', { label: modelLabel(model), bar: bar(e.usd === null ? 0 : share, 10), value: words })
177 }
178 }
179
180 // The one footnote: what the figures count, and over what span.
181 const since = g.trackingSince === null ? '' : ` Counting since redline started, ${fmtDuration(now - g.trackingSince)} ago.`
182 add('note', 'note', 'dim', {
183 value: `New tokens are uncached input, cache writes and output; cache reads are not counted. Throughput is over the last ${SPAN_MS / 60_000} minutes, pace over the last 10, and run-out from the window's average or the last 15 minutes when that is faster.${since}`,
184 })
185 return lines
186}
187
188/**
189 * Bolt's pose beside the context row: crashed once the context is
190 * full or a 5-hour or weekly plan window is spent (OUT, its reset still to
191 * come), idle otherwise.
192 */
193export function boltPose(g: RedlineGauge, now: number): 'idle' | 'crash' {
194 const percent = percentOf(g)
195 if (percent !== undefined && percent >= 100) return 'crash'
196 const isOut = (l: RedlineLimit) => {
197 const reset = Date.parse(l.resetsAt ?? '')
198 return (Number.isNaN(reset) || reset > now) && runOut(l, now)?.kind === 'out'
199 }
200 return g.limits.some(l => (l.kind === 'five_hour' || l.kind === 'seven_day') && isOut(l)) ? 'crash' : 'idle'
201}
202
203/** The same view as text, for a surface that places no pane. */
204export function detailText(g: RedlineGauge, now: number): string {
205 return ['```text', ...detailLines(g, now, 10).map(l => l.text), '```'].join('\n')
206}
207
208/**
209 * The same view for a surface that places no pane, as transcript log rows:
210 * the terminal draws a row's first 2000 characters, so the lines are grouped
211 * under that, whole lines only. None of it is command text, so the model
212 * reads none of it.
213 */
214export function detailChunks(g: RedlineGauge, now: number, max = 1900): string[] {
215 const chunks: string[] = []
216 let chunk = ''
217 for (const line of detailLines(g, now, 10).map(l => l.text)) {
218 if (chunk !== '' && chunk.length + 1 + line.length > max) {
219 chunks.push(chunk)
220 chunk = ''
221 }
222 chunk = chunk === '' ? line : `${chunk}\n${line}`
223 }
224 if (chunk !== '') chunks.push(chunk)
225 return chunks
226}
227hooks/gauge.ts 451 lines1// redline's pure logic: no `$`, no clock, no state. Every hook turns what it
2// saw into a Reading, `reduce` folds it into the gauge, and the formatters
3// draw the gauge as text. Kept pure so the thresholds, the toast's hysteresis,
4// the pace and the cost math are table-tested on their own.
5
6import type { TurnUsage } from 'claude-code'
7import type { RedlineGauge, RedlineLimit, RedlineSample, RedlineSpend, RedlineTokens, RedlineTurn } from '../types'
8import { priceRequest, sumTokens } from './prices'
9
10export const AMBER_AT = 60
11export const RED_AT = 80
12/** The 80% toast re-arms only once context drops under this (after /compact). */
13export const REARM_BELOW = 75
14/** Pace and tokens per minute look back this far. */
15export const SPAN_MS = 10 * 60_000
16/** A plan window's current rate looks back this far (it moves in whole points). */
17export const LIMIT_SPAN_MS = 15 * 60_000
18/** Under a minute of growth is too little to extrapolate from. */
19const MIN_PACE_MS = 60_000
20const TURNS_KEPT = 100
21const MINUTE = 60_000
22const HOUR = 60 * MINUTE
23
24export type Level = 'unknown' | 'normal' | 'amber' | 'red'
25
26export const ZERO: RedlineTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
27
28export const EMPTY_GAUGE: RedlineGauge = {
29 spend: {},
30 byModel: {},
31 sessionStartedAt: null,
32 trackingSince: null,
33 window: null,
34 contextTokens: null,
35 contextPercent: null,
36 samples: [],
37 flow: [],
38 totals: ZERO,
39 tableUsd: ZERO,
40 unpriced: [],
41 ledgerUsd: null,
42 limits: [],
43 limitHistory: {},
44 turns: [],
45 model: null,
46 isArmed: true,
47}
48
49// ---------------------------------------------------------------- thresholds
50
51/**
52 * Context fill as a whole percentage, as the gauge shows and judges it:
53 * Claude Code's own figure when it gave one for this count, else the count
54 * over the window, rounded as Claude Code rounds it.
55 */
56export function percentOf(g: Pick<RedlineGauge, 'contextTokens' | 'contextPercent' | 'window'>): number | undefined {
57 if (g.contextTokens === null) return undefined
58 if (g.contextPercent !== null) return g.contextPercent
59 if (!g.window) return undefined
60 return Math.round((g.contextTokens * 100) / g.window)
61}
62
63export function levelOf(percent: number | undefined): Level {
64 if (percent === undefined) return 'unknown'
65 if (percent >= RED_AT) return 'red'
66 if (percent >= AMBER_AT) return 'amber'
67 return 'normal'
68}
69
70/**
71 * The 80% toast's hysteresis: fire once on the way up, then stay quiet until
72 * the fill drops under 75% (a /compact, a fresh session) and arms it again.
73 */
74export function alarm(isArmed: boolean, percent: number | undefined): { isArmed: boolean; fire: boolean } {
75 if (percent === undefined) return { isArmed, fire: false }
76 if (isArmed && percent >= RED_AT) return { isArmed: false, fire: true }
77 if (!isArmed && percent < REARM_BELOW) return { isArmed: true, fire: false }
78 return { isArmed, fire: false }
79}
80
81// ---------------------------------------------------------------------- pace
82
83/**
84 * When the context fills at the rate of the last 10 minutes: milliseconds
85 * after the last reading, 0 when already full, undefined with no estimate.
86 *
87 * Only the run since the last drop counts (a compaction starts it over), and
88 * it must span a minute and grow; flat or shrinking context has no estimate.
89 */
90export function paceEta(samples: readonly RedlineSample[], window: number | null, now: number): number | undefined {
91 const growth = contextGrowth(samples, now)
92 if (!window || growth === undefined) return undefined
93 const left = window - growth.last
94 return left <= 0 ? 0 : left / growth.perMs
95}
96
97/**
98 * How fast a reading grew over the last `span` (10 minutes for the context),
99 * counted from the last drop: units per millisecond and the latest value.
100 */
101export function contextGrowth(samples: readonly RedlineSample[], now: number, span = SPAN_MS): { perMs: number; last: number } | undefined {
102 const recent = samples.filter(s => s.t >= now - span)
103 let start = 0
104 for (let i = 1; i < recent.length; i++) {
105 if ((recent[i]?.tokens ?? 0) < (recent[i - 1]?.tokens ?? 0)) start = i
106 }
107 const run = recent.slice(start)
108 const first = run[0]
109 const last = run[run.length - 1]
110 if (!first || !last || run.length < 2) return undefined
111 const covered = last.t - first.t
112 const growth = last.tokens - first.tokens
113 if (covered < MIN_PACE_MS || growth <= 0) return undefined
114 return { perMs: growth / covered, last: last.tokens }
115}
116
117/**
118 * New tokens a minute over the last 10 minutes: uncached input, cache writes
119 * and output. Cache reads are left out: every request re-reads the whole
120 * context, so counting them measures context size, not work.
121 */
122export function tokensPerMinute(flow: readonly RedlineSample[], now: number, since: number | null): number {
123 const from = now - SPAN_MS
124 const sum = flow.filter(s => s.t > from).reduce((n, s) => n + s.tokens, 0)
125 const covered = Math.min(SPAN_MS, now - (since ?? from))
126 return sum / Math.max(1, covered / MINUTE)
127}
128
129// --------------------------------------------------------------- plan limits
130
131// The windows Claude Code reports (SessionRateLimit.kind) and how long each
132// runs; any other kind (a gateway's spend_limit) is shown with no run-out.
133const LIMIT_WINDOWS: Record<string, { label: string; short: string; ms: number }> = {
134 five_hour: { label: '5-hour', short: '5h', ms: 5 * HOUR },
135 seven_day: { label: 'weekly', short: '7d', ms: 7 * 24 * HOUR },
136}
137
138export const limitLabel = (kind: string): string => LIMIT_WINDOWS[kind]?.label ?? kind.replace(/_/g, ' ')
139export const limitShort = (kind: string): string => LIMIT_WINDOWS[kind]?.short ?? kind.replace(/_limit$/, '').replace(/_/g, ' ')
140
141/** What a run-out was judged on: the last 15 minutes, or the window's average. */
142export type RunOutBasis = 'recent' | 'average'
143
144export type RunOut = { kind: 'out' } | { kind: 'at'; ms: number; basis: RunOutBasis } | { kind: 'resets-first'; basis: RunOutBasis }
145
146/**
147 * When a plan window runs out. At its current rate (how far it moved over the
148 * last 15 minutes) when it moved; otherwise at the average pace since it
149 * opened (its start is its reset less its length), so a late burst warns and
150 * a quiet spell does not hide what came before. Undefined with no reset time
151 * or one already past, and for an average that has too little to go on (a
152 * window of unknown length, unused, or opened under 10 minutes ago).
153 */
154export function runOut(limit: RedlineLimit, now: number, history: readonly RedlineSample[] = []): RunOut | undefined {
155 if (limit.percentUsed >= 100) return { kind: 'out' }
156 const reset = Date.parse(limit.resetsAt ?? '')
157 if (Number.isNaN(reset) || reset <= now) return undefined
158 const left = 100 - limit.percentUsed
159 const recent = contextGrowth(history, now, LIMIT_SPAN_MS)
160 let ms: number
161 let basis: RunOutBasis
162 if (recent) {
163 ms = left / recent.perMs
164 basis = 'recent'
165 } else {
166 const length = LIMIT_WINDOWS[limit.kind]?.ms
167 if (length === undefined || limit.percentUsed <= 0) return undefined
168 const elapsed = now - (reset - length)
169 if (elapsed < 10 * MINUTE) return undefined
170 ms = (left * elapsed) / limit.percentUsed
171 basis = 'average'
172 }
173 return now + ms >= reset ? { kind: 'resets-first', basis } : { kind: 'at', ms, basis }
174}
175
176/** Adds this reading of each plan window to its last 15 minutes of readings. */
177export function recordLimits(
178 history: Readonly<Record<string, readonly RedlineSample[]>>,
179 limits: readonly RedlineLimit[],
180 now: number,
181): Record<string, RedlineSample[]> {
182 const out: Record<string, RedlineSample[]> = {}
183 for (const l of limits) {
184 const kept = (history[l.kind] ?? []).filter(s => s.t >= now - LIMIT_SPAN_MS)
185 const last = kept[kept.length - 1]
186 // A reading a minute is plenty while the figure holds still.
187 const isSame = last !== undefined && last.tokens === l.percentUsed && now - last.t < MINUTE
188 out[l.kind] = isSame ? kept : [...kept, { t: now, tokens: l.percentUsed }]
189 }
190 return out
191}
192
193// ------------------------------------------------------------------- reduce
194
195/** What one hook saw. A field left out changes nothing in the gauge. */
196export type Reading = {
197 now: number
198 sessionStartedAt?: number
199 window?: number
200 /** The main loop's context: a count, or null when unknown again. */
201 contextTokens?: number | null
202 /** Claude Code's own percentage for that count, when it gave one. */
203 contextPercent?: number
204 ledgerUsd?: number
205 limits?: readonly RedlineLimit[]
206 /** A main-loop turn began with this prompt. */
207 turnStart?: { turnId: string; text: string }
208 /** One model request finished, in the main loop or a subagent's, and the kinds of work it carried. */
209 request?: { turnId: string; agentId?: string; usage: TurnUsage; kinds?: readonly string[] }
210}
211
212const addTokens = (a: RedlineTokens, b: RedlineTokens): RedlineTokens => ({
213 input: a.input + b.input,
214 output: a.output + b.output,
215 cacheRead: a.cacheRead + b.cacheRead,
216 cacheWrite: a.cacheWrite + b.cacheWrite,
217})
218
219export const tokensOf = (u: TurnUsage): RedlineTokens => ({
220 input: u.input_tokens,
221 output: u.output_tokens,
222 cacheRead: u.cache_read_input_tokens,
223 cacheWrite: u.cache_creation_input_tokens,
224})
225
226/** A prompt's first line, cut to fit a row. */
227export function labelOf(text: string): string {
228 const line = text.trim().split('\n')[0]?.trim() ?? ''
229 if (line === '') return '(no prompt text)'
230 return line.length > 60 ? `${line.slice(0, 59)}...` : line
231}
232
233function withTurn(turns: readonly RedlineTurn[], id: string, make: () => RedlineTurn, change: (t: RedlineTurn) => RedlineTurn): RedlineTurn[] {
234 const found = turns.some(t => t.id === id)
235 const next = found ? turns.map(t => (t.id === id ? change(t) : t)) : [...turns, change(make())]
236 if (next.length <= TURNS_KEPT) return next
237 // Over the cap: drop the least busy, which the busiest list never shows;
238 // never the turn being written (a new turn has no tokens yet).
239 const others = next.filter(t => t.id !== id)
240 const quietest = others.reduce((a, b) => (sumTokens(b.tokens) < sumTokens(a.tokens) ? b : a))
241 return next.filter(t => t !== quietest)
242}
243
244/** Folds a reading into the gauge; `fire` says the 80% toast is due now. */
245export function reduce(g: RedlineGauge, r: Reading): { gauge: RedlineGauge; fire: boolean } {
246 // A gauge kept in session state by an earlier version lacks the newer fields: they start empty.
247 let next: RedlineGauge = { ...EMPTY_GAUGE, ...g }
248 if (r.sessionStartedAt !== undefined) next.sessionStartedAt = r.sessionStartedAt
249 if (next.trackingSince === null) next.trackingSince = r.now
250 if (r.window !== undefined && r.window > 0) next.window = r.window
251 if (r.ledgerUsd !== undefined) next.ledgerUsd = r.ledgerUsd
252 if (r.limits !== undefined) {
253 next.limits = r.limits.map(l => ({ ...l }))
254 next.limitHistory = recordLimits(next.limitHistory, r.limits, r.now)
255 }
256
257 let context = r.contextTokens
258 if (r.turnStart) {
259 const { turnId, text } = r.turnStart
260 const make = (): RedlineTurn => ({ id: turnId, label: '', isSubagent: false, startedAt: r.now, requests: 0, tokens: ZERO, usd: 0 })
261 next.turns = withTurn(next.turns, turnId, make, t => ({ ...t, label: labelOf(text) }))
262 }
263 if (r.request) {
264 const { turnId, agentId, usage, kinds = [] } = r.request
265 const tokens = tokensOf(usage)
266 const usd = priceRequest(usage.model, tokens)
267 next.totals = addTokens(next.totals, tokens)
268 if (usd) next.tableUsd = addTokens(next.tableUsd, usd)
269 else if (!next.unpriced.includes(usage.model)) next.unpriced = [...next.unpriced, usage.model].slice(-5)
270 next.flow = [...next.flow.filter(s => s.t > r.now - SPAN_MS), { t: r.now, tokens: tokens.input + tokens.cacheWrite + tokens.output }]
271 const make = (): RedlineTurn => ({
272 id: turnId,
273 label: agentId === undefined ? '(prompt not seen)' : `subagent ${agentId.slice(0, 8)}`,
274 isSubagent: agentId !== undefined,
275 startedAt: r.now,
276 requests: 0,
277 tokens: ZERO,
278 usd: 0,
279 })
280 next.turns = withTurn(next.turns, turnId, make, t => ({
281 ...t,
282 requests: t.requests + 1,
283 tokens: addTokens(t.tokens, tokens),
284 usd: t.usd === null || !usd ? null : t.usd + sumTokens(usd),
285 }))
286 // Where the money went: the request's tokens and price split evenly across the kinds it carried, and by model.
287 const cost = usd ? sumTokens(usd) : undefined
288 const charge = (entry: RedlineSpend | undefined, t: RedlineTokens, c: number | undefined): RedlineSpend => ({
289 requests: (entry?.requests ?? 0) + 1,
290 tokens: addTokens(entry?.tokens ?? ZERO, t),
291 usd: entry?.usd === null || c === undefined ? null : (entry?.usd ?? 0) + c,
292 })
293 const share = kinds.length > 0 ? 1 / kinds.length : 1
294 const part: RedlineTokens = { input: tokens.input * share, output: tokens.output * share, cacheRead: tokens.cacheRead * share, cacheWrite: tokens.cacheWrite * share }
295 const spend = { ...next.spend }
296 for (const kind of kinds) spend[kind] = charge(spend[kind], part, cost === undefined ? undefined : cost * share)
297 next.spend = spend
298 next.byModel = { ...next.byModel, [usage.model]: charge(next.byModel[usage.model], tokens, cost) }
299 // Only the main loop's requests are the session's context; a subagent
300 // has a window of its own.
301 if (agentId === undefined) {
302 context = tokens.input + tokens.cacheRead + tokens.cacheWrite
303 next.model = usage.model
304 }
305 }
306 if (context !== undefined) {
307 next.contextTokens = context
308 // Claude Code's figure belongs to the count it came with, never to another.
309 next.contextPercent = r.contextPercent !== undefined && context === r.contextTokens ? r.contextPercent : null
310 const last = next.samples[next.samples.length - 1]
311 if (context !== null && last?.tokens !== context) {
312 next.samples = [...next.samples.filter(s => s.t >= r.now - SPAN_MS), { t: r.now, tokens: context }]
313 }
314 }
315
316 const { isArmed, fire } = alarm(next.isArmed, percentOf(next))
317 next.isArmed = isArmed
318 return { gauge: next, fire }
319}
320
321/** The busiest turns first, by every token their requests used. */
322export const busiest = (g: RedlineGauge, n = 5): RedlineTurn[] =>
323 [...g.turns].filter(t => t.requests > 0).sort((a, b) => sumTokens(b.tokens) - sumTokens(a.tokens)).slice(0, n)
324
325// --------------------------------------------------------------- formatting
326
327// ANSI sequences: CSI (7-bit ESC [ or 8-bit U+009B) and OSC (ESC ] ... BEL or ST).
328const ANSI = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g
329const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g
330
331/**
332 * Text safe to draw: the engine refuses a whole tree (and the status line's
333 * text) that holds a C0 or C1 control character, hiding every band beneath.
334 * Model ids, plan window kinds and prompt text come from outside, so every
335 * string redline draws passes through here: ANSI sequences go, a tab becomes
336 * a space, and any other control character becomes U+FFFD.
337 */
338export const clean = (s: string): string => s.replace(ANSI, '').replace(/\t/g, ' ').replace(CONTROL, '\ufffd')
339
340export function fmtTokens(n: number): string {
341 if (n < 1000) return String(Math.round(n))
342 if (n < 10_000) return `${(n / 1000).toFixed(1)}k`
343 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
344 return `${(n / 1_000_000).toFixed(1)}M`
345}
346
347/**
348 * Dollars as the gauge writes them, always marked "≈": the price table and
349 * Claude Code's own ledger alike are list-price arithmetic, not a bill (on a
350 * subscription nobody pays them).
351 */
352export function fmtUsd(usd: number): string {
353 if (usd >= 100) return `≈$${Math.round(usd)}`
354 // A real but tiny cost reads as such, not as a suspicious $0.00.
355 if (usd > 0 && usd < 0.005) return '≈<$0.01'
356 return `≈$${usd.toFixed(2)}`
357}
358
359export function fmtDuration(ms: number): string {
360 if (ms < MINUTE) return '<1m'
361 // 59m 40s rounds to the hour, not to "60m".
362 if (ms < HOUR) return Math.round(ms / MINUTE) === 60 ? '1h' : `${Math.round(ms / MINUTE)}m`
363 if (ms < 24 * HOUR) {
364 const h = Math.floor(ms / HOUR)
365 const m = Math.round((ms - h * HOUR) / MINUTE)
366 return m === 0 ? `${h}h` : m === 60 ? `${h + 1}h` : `${h}h ${m}m`
367 }
368 const d = Math.floor(ms / (24 * HOUR))
369 const h = Math.round((ms - d * 24 * HOUR) / HOUR)
370 // 23h 50m rounds up into the next day, as 59m 40s does into the next hour.
371 return h === 0 ? `${d}d` : h === 24 ? `${d + 1}d` : `${d}d ${h}h`
372}
373
374export function fmtPace(ms: number | undefined): string | undefined {
375 if (ms === undefined) return undefined
376 if (ms <= 0) return 'full now'
377 if (ms < MINUTE) return 'full in <1m'
378 if (ms > 24 * HOUR) return 'full in >1d'
379 return `full in ~${fmtDuration(ms)}`
380}
381
382export function bar(percent: number | undefined, cells: number): string {
383 const filled = percent === undefined ? 0 : Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
384 return '█'.repeat(filled) + '░'.repeat(cells - filled)
385}
386
387/** The level as a word, so the gauge reads the same without colour. */
388export const levelWord = (level: Level): string => (level === 'red' ? 'RED' : level === 'amber' ? 'AMBER' : '')
389
390/**
391 * The session's cost as the gauge shows it: Claude Code's own estimate (what
392 * /cost shows), else the price table's, else tokens.
393 */
394export function costText(g: RedlineGauge): string {
395 if (g.ledgerUsd !== null) return fmtUsd(g.ledgerUsd)
396 if (g.unpriced.length === 0) return fmtUsd(sumTokens(g.tableUsd))
397 return `${fmtTokens(sumTokens(g.totals))} tok`
398}
399
400/** A run-out in words: `out in ~1h 20m`, `resets first`, `OUT`, or nothing. */
401export function fmtRunOut(r: RunOut | undefined): string | undefined {
402 if (r === undefined) return undefined
403 if (r.kind === 'out') return 'OUT'
404 if (r.kind === 'resets-first') return 'resets first'
405 return r.ms < MINUTE ? 'out in <1m' : `out in ~${fmtDuration(r.ms)}`
406}
407
408/** One plan window for the one-line gauge: `5h 46%`, its run-out when due first. */
409export function limitText(l: RedlineLimit, now: number, history: readonly RedlineSample[] = []): string {
410 const short = limitShort(l.kind)
411 const reset = Date.parse(l.resetsAt ?? '')
412 // Past its reset the reading is stale until the next response says more.
413 if (!Number.isNaN(reset) && reset <= now) return `${short} reset`
414 const r = runOut(l, now, history)
415 // A spent window: when it comes back is the one figure that matters.
416 if (r?.kind === 'out') return Number.isNaN(reset) ? `${short} OUT` : `${short} OUT, resets in ~${fmtDuration(reset - now)}`
417 const head = `${short} ${Math.round(l.percentUsed)}%`
418 // On the one line only a window that runs dry before it resets earns words.
419 return r && r.kind === 'at' ? `${head} ${fmtRunOut(r)}` : head
420}
421
422/** The pieces every view of the gauge draws, computed once. */
423export function gaugeParts(g: RedlineGauge, now: number) {
424 const percent = percentOf(g)
425 return {
426 percent,
427 level: levelOf(percent),
428 context: percent === undefined ? 'ctx --' : `ctx ${percent}%`,
429 cost: costText(g),
430 rate: `${fmtTokens(tokensPerMinute(g.flow, now, g.trackingSince))} tok/min`,
431 pace: fmtPace(paceEta(g.samples, g.window, now)),
432 // Carries the engine's window kinds: each view cleans what it draws.
433 limits: g.limits.map(l => limitText(l, now, g.limitHistory[l.kind])),
434 }
435}
436
437/**
438 * The status line: plain text, the level spelled out since it has no colour,
439 * cleaned since a plan window's kind is the engine's string.
440 */
441export function gaugeLine(g: RedlineGauge, now: number): string {
442 const p = gaugeParts(g, now)
443 const word = levelWord(p.level)
444 const head = p.percent === undefined ? p.context : `${p.context} ${bar(p.percent, 10)}${word ? ` ${word}` : ''}`
445 return clean([head, p.cost, p.rate, p.pace, ...p.limits].filter(Boolean).join(' · '))
446}
447
448/** The 80% toast: redline's own words and a number, no outside text. */
449export const TOAST_TEXT = (percent: number): string =>
450 `Context is ${percent}% full. Run /compact to summarize it, or /clear to start a fresh session.`
451hooks/spend.ts 66 lines1// Where the money went: which kind of work a model request carried. A request's
2// input holds the results of the previous response's tool calls in the same loop,
3// so each request is charged to the kinds of those tools, read off the response
4// stream redline already watches; the first request of a turn carries the prompt,
5// a subagent's requests are one kind. Nothing is guessed from the transcript.
6
7import { priceRow } from './prices'
8
9/** The kinds, in the words the pane uses. */
10export const KIND_WORDS: Readonly<Record<string, string>> = {
11 prompt: 'prompt',
12 reading: 'reading',
13 editing: 'editing',
14 shell: 'shell',
15 search: 'search',
16 planning: 'planning',
17 subagents: 'subagents',
18 answering: 'answering',
19 other: 'other',
20}
21
22const READING = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead'])
23const EDITING = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
24const SHELL = new Set(['Bash', 'BashOutput', 'KillShell'])
25const SEARCH = new Set(['WebSearch', 'WebFetch', 'ToolSearch'])
26const PLANNING = new Set(['TodoWrite', 'TodoRead', 'Task', 'Agent', 'ExitPlanMode', 'EnterPlanMode', 'AskUserQuestion'])
27
28/** The kind a tool's result counts as: one of the words, or `mcp:<server>` for an MCP server's tools. */
29export function kindOf(tool: string): string {
30 if (tool.startsWith('mcp__')) {
31 const [server = '', ...rest] = tool.slice(5).split('__')
32 // A lent plan tool is planning, whichever mod lends it.
33 if (rest.join('__') === 'plan') return 'planning'
34 return `mcp:${server}`
35 }
36 if (READING.has(tool)) return 'reading'
37 if (EDITING.has(tool)) return 'editing'
38 if (SHELL.has(tool)) return 'shell'
39 if (SEARCH.has(tool)) return 'search'
40 if (PLANNING.has(tool)) return 'planning'
41 return 'other'
42}
43
44/**
45 * The kinds one request is charged to: a subagent's request is `subagents`; a turn's
46 * first request is the `prompt`; any other carries the results of the tools the
47 * previous response called (each kind once), or `answering` when it carried none.
48 */
49export function kindsCarried(index: number, agentId: string | undefined, previousTools: readonly string[]): string[] {
50 if (agentId !== undefined) return ['subagents']
51 if (index === 0) return ['prompt']
52 const kinds = [...new Set(previousTools.map(kindOf))]
53 return kinds.length > 0 ? kinds : ['answering']
54}
55
56/** A kind's row label and the words that lead its value (`MCP · ` for a server). */
57export function spendLabel(kind: string): { label: string; prefix: string } {
58 if (kind.startsWith('mcp:')) return { label: kind.slice(4).slice(0, 12), prefix: 'MCP · ' }
59 return { label: KIND_WORDS[kind] ?? kind, prefix: '' }
60}
61
62/** A model's row label: its price-table family, else the id without its vendor prefix. */
63export function modelLabel(model: string): string {
64 return priceRow(model)?.label ?? model.replace(/^claude-/, '').slice(0, 12)
65}
66hooks/bolt.ts 109 lines1// Bolt sprites for mods, v1.0.0. GENERATED by brand/bolt-sprites/tools/build.py: edit design.py, not this file.
2// Copy this file into your mod's hooks/ folder (mods install on their own, so they cannot import from brand/).
3// Rules (brand/BRAND.md): pink is the antenna light only, blush the cheeks only. Never recolour, rotate or stretch.
4// Never draw Bolt over a safety hold, and never in a toast (toasts are uncoloured text).
5
6export const BOLT_PALETTE = {
7 B: '#C8F53C',
8 S: '#9BC21A',
9 O: '#FF5C8A',
10 K: '#FF8FB1',
11 E: '#101114',
12 W: '#FBFBF8',
13 G: '#8E95A3',
14} as const
15
16export type BoltSize = 'small' | 'tiny'
17export type BoltSmallPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep' | 'sweat' | 'think' | 'egg' | 'egg-wobble-l' | 'egg-wobble-r' | 'egg-crack-1' | 'egg-crack-2' | 'egg-crack-3' | 'egg-hatch'
18export type BoltTinyPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep'
19export type BoltPose = BoltSmallPose | BoltTinyPose
20
21/** One text cell: a character and its colours (absent = the surface's own). */
22export type BoltCell = { ch: string; fg?: string; bg?: string }
23
24/** Pixel rows: one palette letter per pixel, '.' empty. Heights are even (two pixel rows per cell). */
25export const BOLT_SPRITES = {
26 small: {
27 'idle': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '..SS..SS..'],
28 'run1': ['...OO.....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', 'SS......SS'],
29 'run2': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', '...SSSS...'],
30 'jump': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '...S..S...'],
31 'crash': ['......OO..', '.....S....', '..BBBBBB..', '.BBBBBBBB.', 'BEBEBBEBEB', 'BBEBBBBEBB', 'BEBEBBEBEB', 'BBBBEEBBBB', '.SSSSSSSS.', 'SS......SS'],
32 'cheer': ['S...OO...S', 'S...SS...S', 'SBBBBBBBBS', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBEEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
33 'sleep': ['....SS....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBBBBBBBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBBBBBBB', '.SSSSSSSS.', '..SS..SS..'],
34 'sweat': ['....OO....', '....SS...G', '..BBBBBB.G', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
35 'think': ['....OO....', '....SS....', '..BBBBBB..', '.BBWEBWEB.', 'BBBEEBEEBB', 'BBBBBBBBBB', 'BKKBBBBKKB', 'BBBBBEBBBB', '.SSSSSSSS.', '..SS..SS..'],
36 'egg': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
37 'egg-wobble-l': ['...OO.....', '...SS.....', '..BWBB....', '.BWBBBB...', '.BBBBBB...', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
38 'egg-wobble-r': ['.....OO...', '.....SS...', '....BWBB..', '...BWBBBB.', '...BBBBBB.', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
39 'egg-crack-1': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBBBBBB.', '.BBEBBBBS.', '..BBBBBS..', '...SSSS...'],
40 'egg-crack-2': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBBBB.', '.BBEBEBBS.', '..BBBBBS..', '...SSSS...'],
41 'egg-crack-3': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBEBB.', '.BBEBEBES.', '..BBBBBS..', '...SSSS...'],
42 'egg-hatch': ['......OO..', '......SS..', '....BWBBBB', '....B.B.B.', '..........', '..........', '.B.B.B.BB.', '.BSSSSSSB.', '..BBBBBS..', '...SSSS...'],
43 },
44 tiny: {
45 'idle': ['.O.', 'BEB', 'BBB', 'S.S'],
46 'run1': ['.O.', 'BEB', 'BBB', 'S.S'],
47 'run2': ['.O.', 'BEB', 'BBB', '.S.'],
48 'jump': ['.O.', 'BEB', 'BBB', '.S.'],
49 'crash': ['...', '.SO', 'BEB', 'SSS'],
50 'cheer': ['SOS', 'BEB', 'BBB', 'S.S'],
51 'sleep': ['.S.', 'BSB', 'BBB', 'S.S'],
52 },
53} as const
54
55/** Letters drawn over a whole cell (cell coordinates). */
56const GLYPHS: Record<string, readonly { row: number; col: number; ch: string; color: keyof typeof BOLT_PALETTE }[]> = {
57 'small/sleep': [{ row: 0, col: 8, ch: 'z', color: 'G' }],
58}
59
60/** The two run frames, in order. Swap them about every 120 ms while running. */
61export const BOLT_RUN = ['run1', 'run2'] as const
62
63function colour(key: string | undefined): string | undefined {
64 if (key === undefined || key === '.') return undefined
65 return (BOLT_PALETTE as Record<string, string>)[key]
66}
67
68/**
69 * Packs a pose into half-block text cells: rows / 2 lines of `cols` cells.
70 * Terminal: draw with Raster (see hatchling's rasterCells). Desktop: one fixed-width cell per character.
71 */
72export function boltCells(size: 'small', pose: BoltSmallPose): BoltCell[][]
73export function boltCells(size: 'tiny', pose: BoltTinyPose): BoltCell[][]
74export function boltCells(size: BoltSize, pose: BoltPose): BoltCell[][] {
75 const rows: readonly string[] = (BOLT_SPRITES[size] as Record<string, readonly string[]>)[pose] ?? []
76 const out: BoltCell[][] = []
77 for (let r = 0; r + 1 < rows.length; r += 2) {
78 const top = rows[r] ?? ''
79 const bottom = rows[r + 1] ?? ''
80 const line: BoltCell[] = []
81 for (let c = 0; c < top.length; c += 1) {
82 const t = colour(top[c])
83 const b = colour(bottom[c])
84 if (t === undefined && b === undefined) line.push({ ch: ' ' })
85 else if (b === undefined) line.push({ ch: '\u2580', fg: t })
86 else if (t === undefined) line.push({ ch: '\u2584', fg: b })
87 else if (t === b) line.push({ ch: '\u2588', fg: t })
88 else line.push({ ch: '\u2580', fg: t, bg: b })
89 }
90 out.push(line)
91 }
92 for (const glyph of GLYPHS[`${size}/${pose}`] ?? []) {
93 const line = out[glyph.row]
94 if (line !== undefined && glyph.col < line.length) line[glyph.col] = { ch: glyph.ch, fg: BOLT_PALETTE[glyph.color] }
95 }
96 return out
97}
98
99/** Merges a row of cells into runs of one style, for coloured Text spans. */
100export function boltRuns(line: readonly BoltCell[]): BoltCell[] {
101 const runs: BoltCell[] = []
102 for (const cell of line) {
103 const last = runs[runs.length - 1]
104 if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg) last.ch += cell.ch
105 else runs.push({ ...cell })
106 }
107 return runs
108}
109hooks/prices.ts 75 lines1// The built-in price table: USD per million tokens, per model family, as the
2// published list prices stood on 2026-10-08.
3//
4// Claude Code's own cost ledger (what /cost shows) is preferred for the session
5// total wherever it exists; this table prices the breakdowns (per category, per
6// turn) and stands in for the total where no ledger is kept. Everything priced
7// here is shown with "≈": it leaves out long-context premiums, fast mode,
8// 1-hour cache writes, batch and regional pricing. A model with no row gets no
9// price at all (tokens only), never a guess.
10
11import type { RedlineTokens } from '../types'
12
13export type Price = RedlineTokens
14
15type Row = {
16 label: string
17 families: readonly string[]
18 /** "major.minor", as the model id spells it (claude-opus-4-1 is 4.1). */
19 versions: readonly string[]
20 price: Price
21 /** A second rate card for requests whose prompt is over 100k tokens. */
22 above100k?: Price
23}
24
25// Cache writes are the 5-minute rate (1.25x input); cache reads 0.1x input
26// unless the model's list price says otherwise.
27const rates = (input: number, output: number, readX = 0.1): Price => ({
28 input,
29 output,
30 cacheWrite: input * 1.25,
31 cacheRead: input * readX,
32})
33
34export const PRICE_TABLE: readonly Row[] = [
35 { label: 'Fable 5.1', families: ['fable', 'mythos'], versions: ['5.1'], price: rates(10, 50, 0.025) },
36 { label: 'Fable 5', families: ['fable', 'mythos'], versions: ['5.0'], price: rates(10, 50) },
37 { label: 'Opus 5.5', families: ['opus'], versions: ['5.5'], price: rates(4, 20, 0.05) },
38 { label: 'Opus 4.5 - 5', families: ['opus'], versions: ['4.5', '4.6', '4.7', '4.8', '5.0'], price: rates(5, 25) },
39 { label: 'Opus 4 - 4.1', families: ['opus'], versions: ['4.0', '4.1'], price: rates(15, 75) },
40 { label: 'Sonnet 5 - 5.5', families: ['sonnet'], versions: ['5.0', '5.5'], price: rates(2, 10) },
41 { label: 'Sonnet 4 - 4.6', families: ['sonnet'], versions: ['4.0', '4.5', '4.6'], price: rates(3, 15) },
42 { label: 'Haiku 5.5', families: ['haiku'], versions: ['5.5'], price: rates(0.1, 0.5), above100k: rates(0.5, 2.5) },
43 { label: 'Haiku 4.5', families: ['haiku'], versions: ['4.5'], price: rates(1, 5) },
44]
45
46// claude-opus-4-1-20250805, us.anthropic.claude-sonnet-4-5-20250929-v1:0,
47// claude-haiku-4-5@20251001, claude-opus-5: the family, then the major, then a
48// one-digit minor when one follows (a date is never a minor).
49const MODEL_ID = /(opus|sonnet|haiku|fable|mythos)-(\d+)(?:-(\d)(?!\d))?/
50
51/** The price row for a model id, or undefined when the table has none. */
52export function priceRow(model: string): Row | undefined {
53 const m = MODEL_ID.exec(model.toLowerCase())
54 if (!m) return undefined
55 const family = m[1] ?? ''
56 const version = `${m[2]}.${m[3] ?? '0'}`
57 return PRICE_TABLE.find(row => row.families.includes(family) && row.versions.includes(version))
58}
59
60/** What one request cost per category, USD; undefined for an unpriced model. */
61export function priceRequest(model: string, t: RedlineTokens): RedlineTokens | undefined {
62 const row = priceRow(model)
63 if (!row) return undefined
64 const prompt = t.input + t.cacheRead + t.cacheWrite
65 const p = row.above100k && prompt > 100_000 ? row.above100k : row.price
66 return {
67 input: (t.input * p.input) / 1e6,
68 output: (t.output * p.output) / 1e6,
69 cacheRead: (t.cacheRead * p.cacheRead) / 1e6,
70 cacheWrite: (t.cacheWrite * p.cacheWrite) / 1e6,
71 }
72}
73
74export const sumTokens = (t: RedlineTokens): number => t.input + t.output + t.cacheRead + t.cacheWrite
75types/index.d.ts 94 lines1// The values redline keeps in $.state: the gauge every drawing reads, and
2// whether the band above the prompt is on. Self-contained on purpose: no
3// import, every name led by "Redline".
4
5/** The four token counts of a model request, as the API reports them. */
6export type RedlineTokens = {
7 input: number
8 output: number
9 cacheRead: number
10 cacheWrite: number
11}
12
13/** One reading over time: context tokens, or new tokens of one request. */
14export type RedlineSample = { t: number; tokens: number }
15
16/** One plan rate-limit window as Claude Code reports it. */
17export type RedlineLimit = {
18 kind: string
19 percentUsed: number
20 resetsAt?: string
21}
22
23/** One turn (or one subagent run) and what its requests used. */
24export type RedlineTurn = {
25 id: string
26 label: string
27 isSubagent: boolean
28 startedAt: number
29 requests: number
30 tokens: RedlineTokens
31 /** USD by the built-in price table; null once a request had no price. */
32 usd: number | null
33}
34
35/** What the requests charged to one kind of work, or to one model, used. */
36export type RedlineSpend = {
37 requests: number
38 tokens: RedlineTokens
39 /** USD by the built-in price table; null once a request charged here had no price. */
40 usd: number | null
41}
42
43export type RedlineGauge = {
44 /** When the session began (Claude Code's own figure), epoch ms. */
45 sessionStartedAt: number | null
46 /** When redline began counting tokens, epoch ms. */
47 trackingSince: number | null
48 /** The model's context window in tokens, from Claude Code. */
49 window: number | null
50 /** Input side of the main loop's last request; null while unknown. */
51 contextTokens: number | null
52 /**
53 * Claude Code's own fill percentage (the status line's used_percentage)
54 * when it reported one for this reading; null to compute it from tokens.
55 */
56 contextPercent: number | null
57 /** Main-loop context readings of the last 10 minutes (pace). */
58 samples: RedlineSample[]
59 /** New tokens per request of the last 10 minutes (tokens per minute). */
60 flow: RedlineSample[]
61 /** Every request counted since trackingSince, main loop and subagents. */
62 totals: RedlineTokens
63 /** The same requests priced by the built-in table, per category, USD. */
64 tableUsd: RedlineTokens
65 /** Models seen with no row in the price table. */
66 unpriced: string[]
67 /** Claude Code's own cost ledger for the session, USD; null where absent. */
68 ledgerUsd: number | null
69 limits: RedlineLimit[]
70 /** Each plan window's readings of the last 15 minutes, by kind (run-out). */
71 limitHistory: Record<string, RedlineSample[]>
72 turns: RedlineTurn[]
73 /**
74 * This session's requests by the kind of work each carried (the results of the
75 * previous response's tool calls: reading, editing, shell, search, planning; a
76 * subagent's requests as subagents; a turn's first request as prompt; one with no
77 * results as answering; an MCP server's tools as mcp:<server>), a request carrying
78 * several kinds split evenly across them.
79 */
80 spend: Record<string, RedlineSpend>
81 /** The same requests by the model that answered. */
82 byModel: Record<string, RedlineSpend>
83 /** The model of the main loop's last request. */
84 model: string | null
85 /** The 80% toast may fire; false until context drops under 75%. */
86 isArmed: boolean
87}
88
89declare module 'claude-code' {
90 interface PluginState {
91 redline: { gauge: RedlineGauge; isBandOn: boolean }
92 }
93}
94