SLOPSHOPPER

redline

A tachometer for your Claude sessions: when the needle hits the redline, stop spawning and start finishing.

newpanebandcommandstatusprocess
★ 1v1.1.4MITupdated 2026-10-02jamubc/toolbox/plugins/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 │ ┃ │ ┃ ⢀⡤⣒⡭⣭⠒⡖⠒⡖⢪⡭⣕⡢⣄ Napping ⏺ Read(src/auth.ts) │ ┃ ⣠⢞⡵⡎⠙⡆⠈ ⠈ ⡞⠉⡶⣝⢦⡀ Engine off. ⎿ Read 6 lines │ ┃ ⡴⡱⠛⠂ ⠈ ⠈ ⠚⠳⣱⡄ 0 working · 0 ⏺ Update(src/auth.ts) │ ┃ ⡜⣼⡉⠁ 10 15 ⠉⣹⣿⡄ ⎿ Added 2 lines, removed 1 line │ ┃ ⢠⢣⣃⣁⡀ ⢀⢀⡀ ⣀⣁⣻⣧ ⏺ Bash(bun test) │ ┃ ⢸⢸⣀ 5 ⢰⣵⣶⢱ 20 ⢀⣸⣿ ⎿ 3 pass, 1 fail │ ┃ ⠸⡸⡤ ⢀⣰⣾⠟⠑⠃ ⠠⣼⡿ │ ┃ ⢇⢧⠤ ⣠⡾⠛⠁ ⠠⢤⣿⠇ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ⠈⢮⢳⡂⢀⠔⠉0 │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /redline │ ┃ No Claude sessions in terminals. ⎿ redline: Redline opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Redline
⢀⡤⣒⡭⣭⠒⡖⠒⡖⢪⡭⣕⡢⣄ Napping ⣠⢞⡵⡎⠙⡆⠈ ⠈ ⡞⠉⡶⣝⢦⡀ Engine off. ⡴⡱⠛⠂ ⠈ ⠈ ⠚⠳⣱⡄ 0 working · 0 sessions ⡜⣼⡉⠁ 10 15 ⠉⣹⣿⡄ ⢠⢣⣃⣁⡀ ⢀⢀⡀ ⣀⣁⣻⣧ ⢸⢸⣀ 5 ⢰⣵⣶⢱ 20 ⢀⣸⣿ ⠸⡸⡤ ⢀⣰⣾⠟⠑⠃ ⠠⣼⡿ ⢇⢧⠤ ⣠⡾⠛⠁ ⠠⢤⣿⠇ ⠈⢮⢳⡂⢀⠔⠉0 No Claude sessions in terminals.
README

redline

Too many Claudes going at once? Feeling overwhelmed? redline puts a gauge above your Claude Code prompt that counts how many of your sessions are working, so you can plan your time better.

<img width="638" height="98" alt="image" src="https://github.com/user-attachments/assets/c7d1ae26-46c2-4222-be22-d6f85e3db13f" />

<img width="302" height="731" alt="image" src="https://github.com/user-attachments/assets/4e546a80-f59e-4ba1-a81e-f5f83e7b584f" />

stop starting new Claudes and finish the ones already running.

Install

Add the marketplace:

/plugin marketplace add jamubc/toolbox

Install the plugin:

/plugin install redline@toolbox

Usage

CommandWhat it does
UI Modgauge & labels.
/redlineToggles detailed view.
/redline <0-25>demo: enable.
/redline livedemo: disable
/config → Show the count at the bottom of the terminaltoggle labels
/config → Show the fuel gaugestoggle the 5H / 7D / CTX dials

Fuel gauges

With Show the fuel gauges on, three more dials ride along: beside the tachometer above your prompt, and under it in the pane.

DialWhat it reads
5HYour plan's five-hour rate-limit window.
7DYour plan's seven-day rate-limit window.
CTXThis session's context, filling as the conversation grows.

On every dial the needle swings right, into the red, as its tank is used. The plan windows drain, so 5H and 7D read F (full) to E (empty); the context fills, so CTX reads E to F. The plan dials need a subscription; CTX works for everyone. A dial the engine has no reading for stays dim.

Stages

WorkingStage
0Napping
1–2Idling
3–5Cruising
6–8Busy
9–12Very Busy
13–17Under Pressure
18–20Redline
21–24Overboost
25+Blown

What it runs, reads and sends

redline runs two read-only programs on your machine and sends nothing anywhere. It makes no network requests.

ProgramExactly what it runsWhy
psps -x -o pid=,ppid=,tty=,args=, every two secondsLists your own processes, to find the claude sessions in your terminals and which are mid-turn.
psps -x -o pid=,ppid=,tty=,command=, once a session until it succeedsThe same table in a second format, so redline can tell which session is this one and mark it (this one) in the pane.
lsoflsof -a -d cwd -Fn -p <pids>, every two seconds, where <pids> are the claude sessions ps just foundReads each session's working directory, shown beside it in the pane.
  • A session is a claude process attached to a terminal. It counts as working while Claude Code holds a caffeinate child process, which happens on macOS during a turn. On Linux every session reads as idle.
  • With the fuel gauges on, it reads this session's usage (session.usage, and the session.measure event after each turn): the context fill and your plan's rate-limit percentages, the same figures the status line has. They are drawn on the dials and kept in this session's plugin state; they are never written to disk by redline or sent anywhere.
  • It never starts, stops or changes another process. The process tables are read, parsed in memory and dropped.
  • It hooks command.run only for its own /redline command, which opens and closes the pane and sets the demo count. It does not see or change any other command.
  • It hooks ui.close to notice when you close its pane, and session.start, session.end and session.measure to keep the gauge right across /clear.
Source 8 files
hooks/register.tsx 355 lines
1// redline: a turbo boost gauge above the prompt, its needle on how many Claude sessions in your
2// terminals are working. `/redline` opens a pane with a larger gauge and every session. With the
3// fuelGauge option on, three more dials ride along: the plan's five-hour and seven-day windows
4// and this session's context, from `$.session.usage()` and the `session.measure` pushes.
5//
6// The render hooks mount Rasters; one 10 fps timer steps the needles and repaints them with
7// `$.ui.blit`, with no render pass. Sessions live in `$.state`, so they survive a hot reload, and
8// in this module too, so they survive a /clear (which empties `$.state`: see `snapshot`).
9
10import { atom, derive, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import type { RedlineSession, RedlineUsage } from '../types'
14import { BAND_COLS, BIG, TALL, bandCanvas, fracOf, paneCanvas, paneLayout, tankFrac, usageFrom, type PaneLayout, type Tanks } from './fuel'
15import { LARGE, Needle, SCALE, SMALL, colorFor, gauge, stageFor, type Face } from './gauge'
16import { cwdsIn, lsofArgv, selfIn, sessionsIn } from './sessions'
17
18const PANE = 'redline'
19const FPS = 10
20const POLL_MS = 2000
21const BAND_WORDS = 20 // the columns the band's words need beside the fuel dials
22const EMPTY_USAGE: RedlineUsage = { fiveHour: null, sevenDay: null, context: null }
23
24const sessions = atom({ plugin: 'redline', key: 'sessions' } as const, [] as RedlineSession[])
25const preview = atom({ plugin: 'redline', key: 'preview' } as const, null as number | null)
26const isPaneOpen = atom({ plugin: 'redline', key: 'isPaneOpen' } as const, false)
27const seeded = atom({ plugin: 'redline', key: 'seeded' } as const, false)
28const usage = atom({ plugin: 'redline', key: 'usage' } as const, EMPTY_USAGE)
29
30// /clear ends the session but not the process: the host's `$.state` starts over empty while this
31// module, its pane and its timers live on, so read straight from the host there are suddenly no
32// sessions, no preview and no pane. `seeded` is false exactly then, and before the first
33// `session.start`. `snapshot` is what the drawings read, in one go: the host's while `seeded`,
34// else what this process last saw; `write` puts the kept values back before the first change
35// after a /clear, and a render that finds `seeded` false schedules that.
36type Snapshot = { sessions: RedlineSession[]; preview: number | null; isPaneOpen: boolean; usage: RedlineUsage }
37const KEYS = ['sessions', 'preview', 'isPaneOpen', 'usage'] as const
38let kept: Snapshot = { sessions: [], preview: null, isPaneOpen: false, usage: EMPTY_USAGE }
39let wasLive: boolean | null = null // what the last read saw; null before the first
40const snapshot = derive([seeded, sessions, preview, isPaneOpen, usage], (isLive, sessions, preview, isPaneOpen, usage): Snapshot => {
41  wasLive = isLive
42  if (!isLive) return kept
43  kept = { sessions, preview, isPaneOpen, usage }
44  return kept
45})
46
47/** The one place the atoms are written: each key to its own, as the validator asks. */
48async function put<K extends keyof Snapshot>($: EngineInterface, key: K, value: Snapshot[K]): Promise<void> {
49  switch (key) {
50    case 'sessions': await update($, sessions, () => value as Snapshot['sessions']); break
51    case 'preview': await update($, preview, () => value as Snapshot['preview']); break
52    case 'isPaneOpen': await update($, isPaneOpen, () => value as Snapshot['isPaneOpen']); break
53    case 'usage': await update($, usage, () => value as Snapshot['usage']); break
54  }
55}
56
57/** After a /clear (or at the first start), writes what this process kept back to the host. */
58let reseeding: Promise<void> | null = null
59function reseed($: EngineInterface): Promise<void> {
60  reseeding ??= (async () => {
61    try {
62      if (await read($, seeded)) return
63      for (const key of KEYS) await put($, key, kept[key])
64      await update($, seeded, () => true)
65      wasLive = true
66    } finally {
67      reseeding = null
68    }
69  })()
70  return reseeding
71}
72
73/** Changes one value from what `snapshot` reads, and keeps it here too. */
74async function write<K extends keyof Snapshot>($: EngineInterface, key: K, change: (now: Snapshot[K]) => Snapshot[K]): Promise<void> {
75  // `kept` is current once a read has seen the host live: only this module writes these values.
76  if (wasLive === null) await read($, snapshot)
77  if (!wasLive) await reseed($)
78  const next = change(kept[key])
79  kept = { ...kept, [key]: next }
80  await put($, key, next)
81}
82
83/** `fuel` is the pane's dial layout, or true for the band's; absent, the tach draws alone. */
84type View = { requestId: string; face: Face; fuel?: PaneLayout | true }
85type TankKey = keyof Tanks
86
87const TANK_KEYS = ['fiveHour', 'sevenDay', 'context'] as const
88
89const needle = new Needle()
90const fuelNeedles: Record<TankKey, Needle> = {
91  fiveHour: new Needle(SCALE, 0),
92  sevenDay: new Needle(SCALE, 0),
93  context: new Needle(SCALE, 0),
94}
95const fuelReads: Record<TankKey, number | null> = { fiveHour: null, sevenDay: null, context: null }
96let views: View[] = []
97let tick = 0
98let working = 0 // the count the needle heads for: live, or the preview
99let reads = 0 // where the needle reads this frame
100let latestUsage: RedlineUsage = EMPTY_USAGE // the last figures the engine reported
101let hasFuel = false // the fuelGauge option
102let self: number | null = null // this session's pid, once looked for
103let hasStatusLine = false
104
105const workingIn = (all: readonly RedlineSession[]) => all.filter(s => s.isWorking).length
106const tally = (all: readonly RedlineSession[]) => `${workingIn(all)} working · ${all.length} session${all.length === 1 ? '' : 's'}`
107const folder = (cwd: string) => cwd.slice(cwd.lastIndexOf('/') + 1) || cwd || '?'
108const hex = (c: number) => `#${c.toString(16).padStart(6, '0')}`
109
110/** Where each tank sits now: the stored fill, emptied once its reset time has passed. */
111function tanksNow(now: number): Tanks {
112  return {
113    fiveHour: tankFrac(latestUsage.fiveHour, now),
114    sevenDay: tankFrac(latestUsage.sevenDay, now),
115    context: fracOf(latestUsage.context),
116  }
117}
118
119/** True before any reading has been adopted (a fresh module, a hot reload, the option just on). */
120const isUnknown = (u: RedlineUsage) => u.fiveHour === null && u.sevenDay === null && u.context === null
121
122/** What the dials draw: the sprung needle positions, in 0-1, or null while a tank has no reading. */
123function drawnTanks(): Tanks {
124  const at = (key: TankKey) => (fuelReads[key] === null ? null : fuelReads[key] / SCALE)
125  return { fiveHour: at('fiveHour'), sevenDay: at('sevenDay'), context: at('context') }
126}
127
128function cells(view: View): string {
129  const reading = { needle: reads, isNapping: working <= 0 && reads < 0.3, isVenting: working >= SCALE, t: tick / FPS }
130  if (view.fuel === undefined) return gauge(view.face, reading).encode()
131  const tanks = drawnTanks()
132  return (view.fuel === true ? bandCanvas(reading, tanks) : paneCanvas(reading, tanks, view.fuel)).encode()
133}
134
135function mounted(view: View): void {
136  views = [...views.filter(v => v.requestId !== view.requestId), view]
137}
138
139async function frame($: EngineInterface): Promise<void> {
140  tick += 1
141  reads = needle.step(working, 1 / FPS)
142  if (hasFuel) {
143    const tanks = tanksNow(Date.now())
144    for (const key of TANK_KEYS) {
145      const frac = tanks[key]
146      fuelReads[key] = frac === null ? null : fuelNeedles[key].step(frac * SCALE, 1 / FPS)
147    }
148  }
149  await Promise.all(
150    views.map(async v => {
151      const res = await $.ui.blit({ requestId: v.requestId, key: 'gauge', cells: cells(v) })
152      if (res.deny !== undefined) views = views.filter(w => w !== v)
153    }),
154  )
155}
156
157function show($: EngineInterface, all: readonly RedlineSession[], previewing: number | null): void {
158  working = previewing ?? workingIn(all)
159  if (!hasStatusLine) return
160  const stage = stageFor(working).name
161  $.ui.status(previewing === null ? `${stage} · ${workingIn(all)}/${all.length}` : `${stage} · preview`)
162}
163
164/** The engine's figures for the fuel dials; null when the engine has none to give. */
165async function readUsage($: EngineInterface): Promise<RedlineUsage | null> {
166  try {
167    const { rateLimits, context } = await $.session.usage()
168    return usageFrom(rateLimits, context.percent ?? null)
169  } catch {
170    return null
171  }
172}
173
174/** Adopts new figures and writes them where the drawings can see them. */
175async function adoptUsage($: EngineInterface, found: RedlineUsage): Promise<void> {
176  latestUsage = found
177  await write($, 'usage', () => found)
178}
179
180async function refreshUsage($: EngineInterface): Promise<void> {
181  const found = await readUsage($)
182  if (found !== null) await adoptUsage($, found)
183}
184
185async function poll($: EngineInterface): Promise<void> {
186  const ps = await $.process.run(['ps', '-x', '-o', 'pid=,ppid=,tty=,args='], { timeoutMs: 5000 })
187  if (ps.exitCode !== 0) return
188  let found = sessionsIn(ps.stdout)
189  if (found.length) {
190    // lsof exits 1 when a pid has gone; the directories it did find still count.
191    const cwds = cwdsIn((await $.process.run(lsofArgv(found.map(s => s.pid)), { timeoutMs: 5000 })).stdout)
192    found = found.map(s => ({ ...s, cwd: cwds.get(s.pid) ?? '' }))
193  }
194  if (!self) self = selfIn((await $.process.run(['ps', '-x', '-o', 'pid=,ppid=,tty=,command='], { timeoutMs: 5000 })).stdout) || null
195
196  const before = await read($, snapshot)
197  if (JSON.stringify(before.sessions) === JSON.stringify(found)) return
198  await write($, 'sessions', () => found)
199  show($, found, before.preview)
200}
201
202export const register: Register = (on, options) => {
203  hasStatusLine = options.statusLine === true
204  hasFuel = options.fuelGauge === true
205
206  on('session.start', async ($, e, next) => {
207    const result = await next(e)
208    if (!hasStatusLine) $.ui.status(undefined)
209    await $.command.register({
210      name: 'redline',
211      description: `Show your Claude sessions; /redline <0-${SCALE}> previews a count, /redline live follows again`,
212    })
213    await reseed($)
214    if (hasFuel) await refreshUsage($).catch(() => {})
215    const now = await read($, snapshot)
216    show($, now.sessions, now.preview)
217    const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
218    await write($, 'isPaneOpen', () => isOpen)
219
220    $.clock.every(1000 / FPS, () => void frame($).catch(() => {}))
221    if (e.isInteractive) {
222      const count = () => void poll($).catch(() => {})
223      count()
224      $.clock.every(POLL_MS, count)
225    }
226    return result
227  })
228
229  // The engine measures the session after every turn and whenever a rate-limit window moves a
230  // whole point; the figures ride the event, so the tanks only ever move on news.
231  on('session.measure', async ($, e, next) => {
232    const result = await next(e)
233    if (hasFuel) await adoptUsage($, usageFrom(e.rateLimits, e.context.percent ?? null))
234    return result
235  })
236
237  // /clear: the pane and the band stay up, so what they show is written back as soon as the
238  // host's state is the new session's (now, or from the next event if that comes later).
239  on('session.end', async ($, e, next) => {
240    const result = await next(e)
241    if (e.reason === 'clear') await reseed($).catch(() => {})
242    return result
243  })
244
245  on('command.run', { command: 'redline' }, async ($, e) => {
246    await reseed($)
247    const arg = e.args.trim()
248    if (arg === 'live') {
249      await write($, 'preview', () => null)
250      show($, (await read($, snapshot)).sessions, null)
251      return { text: 'Following your sessions again.' }
252    }
253    if (/^\d+$/.test(arg)) {
254      const count = Math.min(Number(arg), SCALE)
255      await write($, 'preview', () => count)
256      show($, (await read($, snapshot)).sessions, count)
257      return { text: `Previewing ${count} working: ${stageFor(count).name}. /redline live to follow your sessions again.` }
258    }
259    if (arg !== '') return { text: `Usage: /redline, /redline <0-${SCALE}>, /redline live` }
260
261    if ((await read($, snapshot)).isPaneOpen) {
262      await $.ui.close({ id: PANE })
263      return { text: 'Redline closed.' }
264    }
265    const rows = LARGE.rows + 12 + (hasFuel ? TALL.rows - LARGE.rows : 0)
266    const opened = await $.ui.open({ id: PANE, title: 'Redline', rows })
267    await write($, 'isPaneOpen', () => true)
268    return { text: opened.isPlaced ? 'Redline opened.' : 'Redline opened; widen the terminal to see it.' }
269  })
270
271  on('ui.close', async ($, e, next) => {
272    const result = await next(e)
273    if (e.id === PANE) await write($, 'isPaneOpen', () => false)
274    return result
275  })
276
277  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
278    // Drawn from what this process kept after a /clear; the host gets it back off the render.
279    if (!(await read($, seeded))) $.clock.after(0, () => void reseed($).catch(() => {}))
280    const { sessions: all, preview: previewing, isPaneOpen: isOpen, usage: heldUsage } = await read($, snapshot)
281    if (hasFuel && isUnknown(latestUsage)) latestUsage = heldUsage
282    if (e.props.hasSurvey || isOpen) {
283      views = views.filter(v => v.requestId !== e.requestId)
284      return next(e)
285    }
286    const shown = previewing ?? workingIn(all)
287    const stage = stageFor(shown)
288    const count = previewing === null ? tally(all) : 'preview · /redline live'
289
290    if (e.surface !== 'terminal') {
291      const { Text } = $.ui.resolve(e)
292      return <Text color={hex(colorFor(shown))}>{`${stage.name} · ${count}`}</Text>
293    }
294    const { Box, Text, Raster } = $.ui.resolve(e)
295    const withFuel = hasFuel && e.props.bodyColumns >= BAND_COLS + BAND_WORDS
296    const view: View = { requestId: e.requestId, face: SMALL, fuel: withFuel ? true : undefined }
297    mounted(view)
298    return (
299      <Box flexDirection="row">
300        <Raster key="gauge" columns={withFuel ? BAND_COLS : SMALL.cols} rows={SMALL.rows} cells={cells(view)} />
301        <Box flexDirection="column" paddingX={1}>
302          <Text bold color={hex(colorFor(shown))}>{stage.name}</Text>
303          {e.props.bodyColumns >= (withFuel ? BAND_COLS : SMALL.cols) + 30 && <Text dimColor wrap="truncate">{stage.line}</Text>}
304          <Text dimColor wrap="truncate">{count}</Text>
305        </Box>
306      </Box>
307    )
308  })
309
310  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
311    if (!(await read($, seeded))) $.clock.after(0, () => void reseed($).catch(() => {}))
312    const { sessions: all, preview: previewing, usage: heldUsage } = await read($, snapshot)
313    if (hasFuel && isUnknown(latestUsage)) latestUsage = heldUsage
314    const shown = previewing ?? workingIn(all)
315    const stage = stageFor(shown)
316    const { Box, Text } = $.ui.resolve(e)
317    const list = (
318      <Box flexDirection="column" paddingX={1}>
319        {all.length === 0 && <Text dimColor>No Claude sessions in terminals.</Text>}
320        {all.map(s => (
321          <Text wrap="truncate" dimColor={!s.isWorking}>
322            {`${s.isWorking ? '●' : '○'} ${s.tty.padEnd(8)} ${folder(s.cwd)}${s.pid === self ? '  (this one)' : ''}`}
323          </Text>
324        ))}
325      </Box>
326    )
327    const words = (
328      <Box flexDirection="column" paddingX={1}>
329        <Text bold color={hex(colorFor(shown))}>{previewing === null ? stage.name : `${stage.name} (preview)`}</Text>
330        <Text dimColor wrap="wrap">{stage.line}</Text>
331        <Text dimColor>{tally(all)}</Text>
332      </Box>
333    )
334    if (e.surface !== 'terminal') {
335      return <Box flexDirection="column">{words}{list}</Box>
336    }
337    const { Raster } = $.ui.resolve(e)
338    // A narrow pane stacks the dials, as many to a row as fit; a resize renders again and remounts.
339    const fuel = hasFuel && e.props.bodyColumns >= BIG.cols ? paneLayout(e.props.bodyColumns) : undefined
340    const view: View = { requestId: e.requestId, face: LARGE, fuel }
341    mounted(view)
342    const rasterCols = fuel?.cols ?? LARGE.cols
343    return (
344      <Box flexDirection="column" paddingTop={1}>
345        <Box flexDirection={e.props.bodyColumns >= rasterCols + 24 ? 'row' : 'column'}>
346          <Raster key="gauge" columns={rasterCols} rows={fuel?.rows ?? LARGE.rows} cells={cells(view)} />
347          {words}
348        </Box>
349        <Text> </Text>
350        {list}
351      </Box>
352    )
353  })
354}
355
hooks/fuel.ts 144 lines
1// The fuel dials: a shallow arc with F at full (left) and E at empty (right), a red reserve at
2// the E end. Same dial library and palette as the tach. Each dial reads how much of its tank is
3// used: the plan's five-hour or seven-day rate-limit window, or this session's context.
4
5import type { SessionRateLimit } from 'claude-code'
6
7import type { RedlineTank, RedlineUsage } from '../types'
8import type { Canvas } from './canvas'
9import { dial, shallow } from './dial'
10import { Dots } from './dots'
11import { DIAL, DIAL_DIM, HUB, LARGE, NEEDLE, RED_C, SMALL, TICK, tachDots, type Reading } from './gauge'
12
13/** How much of each tank is used, 0 to 1; null while that tank has no reading. */
14export type Tanks = { fiveHour: number | null; sevenDay: number | null; context: number | null }
15
16export type FuelFace = { cols: number; rows: number; big: boolean }
17
18/** Beside the tach in the band: 4 rows, a label but no F/E letters. */
19export const MINI: FuelFace = { cols: 11, rows: 4, big: false }
20/** Under the tach in the pane: 6 rows, labelled F and E. */
21export const BIG: FuelFace = { cols: 15, rows: 6, big: true }
22
23const GAP = 2
24export const BAND_COLS = SMALL.cols + GAP + 3 * MINI.cols + 2 * GAP
25
26// The tach's hub is left of its panel's middle, its steam lane to the right; centered on the hub it spans this.
27const TACH_SPAN = 2 * (LARGE.cols - LARGE.cx / 2)
28
29/** How the pane fits the three big dials into `columns`: as many to a row as fit, 1 to 3. */
30export type PaneLayout = { perRow: number; cols: number; rows: number }
31
32export function paneLayout(columns: number): PaneLayout {
33  const perRow = Math.max(1, Math.min(3, Math.floor((columns + GAP) / (BIG.cols + GAP))))
34  const lines = Math.ceil(3 / perRow)
35  const rowCols = perRow * BIG.cols + (perRow - 1) * GAP
36  return {
37    perRow,
38    // Wide enough, where there is room, for the tach's hub to sit over the middle of the dials.
39    cols: Math.max(LARGE.cols, rowCols, Math.min(columns, TACH_SPAN)),
40    rows: LARGE.rows + lines * (BIG.rows + 1) + 1, // a blank row under the last dials, as above each
41  }
42}
43
44/** The pane at its widest: the three dials in one row under the tach. */
45export const WIDE = paneLayout(Infinity)
46/** The tallest the pane's dash gets: one dial to a row. */
47export const TALL = paneLayout(0)
48
49const FROM = (150 / 180) * Math.PI
50const SWEEP = (120 / 180) * Math.PI
51const RESERVE = 0.85 // the red band starts here, as a fraction of the tank used
52
53/**
54 * `fills` is for a tank that fills as it is used, the context: it reads E at the left and F at the
55 * red end. The plan windows drain, F at the left and E at the red end. Either way the needle
56 * swings right, into the red, as the tank is used.
57 */
58export function fuelDial(face: FuelFace, frac: number | null, label: string, fills = false): Dots {
59  const dots = new Dots(face.cols, face.rows)
60  const dim = frac === null
61  const reserve = (t: number) => t >= RESERVE
62  dial(dots, {
63    dial: shallow(face.cols, face.rows, FROM, SWEEP),
64    arc: {
65      colorAt: t => (dim ? DIAL_DIM : reserve(t) ? RED_C : DIAL),
66      widthAt: t => (!dim && reserve(t) ? 1 : 0),
67    },
68    ticks: {
69      count: face.big ? 8 : 6,
70      majorEvery: face.big ? 4 : 3,
71      length: 2,
72      majorLength: 3,
73      rank: 2,
74      colorAt: t => (dim ? DIAL_DIM : reserve(t) ? RED_C : TICK),
75    },
76    needle: dim
77      ? undefined
78      : {
79          t: frac,
80          color: reserve(frac) ? RED_C : NEEDLE,
81          inner: face.big ? 4 : 2.5,
82          tail: face.big ? 2.5 : 0,
83          width: face.big ? 1.4 : 1.0,
84          hub: { color: HUB },
85        },
86  })
87
88  dots.text(0, Math.round(face.cols / 2 - label.length / 2), label, dim ? DIAL_DIM : TICK)
89  if (face.big) {
90    const [start, end] = fills ? ['E', 'F'] : ['F', 'E']
91    dots.text(face.rows - 2, 0, start, dim ? DIAL_DIM : TICK)
92    dots.text(face.rows - 2, face.cols - 1, end, dim ? DIAL_DIM : RED_C)
93  }
94  return dots
95}
96
97const LABELS = ['5H', '7D', 'CTX'] as const
98
99const dialsFor = (face: FuelFace, tanks: Tanks): Dots[] =>
100  [tanks.fiveHour, tanks.sevenDay, tanks.context].map((frac, i) => fuelDial(face, frac, LABELS[i]!, i === 2))
101
102/** The band's dash: the tach with the three small dials beside it. */
103export function bandCanvas(reading: Reading, tanks: Tanks): Canvas {
104  const out = new Dots(BAND_COLS, SMALL.rows)
105  out.blit(tachDots(SMALL, reading), 0, 0)
106  dialsFor(MINI, tanks).forEach((panel, i) => out.blit(panel, 0, SMALL.cols + GAP + i * (MINI.cols + GAP)))
107  return out.toCanvas()
108}
109
110/** The pane's dash: the tach with the three big dials under it, `layout.perRow` to a row, each row centered. */
111export function paneCanvas(reading: Reading, tanks: Tanks, layout: PaneLayout = WIDE): Canvas {
112  const out = new Dots(layout.cols, layout.rows)
113  out.blit(tachDots(LARGE, reading), 0, Math.max(0, Math.min(layout.cols - LARGE.cols, Math.round(layout.cols / 2 - LARGE.cx / 2))))
114  const dials = dialsFor(BIG, tanks)
115  dials.forEach((panel, i) => {
116    const line = Math.floor(i / layout.perRow)
117    const inLine = Math.min(layout.perRow, dials.length - line * layout.perRow)
118    const left = Math.floor((layout.cols - (inLine * BIG.cols + (inLine - 1) * GAP)) / 2)
119    out.blit(panel, LARGE.rows + 1 + line * (BIG.rows + 1), left + (i % layout.perRow) * (BIG.cols + GAP))
120  })
121  return out.toCanvas()
122}
123
124/** A tank's fill from its last reading: emptied once its reset time has passed. */
125export function tankFrac(tank: RedlineTank | null, now: number): number | null {
126  if (tank === null) return null
127  if (tank.resetsAt !== null && now >= tank.resetsAt) return 0
128  return Math.max(0, Math.min(1, tank.percent / 100))
129}
130
131/** A bare percentage, clamped, as a fill; null stays null. */
132export const fracOf = (percent: number | null): number | null => (percent === null ? null : Math.max(0, Math.min(1, percent / 100)))
133
134/** The two plan windows and the context, from what a measurement reported. */
135export function usageFrom(rateLimits: readonly SessionRateLimit[], contextPercent: number | null): RedlineUsage {
136  const tank = (kind: string): RedlineTank | null => {
137    const limit = rateLimits.find(l => l.kind === kind)
138    if (!limit) return null
139    const parsed = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
140    return { percent: limit.percentUsed, resetsAt: Number.isFinite(parsed) ? parsed : null }
141  }
142  return { fiveHour: tank('five_hour'), sevenDay: tank('seven_day'), context: contextPercent }
143}
144
hooks/gauge.ts 148 lines
1// A turbo boost gauge drawn in braille: the needle reads how many Claude sessions are working.
2// The dial wears a dim housing, the redline zone is painted on thicker, and the needle tapers
3// from a ringed hub. Built from the dial library; the fuel dials in `fuel.ts` use it too.
4
5import type { Canvas } from './canvas'
6import { arc, at, dial } from './dial'
7import { Dots } from './dots'
8
9export const SCALE = 25 // the needle pins here
10const AMBER = 13
11const RED = 18
12
13export const DIAL = 0x6c6c6c
14export const DIAL_DIM = 0x4a4a4a
15export const TICK = 0xd0d0d0
16export const AMBER_C = 0xffaf00
17export const RED_C = 0xff3b30
18export const NEEDLE = 0xff875f
19export const HUB = 0xeeeeee
20const STEAM = 0xc7c7cc
21const SNORE = 0x9aa5b1
22
23export type Stage = { from: number; name: string; line: string }
24
25/** Each stage starts at `from` working sessions. */
26export const STAGES: readonly Stage[] = [
27  { from: 0, name: 'Napping', line: 'Engine off.' },
28  { from: 1, name: 'Idling', line: 'This is peaceful.' },
29  { from: 3, name: 'Cruising', line: 'Smooth and steady.' },
30  { from: 6, name: 'Busy', line: "Things are heating up." },
31  { from: 9, name: 'Very Busy', line: "Needle's climbing." },
32  { from: AMBER, name: 'Under Pressure', line: 'Pushing hard.' },
33  { from: RED, name: 'Redline', line: 'Needle in the red.' },
34  { from: 21, name: 'Overboost', line: "Past the red. Something's going to give." },
35  { from: SCALE, name: 'Blown', line: 'Pinned. Blow-off valve wide open.' },
36]
37
38export const stageFor = (working: number): Stage => STAGES.findLast(s => working >= s.from) ?? STAGES[0]!
39
40export const colorFor = (working: number): number =>
41  working <= 0 ? SNORE : working >= RED ? RED_C : working >= AMBER ? AMBER_C : NEEDLE
42
43const zoneColor = (value: number) => (value >= RED ? RED_C : value >= AMBER ? AMBER_C : TICK)
44
45/**
46 * A damped spring: it swings to a new count with a little overshoot, trembles more the higher it
47 * reads, and past the top presses against the pin and rattles there. `scale` is where the pin
48 * sits and `jitter` how much it trembles; the fuel needles take the same spring without jitter.
49 */
50export class Needle {
51  value = 0
52  private velocity = 0
53
54  constructor(
55    private readonly scale = SCALE,
56    private readonly jitter = 1,
57  ) {}
58
59  /** Moves `dt` seconds toward `target`; returns where the needle reads now. */
60  step(target: number, dt: number): number {
61    const goal = target >= this.scale ? this.scale + 2 : Math.min(target, this.scale)
62    this.velocity += (18 * (goal - this.value) - 5 * this.velocity) * dt
63    this.value += this.velocity * dt
64    if (this.value > this.scale) {
65      this.value = this.scale
66      this.velocity = -Math.abs(this.velocity) * 0.6
67    }
68    if (this.value < 0) {
69      this.value = 0
70      this.velocity = Math.abs(this.velocity) * 0.3
71    }
72    const tremble = (Math.max(0, Math.min(target, this.scale) - 6) / (this.scale - 6)) * 0.5 * this.jitter
73    return Math.max(0, Math.min(this.scale, this.value + (Math.random() * 2 - 1) * tremble))
74  }
75}
76
77/** A dial in dots: center, radius, and the arc the scale sweeps clockwise from `from`. */
78export type Face = {
79  cols: number
80  rows: number
81  cx: number
82  cy: number
83  r: number
84  from: number
85  sweep: number
86  numbers: boolean
87  lane: number // columns on the right for the snore and the steam
88}
89
90/** Above the prompt: 240°, so at rest the needle points down-left. */
91export const SMALL: Face = { cols: 11 + 5, rows: 4, cx: 11, cy: 9, r: 9, from: (7 / 6) * Math.PI, sweep: (4 / 3) * Math.PI, numbers: false, lane: 5 }
92/** In the pane: 270°, numbered. */
93export const LARGE: Face = { cols: 24 + 6, rows: 11, cx: 24, cy: 22, r: 21, from: 1.25 * Math.PI, sweep: 1.5 * Math.PI, numbers: true, lane: 6 }
94
95export type Reading = { needle: number; isNapping: boolean; isVenting: boolean; t: number }
96
97/** The tach alone, as dots, for composing with the fuel dials. */
98export function tachDots(face: Face, reading: Reading): Dots {
99  const dots = new Dots(face.cols, face.rows)
100  const zone = (t: number) => zoneColor(t * SCALE)
101
102  if (face.numbers) arc(dots, face, { colorAt: () => DIAL_DIM, radius: face.r + 2, rank: 0 })
103  dial(dots, {
104    dial: face,
105    arc: { colorAt: zone, widthAt: t => (t * SCALE >= RED ? 1 : 0) },
106    ticks: face.numbers
107      ? { count: SCALE, majorEvery: 5, length: 2, majorLength: 5, rank: 2, colorAt: zone }
108      : { count: 5, majorEvery: 1, length: 3, majorLength: 3, colorAt: zone },
109    needle: {
110      t: reading.needle / SCALE,
111      color: reading.needle >= RED ? RED_C : NEEDLE,
112      inner: face.numbers ? 5 : 3,
113      tail: face.numbers ? 3 : 1.5,
114      width: face.numbers ? 1.6 : 1.1,
115      hub: face.numbers ? { color: HUB, ringRadius: 3, ringColor: DIAL } : { color: HUB },
116    },
117  })
118
119  if (face.numbers) {
120    for (const v of [0, 5, 10, 15, 20, 25]) {
121      const [x, y] = at(face, v / SCALE, face.r - 9)
122      const s = String(v)
123      dots.text(Math.round(y / 4), Math.round(x / 2 - (s.length - 1) / 2), s, zoneColor(v))
124    }
125    dots.text(face.rows - 1, Math.round(face.cx / 2 - 7 / 2), 'CLAUDES', DIAL)
126  }
127
128  const lane = face.cols - face.lane
129  if (reading.isNapping) {
130    const k = Math.floor(reading.t * 2) % 8
131    const snore = ['z', 'z', 'Z', 'Z']
132    for (let i = 0; i < Math.min(k, 4); i++) dots.text(face.rows - 1 - i, lane + 1 + i, snore[i]!, SNORE)
133  }
134  if (reading.isVenting) {
135    for (let i = 0; i < 3; i++) {
136      const u = (reading.t * 1.7 + i / 3) % 1
137      const row = Math.round((face.rows - 1) * (1 - u))
138      const col = lane + 1 + Math.round(u * (face.lane - 2))
139      dots.text(row, col, u < 0.4 ? '▓' : u < 0.7 ? '▒' : '░', STEAM)
140    }
141  }
142  return dots
143}
144
145export function gauge(face: Face, reading: Reading): Canvas {
146  return tachDots(face, reading).toCanvas()
147}
148
hooks/sessions.ts 72 lines
1// The Claude sessions running in your terminals, and which of them are mid-turn.
2
3export type Session = { pid: number; tty: string; isWorking: boolean; cwd: string }
4
5/** Working directories of `pids`, in lsof's field format: `p<pid>`, then `n<path>`. */
6export const lsofArgv = (pids: readonly number[]) => ['lsof', '-a', '-d', 'cwd', '-Fn', '-p', pids.join(',')]
7
8type Proc = { pid: number; ppid: number; tty: string; cmd: string; arg1: string }
9
10const base = (path: string) => path.slice(path.lastIndexOf('/') + 1)
11
12function procsIn(ps: string): Proc[] {
13  return ps
14    .split('\n')
15    .map(line => {
16      const [pid = '', ppid = '', tty = '', argv0 = '', arg1 = ''] = line.trim().split(/\s+/)
17      return { pid: Number(pid), ppid: Number(ppid), tty, cmd: base(argv0), arg1 }
18    })
19    .filter(p => p.pid > 0 && p.cmd !== '')
20}
21
22/** `claude` with a terminal, and not one of the daemon's helpers. */
23const isSession = (p: Proc) =>
24  p.cmd === 'claude' && p.tty !== '??' && p.tty !== '?' && p.arg1 !== 'daemon' && !p.arg1.startsWith('bg-')
25
26/**
27 * The sessions in `ps` output, ordered by terminal. A session is working while it has a
28 * `caffeinate` child, which Claude Code holds on macOS through a turn.
29 */
30export function sessionsIn(ps: string): Session[] {
31  const procs = procsIn(ps)
32  const caffeinated = new Set(procs.filter(p => p.cmd === 'caffeinate').map(p => p.ppid))
33  return procs
34    .filter(isSession)
35    .map(p => ({ pid: p.pid, tty: p.tty, isWorking: caffeinated.has(p.pid), cwd: '' }))
36    .sort((a, b) => a.tty.localeCompare(b.tty) || a.pid - b.pid)
37}
38
39/** The session `pid` runs under: itself or its nearest session ancestor; 0 for none. */
40/**
41 * This session, from the table `SELF_ARGV` printed: the session its own row runs under. Every
42 * redline polls with the same `ps` at the same moment, so the lookup asks with a format of its own,
43 * and only another session looking itself up in the same instant makes it ambiguous: then 0, and
44 * the next poll asks again.
45 */
46export const SELF_ARGV = ['ps', '-x', '-o', 'pid=,ppid=,tty=,command='] as const
47
48export function selfIn(ps: string): number {
49  const signature = SELF_ARGV.join(' ')
50  const ours = ps.split('\n').filter(line => line.trimEnd().endsWith(signature)).map(line => Number(line.trim().split(/\s+/)[0]))
51  const found = new Set(ours.map(pid => sessionOf(ps, pid)).filter(pid => pid > 0))
52  return found.size === 1 ? [...found][0]! : 0
53}
54
55export function sessionOf(ps: string, pid: number): number {
56  const procs = new Map(procsIn(ps).map(p => [p.pid, p]))
57  for (let p = procs.get(pid), hops = 0; p && hops < 64; p = procs.get(p.ppid), hops++) {
58    if (isSession(p)) return p.pid
59  }
60  return 0
61}
62
63export function cwdsIn(lsof: string): Map<number, string> {
64  const cwds = new Map<number, string>()
65  let pid = 0
66  for (const line of lsof.split('\n')) {
67    if (line.startsWith('p')) pid = Number(line.slice(1))
68    else if (line.startsWith('n') && pid) cwds.set(pid, line.slice(1))
69  }
70  return cwds
71}
72
hooks/canvas.ts 37 lines
1// A grid of terminal cells, encoded as a `Raster` takes them: row-major little-endian u32
2// triplets [codePoint, foreground, background], base64.
3
4const DEFAULT = 0x01000000 // the terminal's own color
5
6export class Canvas {
7  readonly cells: Uint32Array
8
9  constructor(
10    readonly rows: number,
11    readonly cols: number,
12  ) {
13    this.cells = new Uint32Array(rows * cols * 3)
14    for (let i = 0; i < rows * cols; i++) this.cells.set([0x20, DEFAULT, DEFAULT], i * 3)
15  }
16
17  /** Writes `s` from (r, c) in color `fg`; characters off the grid are dropped. */
18  put(r: number, c: number, s: string, fg: number): void {
19    if (r < 0 || r >= this.rows) return
20    let x = c
21    for (const ch of s) {
22      if (x >= 0 && x < this.cols) this.cells.set([ch.codePointAt(0)!, fg, DEFAULT], (r * this.cols + x) * 3)
23      x++
24    }
25  }
26
27  line(r: number): string {
28    let s = ''
29    for (let c = 0; c < this.cols; c++) s += String.fromCodePoint(this.cells[(r * this.cols + c) * 3] ?? 0x20)
30    return s
31  }
32
33  encode(): string {
34    return new Uint8Array(this.cells.buffer).toBase64()
35  }
36}
37
hooks/dial.ts 123 lines
1// The dial standard library: the parts every gauge in redline is built from. A dial is a pivot,
2// a radius, and the arc its scale sweeps clockwise from `from`; callers work in `t`, 0 at the
3// start of the sweep and 1 at its end, and supply the colors. The braille grid is in `dots.ts`.
4
5import { Dots } from './dots'
6
7/** A dial's scale: where the pivot sits and the arc it sweeps. */
8export type Dial = { cx: number; cy: number; r: number; from: number; sweep: number }
9
10/** A shallow dial whose pivot sits at the bottom edge of a `cols` × `rows` panel. */
11export function shallow(cols: number, rows: number, from: number, sweep: number): Dial {
12  const cx = cols - 0.5 // the panel's middle, between two dot columns, so the dial rounds the same both sides
13  const cy = rows * 4 - 2
14  const reach = Math.max(Math.abs(Math.cos(from)), Math.abs(Math.cos(from - sweep)))
15  const r = Math.floor(Math.min((cols - 2) / reach, cy - 5))
16  return { cx, cy, r, from, sweep }
17}
18
19export const angleAt = (d: Dial, t: number) => d.from - t * d.sweep
20
21/** The point `r` from the pivot at angle `a`. */
22export const polar = (d: Dial, a: number, r: number): [number, number] => [d.cx + r * Math.cos(a), d.cy - r * Math.sin(a)]
23
24/** The point at `t` on a circle of radius `r`. */
25export const at = (d: Dial, t: number, r = d.r): [number, number] => polar(d, angleAt(d, t), r)
26
27export type ArcSpec = {
28  colorAt: (t: number) => number
29  /** Extra dots drawn outward at `t`, for a painted zone. */
30  widthAt?: (t: number) => number
31  radius?: number
32  rank?: number
33}
34
35/** The scale arc, in fine steps so it reads as a line. */
36export function arc(dots: Dots, d: Dial, spec: ArcSpec): void {
37  const r = spec.radius ?? d.r
38  const rank = spec.rank ?? 1
39  const widthAt = spec.widthAt ?? (() => 0)
40  const n = Math.ceil(d.sweep * r * 1.6)
41  for (let i = 0; i <= n; i++) {
42    const t = i / n
43    const color = spec.colorAt(t)
44    const [x, y] = at(d, t, r)
45    dots.dot(x, y, color, rank)
46    for (let k = 1, w = widthAt(t); k <= w; k++) {
47      const [wx, wy] = at(d, t, r + k)
48      dots.dot(wx, wy, color, rank)
49    }
50  }
51}
52
53export type TickSpec = {
54  count: number
55  colorAt: (t: number) => number
56  length?: number
57  majorLength?: number
58  /** Every `majorEvery`-th tick is a major one, one rank above the rest; 0 (the default) makes none. */
59  majorEvery?: number
60  rank?: number
61  /** How far inside the arc the ticks start. */
62  inner?: number
63}
64
65/** The ticks along the scale. */
66export function ticks(dots: Dots, d: Dial, spec: TickSpec): void {
67  const length = spec.length ?? 2
68  const majorLength = spec.majorLength ?? length + 1
69  const rank = spec.rank ?? 2
70  const inner = spec.inner ?? 1
71  for (let k = 0; k <= spec.count; k++) {
72    const t = k / spec.count
73    const major = spec.majorEvery ? k % spec.majorEvery === 0 : false
74    const [x0, y0] = at(d, t, d.r - inner)
75    const [x1, y1] = at(d, t, d.r - (major ? majorLength : length))
76    dots.line(x0, y0, x1, y1, spec.colorAt(t), major ? rank + 1 : rank)
77  }
78}
79
80export type NeedleSpec = {
81  t: number
82  color: number
83  /** The tip stops this far inside the arc. */
84  inner?: number
85  /** How far past the pivot the tail runs. */
86  tail?: number
87  /** The needle's width at the pivot; it tapers to the tip. */
88  width?: number
89  hub?: { color: number; ringRadius?: number; ringColor?: number }
90}
91
92/** The needle at `t`, with its tail and hub. */
93export function needle(dots: Dots, d: Dial, spec: NeedleSpec): void {
94  const a = angleAt(d, spec.t)
95  const [tipX, tipY] = polar(d, a, d.r - (spec.inner ?? 4))
96  const [tailX, tailY] = polar(d, a + Math.PI, spec.tail ?? 2)
97  dots.needle(d.cx, d.cy, tipX, tipY, spec.width ?? 1.2, spec.color, 3)
98  dots.line(tailX, tailY, tipX, tipY, spec.color, 3)
99  if (spec.hub) {
100    if (spec.hub.ringRadius) {
101      for (let i = 0; i < 18; i++) {
102        const ha = (i / 18) * 2 * Math.PI
103        dots.dot(d.cx + spec.hub.ringRadius * Math.cos(ha), d.cy - spec.hub.ringRadius * Math.sin(ha), spec.hub.ringColor ?? spec.hub.color, 4)
104      }
105    }
106    for (const [dx, dy] of [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]] as const) dots.dot(d.cx + dx, d.cy + dy, spec.hub.color, 5)
107  }
108}
109
110export type DialSpec = {
111  dial: Dial
112  arc?: ArcSpec
113  ticks?: TickSpec
114  needle?: NeedleSpec
115}
116
117/** Draws a dial from its parts, the needle last. */
118export function dial(dots: Dots, spec: DialSpec): void {
119  if (spec.arc) arc(dots, spec.dial, spec.arc)
120  if (spec.ticks) ticks(dots, spec.dial, spec.ticks)
121  if (spec.needle) needle(dots, spec.dial, spec.needle)
122}
123
hooks/dots.ts 105 lines
1// The braille dot grid the gauges draw into: each terminal cell is a 2 × 4 grid of dots, so the
2// arcs stay round and the needles thin. A cell takes the color of the highest-ranked dot drawn in
3// it; text cells sit above every dot.
4
5import { Canvas } from './canvas'
6
7const BIT = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]] as const // braille dot bits by [row][column]
8
9export class Dots {
10  readonly bits: Uint8Array
11  readonly color: Int32Array
12  readonly rank: Int8Array
13  private readonly chars = new Map<number, string>()
14
15  constructor(readonly cols: number, readonly rows: number) {
16    this.bits = new Uint8Array(cols * rows)
17    this.color = new Int32Array(cols * rows)
18    this.rank = new Int8Array(cols * rows).fill(-1)
19  }
20
21  dot(x: number, y: number, color: number, rank: number): void {
22    const [px, py] = [Math.round(x), Math.round(y)]
23    const [c, r] = [Math.floor(px / 2), Math.floor(py / 4)]
24    if (px < 0 || py < 0 || c >= this.cols || r >= this.rows) return
25    const i = r * this.cols + c
26    this.bits[i]! |= BIT[py % 4]![px % 2]!
27    if (rank >= this.rank[i]!) {
28      this.rank[i] = rank
29      this.color[i] = color
30    }
31  }
32
33  line(x0: number, y0: number, x1: number, y1: number, color: number, rank: number): void {
34    const n = Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)) * 1.5) || 1
35    for (let i = 0; i <= n; i++) this.dot(x0 + ((x1 - x0) * i) / n, y0 + ((y1 - y0) * i) / n, color, rank)
36  }
37
38  /** A needle from the hub to the tip, `wBase` dots wide at the hub and a point at the tip. */
39  needle(hubX: number, hubY: number, tipX: number, tipY: number, wBase: number, color: number, rank: number): void {
40    const dx = tipX - hubX
41    const dy = tipY - hubY
42    const len = Math.hypot(dx, dy) || 1
43    const px = -dy / len
44    const py = dx / len
45    const n = Math.ceil(len * 2)
46    for (let i = 0; i <= n; i++) {
47      const t = i / n
48      const w = wBase * (1 - t)
49      const x = hubX + dx * t
50      const y = hubY + dy * t
51      for (let o = -w; o <= w; o += 0.5) this.dot(x + px * o, y + py * o, color, rank)
52    }
53  }
54
55  /** Writes `s` as text cells from (r, c); characters off the grid are dropped. */
56  text(r: number, c: number, s: string, color: number): void {
57    if (r < 0 || r >= this.rows) return
58    let x = c
59    for (const ch of s) {
60      if (x >= 0 && x < this.cols) {
61        const i = r * this.cols + x
62        this.bits[i] = 0
63        this.rank[i] = 99
64        this.color[i] = color
65        this.chars.set(i, ch)
66      }
67      x++
68    }
69  }
70
71  /** Pastes `other` at (r, c): dots merge by rank, text stays text. */
72  blit(other: Dots, r: number, c: number): void {
73    for (let i = 0; i < other.bits.length; i++) {
74      const or = Math.floor(i / other.cols) + r
75      const oc = (i % other.cols) + c
76      if (or < 0 || or >= this.rows || oc < 0 || oc >= this.cols) continue
77      const to = or * this.cols + oc
78      const ch = other.chars.get(i)
79      if (ch !== undefined) {
80        this.bits[to] = 0
81        this.rank[to] = 99
82        this.color[to] = other.color[i]!
83        this.chars.set(to, ch)
84        continue
85      }
86      if (!other.bits[i]) continue
87      this.bits[to]! |= other.bits[i]!
88      if (other.rank[i]! >= this.rank[to]!) {
89        this.rank[to] = other.rank[i]!
90        this.color[to] = other.color[i]!
91      }
92    }
93  }
94
95  toCanvas(): Canvas {
96    const cv = new Canvas(this.rows, this.cols)
97    for (let i = 0; i < this.bits.length; i++) {
98      const ch = this.chars.get(i)
99      if (ch !== undefined) cv.put(Math.floor(i / this.cols), i % this.cols, ch, this.color[i]!)
100      else if (this.bits[i]) cv.put(Math.floor(i / this.cols), i % this.cols, String.fromCharCode(0x2800 + this.bits[i]!), this.color[i]!)
101    }
102    return cv
103  }
104}
105
types/index.d.ts 28 lines
1export type RedlineSession = { pid: number; tty: string; isWorking: boolean; cwd: string }
2
3/** One rate-limit window's last reading: how much is used, and when it refills. */
4export type RedlineTank = { percent: number; resetsAt: number | null }
5
6/** The figures behind the fuel dials; each null until the engine has a reading. */
7export type RedlineUsage = {
8  fiveHour: RedlineTank | null
9  sevenDay: RedlineTank | null
10  /** This session's context fill, percent used; null until a response reports one. */
11  context: number | null
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    redline: {
17      sessions: RedlineSession[]
18      /** A count shown instead of the live one (`/redline 12`); null follows the sessions. */
19      preview: number | null
20      isPaneOpen: boolean
21      /** True once this session's values are written; false after a /clear empties them. */
22      seeded: boolean
23      /** The plan windows and the context, for the fuel dials. */
24      usage: RedlineUsage
25    }
26  }
27}
28