SLOPSHOPPER

redline

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

newpanebandcommandtoaststatus
★ 1v0.1.3MITupdated 2026-10-13modmyclaude/claude-code-mods/mods/redline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · redline
│ ┃ Redline ✕ › fix the failing auth test and add an audit log call │ ┃ Context 49% ▄▀▄ │ ┃ █████░░░░░ 97k of 200k tokens ▀▀▀ ● redline: redline: turn start failed: undefined is not an object (ev │ ┃ Pace not enough dat ⏺ Read(src/auth.ts) │ ┃ Throughput 0 new ⎿ Read 6 lines │ ┃ Cost ≈ ⏺ Update(src/auth.ts) │ ┃ Claude Code's estimate for the whole ⎿ Added 2 lines, removed 1 line │ ┃ session (as /cost shows it) ⏺ Bash(bun test) │ ┃ Session ⎿ 3 pass, 1 fail │ ┃ no model requests counted yet │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Plan limits │ ┃ 5-hour ███░░░░░░░ 31% ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Busiest turns › /redline │ ┃ none yet │ ┃ │ ┃ Where the money went │ ┃ nothing yet │ ┃ │ ┃ New tokens are uncached input, cache writes │ ┃ and output; cache reads are not counted. │ ┃ Throughput is over the last 10 minutes, pace │ ┃ over the last 10, and run-out from the │ ┃ window's average or the last 15 minutes when │ ┃ that is faster. Counting since redline ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ redline: ctx 49% █████░░░░░ · ≈$0.42 · 0 tok/min · 5h 31%

Draws

Pane · Redline
Context 49% ▄▀▄ █████░░░░░ 97k of 200k tokens ▀▀▀ Pace not enough dat Throughput 0 new Cost ≈ Claude Code's estimate for the whole session (as /cost shows it) Session no model requests counted yet Plan limits 5-hour ███░░░░░░░ 31% Busiest turns none yet Where the money went nothing yet New tokens are uncached input, cache writes and output; cache reads are not counted. Throughput is over the last 10 minutes, pace over the last 10, and run-out from the window's average or the last 15 minutes when that is faster. Counting since redline started, <1m ago. [ Show band ] [ Close ]
README

<img src="../../brand/pixel-icons/svg/redline.svg" width="64" height="64" alt="">

Redline

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%

Install

/plugin install redline --marketplace modmyclaude/claude-code-mods

Use it

  • Status line (under the prompt), updated on every model request:
  • context fill as a percentage with a 10-cell bar, and the level as a word: nothing under 60%, AMBER from 60%, RED from 80% (the status line has no colour, so the word carries it);
  • the session cost so far, marked ≈: Claude Code's own estimate (what /cost shows) where it keeps one, otherwise redline's price table;
  • new tokens per minute over the last 10 minutes;
  • the pace: when the context hits 100% at the rate it grew over the last 10 minutes (full in ~25m);
  • your plan's 5-hour and weekly windows (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.
  • Toast at 80%: one toast suggesting /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.

Settings

None. The thresholds (60%, 80%, re-arm under 75%) and the 10-minute window are fixed.

What it can touch

  • Reads: what Claude Code reports about the session ($.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.
  • Writes: one remembered value in the plugin's own store (whether the band is on). No files.
  • No processes, no network, no model calls. No tokens: nothing it draws reaches the model.

Where the numbers come from

  • Context window: Claude Code reports it for the model in use, so a 1M-token model reads correctly. No table is involved.
  • Context fill: Claude Code's own figures (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.
  • Session cost: Claude Code's own total (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.
  • Price table (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 ≈.
  • Plan run-out: the window's current rate, how far it moved over the last 15 minutes of readings, so a late burst warns even when the average looks calm. When it has not moved in that time, the average pace since the window opened (its reset time less its length) stands in; the detail view says which of the two it used. A gateway's spend limit gets an estimate only from its recent rate.

Limits

  • Plan windows appear only on a Claude subscription, after the first reply of a session; with an API key there are none, and the detail view says so.
  • Token counts (and the price-table estimate) start when redline loads; Claude Code's cost estimate and the context fill cover the whole session. A plan window's recent rate needs readings from this session, so right after start the average stands in.
  • The pace needs a minute of growing context inside the last 10 minutes. Flat or shrinking context gives no estimate. Right after a compaction the fill is Claude Code's own estimate of the compacted size until the next reply measures it.
  • The status line, band and pane update on model requests and measurements, not on a timer. Two one-shot repaints are the exception: 10 minutes after the last reading (so an idle session stops showing a pace and rate) and just after each plan window resets (so a spent window flips to reset while you wait). The session time in the detail view refreshes with readings.
  • Terminal and desktop are supported and tested. VS Code and mobile draw the pane through the same hooks but are not tested.

Built from a prompt

This mod was built from its prompt.

More mods: the full catalogue.

Source 9 files
hooks/register.tsx 397 lines
1// 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}
397
hooks/bolt-draw.tsx 86 lines
1// 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}
86
hooks/cells.tsx 45 lines
1// 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}
45
hooks/detail.ts 227 lines
1// 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}
227
hooks/gauge.ts 451 lines
1// 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.`
451
hooks/spend.ts 66 lines
1// 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}
66
hooks/bolt.ts 109 lines
1// 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}
109
hooks/prices.ts 75 lines
1// 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
75
types/index.d.ts 94 lines
1// 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