SLOPSHOPPER

heartline

KOZMOS Heartline: a neon EKG of the session drawn above the prompt. Every tool call is a spike colored by its family, every model request a blip, a failed tool…

newbandguardcommandtimer
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/heartline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · heartline
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /heartline ⎿ heartline: Heartline hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

KOZMOS Heartline

A neon EKG of the session, drawn above the prompt.

  • Every tool call is a sharp spike colored by its tool family (Bash green, Edit yellow, Read/Grep blue, Agent violet, Web cyan, MCP magenta).
  • Every model request is a smaller blip (cyan on the main loop, violet in a subagent).
  • A failed tool adds a red inverted spike.
  • Idle, the line runs flat with a slow breathing glow.
  • The trace drifts green → yellow → red as the context fills.
  • Readout: ♥ 42 bpm (tool calls in the last minute; the heart beats at that rate), ctx 58%, model requests per minute.

Terminal: a Client module draws a 2–3 row braille trace that scrolls left continuously at 30 fps, with a phosphor fade on the old side. Desktop: one SVG monitor screen with a glowing (blurred) trace on a scrolling grid, a pulsing pen and a beating heart, all CSS-animated.

The band shows while the session had any activity in the last 10 minutes. ✕ hides it; /heartline toggles it (remembered across sessions).

Türkçe

Oturumun neon EKG'si, istem satırının üstünde.

  • Her araç çağrısı, araç ailesinin rengiyle keskin bir tepe çizer (Bash yeşil, Edit sarı, Read/Grep mavi, Agent mor, Web camgöbeği, MCP pembe).
  • Her model isteği daha küçük bir kıpırtıdır (ana döngüde camgöbeği, alt ajanda mor).
  • Başarısız bir araç kırmızı, ters bir tepe ekler.
  • Boştayken çizgi düz akar ve yavaşça "nefes alır".
  • Bağlam doldukça iz yeşilden sarıya, sonra kırmızıya kayar.
  • Gösterge: ♥ 42 bpm (son bir dakikadaki araç çağrısı; kalp bu hızda atar), ctx 58%, dakikadaki model isteği.

Terminal: Client modülü 2–3 satırlık braille izini 30 fps ile sürekli sola kaydırır. Masaüstü: parlayan izli, kayan ızgaralı, CSS ile canlandırılmış tek bir SVG monitör.

Son 10 dakikada etkinlik olduğu sürece görünür. ✕ gizler; /heartline açar/kapatır (oturumlar arası hatırlanır).

Source 5 files
hooks/register.tsx 192 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { HeartBeat, HeartSnap } from '../types'
5import { ekgAlt, ekgSvg } from './ekg.ts'
6import { KZ, pxOf, toolColor } from './lib/kz.ts'
7
8const snapAtom = atom({ plugin: 'heartline', key: 'snap' } as const, null)
9const hiddenAtom = atom({ plugin: 'heartline', key: 'isHidden' } as const, false)
10
11/** The band shows while the session had any activity in this long. */
12const SHOW_MS = 10 * 60_000
13/** Beats kept: enough for the widest terminal trace. */
14const KEEP_MS = 4 * 60_000
15
16// Live collector; the band draws from the snapshot published to $.state.
17let beats: HeartBeat[] = []
18let ctx: number | null = null
19let lastAt = 0
20let isWorking = false
21let lastKey = ''
22
23async function setHidden($: EngineInterface, isHidden: boolean): Promise<void> {
24  await update($, hiddenAtom, () => isHidden)
25  try {
26    await $.store.set('hidden', isHidden)
27  } catch {
28    // Not persisted; the session still honours it.
29  }
30}
31
32/** Adds a beat and publishes; with no clock to read the beat is dropped. */
33async function beat($: EngineInterface, k: HeartBeat['k'], c: string): Promise<void> {
34  try {
35    const at = await $.clock.now()
36    beats.push({ at, k, c })
37    if (beats.length > 600) beats = beats.slice(-400)
38    lastAt = at
39    await publish($)
40  } catch {
41    // The band misses one beat.
42  }
43}
44
45async function publish($: EngineInterface): Promise<void> {
46  const now = await $.clock.now()
47  beats = beats.filter(b => now - b.at < KEEP_MS)
48  const minute = beats.filter(b => now - b.at < 60_000)
49  const snap: HeartSnap = {
50    now,
51    beats: [...beats],
52    bpm: minute.filter(b => b.k === 't').length,
53    rpm: minute.filter(b => b.k === 's').length,
54    ctx,
55    lastAt,
56    isWorking,
57  }
58  // The clock only matters while beats are on screen (the desktop scrolls by redraws).
59  const isScrolling = beats.some(b => now - b.at < 70_000)
60  const key = JSON.stringify({ ...snap, now: isScrolling ? now : 0, show: now - lastAt < SHOW_MS })
61  if (key === lastKey) return
62  lastKey = key
63  await update($, snapAtom, () => snap)
64}
65
66/** Publishes, never failing: a missed publish only skips one redraw. */
67function tryPublish($: EngineInterface): Promise<void> {
68  return publish($).catch(() => undefined)
69}
70
71async function readContext($: EngineInterface): Promise<void> {
72  try {
73    const u = await $.session.usage()
74    ctx = u.context.percent ?? ctx
75  } catch {
76    // No reading yet.
77  }
78}
79
80export const register: Register = on => {
81  on('session.start', async ($, e, next) => {
82    const started = await next(e)
83    beats = []
84    ctx = null
85    lastAt = 0
86    isWorking = false
87    lastKey = ''
88    await $.command.register({ name: 'heartline', description: 'KOZMOS: show or hide the Heartline EKG band above the prompt', immediate: true })
89    try {
90      const stored = await $.store.get('hidden')
91      if (typeof stored === 'boolean') await update($, hiddenAtom, () => stored)
92    } catch {
93      // Fresh store.
94    }
95    await readContext($)
96    await tryPublish($)
97    $.clock.every(1000, () => void tryPublish($))
98    return started
99  })
100
101  on('command.run', { command: 'heartline' }, async $ => {
102    const isHidden = !(await read($, hiddenAtom))
103    await setHidden($, isHidden)
104    return { text: isHidden ? 'Heartline hidden.' : 'Heartline shown.' }
105  })
106
107  on('turn.start', async ($, e, next) => {
108    isWorking = true
109    lastAt = await $.clock.now()
110    void tryPublish($)
111    return next(e)
112  })
113
114  on('turn.complete', async ($, e, next) => {
115    if (e.agentId === undefined) {
116      isWorking = false
117      void tryPublish($)
118    }
119    return next(e)
120  })
121
122  // Every model request is a small blip: cyan on the main loop, violet in a subagent.
123  on('turn.step', async function* ($, e, next) {
124    await beat($, 's', e.agentId === undefined ? KZ.cyan : KZ.violet)
125    return yield* next(e)
126  })
127
128  // Every tool call is a spike; a failed one adds a red inverted spike.
129  on('tool.call', async ($, e, next) => {
130    await beat($, 't', toolColor(String(e.tool)))
131    const ran = await next(e)
132    if (ran.isError === true || ran.deny !== undefined) await beat($, 'f', KZ.red)
133    return ran
134  }).catch(($, e, next) => next(e))
135
136  on('session.measure', async ($, e, next) => {
137    if (e.context.percent !== undefined) {
138      ctx = e.context.percent
139      void tryPublish($)
140    }
141    return next(e)
142  })
143
144  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
145    const drawn = await next(e)
146    if (e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
147    const snap = await read($, snapAtom)
148    if (!snap || snap.lastAt === 0 || snap.now - snap.lastAt >= SHOW_MS) return drawn
149
150    const ui = $.ui.resolve(e)
151    const { Box, Button } = ui
152    const hide = (
153      <Button key="heartline-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
154    )
155
156    if ('Svg' in ui && e.surface !== 'terminal') {
157      const { Svg } = ui
158      const W = pxOf(e.props.bodyColumns)
159      const H = e.props.maxRows >= 10 ? 74 : 58
160      return (
161        <Box flexDirection="column">
162          {drawn}
163          <Box flexDirection="row">
164            <Svg source={ekgSvg(snap, W, H)} alt={ekgAlt(snap)} width={W} height={H} />
165            {hide}
166          </Box>
167        </Box>
168      )
169    }
170
171    // Only the terminal is left, and it always has Client.
172    const { Client } = ui as Elements['terminal']
173    const cols = Math.max(24, e.props.bodyColumns || 60)
174    const rows = e.props.maxRows >= 10 ? 3 : 2
175    return (
176      <Box flexDirection="column">
177        {drawn}
178        <Box flexDirection="row">
179          <Client
180            key="heartline"
181            module="./trace.tsx"
182            width={cols - 2}
183            height={rows}
184            props={{ beats: snap.beats, now: snap.now, bpm: snap.bpm, rpm: snap.rpm, ctx: snap.ctx, width: cols - 2, rows }}
185          />
186          {hide}
187        </Box>
188      </Box>
189    )
190  })
191}
192
hooks/ekg.ts 240 lines
1// Heartline's pure drawing: the waveform, its braille frame for the terminal
2// and its glowing SVG for the desktop. No `$` here.
3import { KZ, clamp01, heat, mix, noise, svg, svgText } from './lib/kz.ts'
4
5export type Beat = { at: number; k: 't' | 's' | 'f'; c: string }
6export type Seg = { s: string; c: string; b?: boolean }
7
8/** One braille dot column of the terminal trace covers this many ms. */
9export const DOT_MS = 200
10/** What the old part of the trace fades toward. */
11export const FADE = '#5b6470'
12
13/** The shape each beat stamps on the trace: [dot offset, amplitude -1..1]. */
14const SHAPES: Record<Beat['k'], readonly (readonly [number, number])[]> = {
15  // A QRS complex and its T wave: dip, spike, undershoot, slow bump.
16  t: [[-1, -0.16], [0, 1], [1, -0.46], [2, 0.05], [4, 0.12], [5, 0.19], [6, 0.11]],
17  // A P wave: a soft blip for each model request.
18  s: [[-1, 0.1], [0, 0.3], [1, 0.12]],
19  // A failure: the spike inverted, deeper, with a rebound.
20  f: [[-1, 0.12], [0, -1], [1, 0.38], [2, -0.06]],
21}
22
23/** The trace's own color: green at an empty context, red at a full one. */
24export function traceColor(ctx: number | null): string {
25  return heat(clamp01((ctx ?? 0) / 100) * 1.05)
26}
27
28/** Samples the last `n` dot columns ending at `now`: amplitude and color per column. */
29export function sampleTrace(beats: readonly Beat[], now: number, n: number): { amp: number[]; col: (string | undefined)[] } {
30  const amp: number[] = Array.from({ length: n }, () => 0)
31  const col: (string | undefined)[] = Array.from({ length: n }, () => undefined)
32  for (const b of beats) {
33    const at = n - 1 - Math.round((now - b.at) / DOT_MS)
34    if (at < -8 || at > n + 2) continue
35    for (const [o, a] of SHAPES[b.k]) {
36      const i = at + o
37      if (i < 0 || i >= n) continue
38      if (Math.abs(a) > Math.abs(amp[i] as number)) {
39        amp[i] = a
40        col[i] = b.k === 'f' ? KZ.red : b.c
41      }
42    }
43  }
44  return { amp, col }
45}
46
47const LEFT = [0x01, 0x02, 0x04, 0x40]
48const RIGHT = [0x08, 0x10, 0x20, 0x80]
49
50/**
51 * The trace as `rows` lines of braille, `cols` cells wide, newest on the
52 * right. `breath` (0..1) dims the idle line; the oldest part fades out.
53 */
54export function traceFrame(beats: readonly Beat[], now: number, cols: number, rows: number, ctx: number | null, breath = 0): Seg[][] {
55  const w = Math.max(1, Math.floor(cols))
56  const h = Math.max(1, Math.floor(rows))
57  const n = w * 2
58  const { amp, col } = sampleTrace(beats, now, n)
59  const dots = h * 4
60  // The baseline sits inside a cell (never on a cell edge, where it would
61  // split across two rows); spikes get the room above it, dips the room below.
62  const base0 = h === 1 ? 2 : h === 2 ? 5 : 7
63  const up = base0
64  const down = dots - 1 - base0
65  // A live line: now and then a one-dot tremor, seeded by absolute time so it scrolls.
66  const head = Math.floor(now / DOT_MS)
67  const ys = amp.map((a, i) => {
68    const v = Math.max(-1, Math.min(1, a))
69    if (v === 0) return noise(head - (n - 1 - i), 7) > 0.92 ? base0 - 1 : base0
70    return Math.round(v > 0 ? base0 - v * up : base0 - v * down)
71  })
72  const bits: number[][] = Array.from({ length: h }, () => Array.from({ length: w }, () => 0))
73  for (let i = 0; i < n; i++) {
74    const y = ys[i] as number
75    const prev = i > 0 ? (ys[i - 1] as number) : y
76    const lo = Math.min(y, prev)
77    const hi = Math.max(y, prev)
78    const cell = Math.floor(i / 2)
79    const table = i % 2 === 0 ? LEFT : RIGHT
80    for (let d = Math.max(0, lo); d <= Math.min(dots - 1, hi); d++) {
81      const r = bits[Math.floor(d / 4)] as number[]
82      r[cell] = (r[cell] as number) | (table[d % 4] as number)
83    }
84  }
85  const base = mix(traceColor(ctx), FADE, breath)
86  const colors: string[] = []
87  for (let x = 0; x < w; x++) {
88    const a = Math.abs(amp[x * 2] as number) >= Math.abs(amp[x * 2 + 1] as number) ? x * 2 : x * 2 + 1
89    // A column has a color exactly where it has a beat: the stronger dot's, else the line's.
90    const c = col[a] ?? base
91    // Phosphor decay: the left third fades toward the grid.
92    const age = 1 - x / Math.max(1, w - 1)
93    colors.push(mix(c, FADE, Math.max(0, age - 0.35) * 0.9))
94  }
95  return bits.map(r => {
96    const segs: Seg[] = []
97    for (let x = 0; x < w; x++) {
98      const ch = String.fromCharCode(0x2800 + (r[x] as number))
99      const c = colors[x] as string
100      const isPen = x === w - 1
101      const last = segs[segs.length - 1]
102      if (last && last.c === c && !isPen && !last.b) last.s += ch
103      else segs.push({ s: ch, c, b: isPen || undefined })
104    }
105    return segs
106  })
107}
108
109/** Beats per minute as the heart's period in ms; a slow 1.5 s when quiet. */
110export function beatPeriod(bpm: number): number {
111  return bpm > 0 ? Math.max(300, Math.min(1500, 60_000 / Math.max(bpm, 40))) : 1500
112}
113
114export type EkgInput = { beats: readonly Beat[]; now: number; bpm: number; rpm: number; ctx: number | null; isWorking: boolean }
115
116/** The desktop drawing: a monitor screen with a glowing trace that scrolls left, and a readout. */
117export function ekgSvg(s: EkgInput, W: number, H: number): string {
118  const RW = 118 // readout
119  const x0 = 0
120  const x1 = W - RW - 10 // the pen
121  const span = 60_000
122  const pxMs = (x1 - x0) / span
123  const mid = H / 2
124  const A = H * 0.4
125  const scrollMs = 15_000
126  const dist = pxMs * scrollMs
127  const base = traceColor(s.ctx)
128  const idle = !s.beats.some(b => s.now - b.at < 8000)
129
130  // The trace as one amplitude field sampled every pixel: each beat's shape
131  // (offsets in steps of `u` px) is laid on it, the strongest wins where two meet.
132  const u = 3.2
133  const from = Math.floor(x0 - 20)
134  const to = Math.ceil(x1 + dist + 40)
135  const amp = new Map<number, { a: number; c: string }>()
136  const stamp = (bx: number, b: Beat) => {
137    const shape = SHAPES[b.k]
138    const c = b.k === 'f' ? KZ.red : b.c
139    for (let k = 0; k + 1 < shape.length; k++) {
140      const p = shape[k] as readonly [number, number]
141      const q = shape[k + 1] as readonly [number, number]
142      const xa = bx + p[0] * u
143      // Offsets rise strictly within a shape, so xb > xa.
144      const xb = bx + q[0] * u
145      for (let x = Math.ceil(xa); x <= Math.floor(xb); x++) {
146        const f = (x - xa) / (xb - xa)
147        const v = p[1] + (q[1] - p[1]) * f
148        const cur = amp.get(x)
149        if (!cur || Math.abs(v) > Math.abs(cur.a)) amp.set(x, { a: v, c })
150      }
151    }
152  }
153  for (const b of s.beats) {
154    if (s.now - b.at > span + 4000) continue
155    stamp(x1 - (s.now - b.at) * pxMs, b)
156  }
157  const pts: string[] = []
158  const overlays: string[] = []
159  let run: { c: string; pts: string[] } | undefined
160  const flush = () => {
161    if (run && run.pts.length > 1) overlays.push(`<polyline points="${run.pts.join(' ')}" stroke="${run.c}" class="hlbeat"/>`)
162    run = undefined
163  }
164  let lastFlat = false
165  for (let x = from; x <= to; x++) {
166    const hit = amp.get(x)
167    const y = mid - (hit?.a ?? 0) * A
168    const pt = `${x},${y.toFixed(1)}`
169    // Flat stretches need only their ends.
170    const isFlat = !hit && !amp.get(x + 1)
171    if (!(isFlat && lastFlat) || x === to) pts.push(pt)
172    lastFlat = isFlat
173    if (hit) {
174      if (!run || run.c !== hit.c) {
175        flush()
176        run = { c: hit.c, pts: [`${x - 1},${mid}`] }
177      }
178      run.pts.push(pt)
179    } else if (run) {
180      run.pts.push(pt)
181      flush()
182    }
183  }
184  flush()
185  const line = pts.join(' ')
186
187  const period = beatPeriod(s.bpm) / 1000
188  // Redraws keep the beat's phase: start each loop where the clock says it is.
189  const beatDelay = `animation-delay:-${((s.now % (period * 1000)) / 1000).toFixed(2)}s`
190  const ctxTxt = s.ctx === null ? '—' : `${Math.round(s.ctx)}%`
191  const css = `
192.hlscr{fill:#0a0f14}@media (prefers-color-scheme: dark){.hlscr{fill:#05080b}}
193.hlgrid{stroke:${base};stroke-opacity:.09;stroke-width:1}
194.hlgrid2{stroke:${base};stroke-opacity:.2;stroke-width:1}
195.hlmove{animation:hlmove ${scrollMs / 1000}s linear forwards}
196@keyframes hlmove{to{transform:translateX(-${dist.toFixed(1)}px)}}
197.hltrace{fill:none;stroke:${base};stroke-width:1.8;stroke-linejoin:round;stroke-linecap:round}
198.hlbeat{fill:none;stroke-width:2.2;stroke-linejoin:round;stroke-linecap:round}
199.hlbreath{animation:hlbr 4.2s ease-in-out infinite}@keyframes hlbr{50%{opacity:.38}}
200.hlpen{animation:hlpen ${Math.max(0.6, period).toFixed(2)}s ease-out infinite}@keyframes hlpen{0%{r:5;opacity:1}60%{r:2.6;opacity:.75}100%{r:2.6;opacity:.75}}
201.hlheart{transform-box:fill-box;transform-origin:center;animation:hlbeat ${period.toFixed(2)}s ease-out infinite}
202@keyframes hlbeat{0%{transform:scale(1.32)}18%{transform:scale(.92)}30%{transform:scale(1.12)}55%,100%{transform:scale(1)}}
203`
204  const grid: string[] = []
205  for (let gx = x0; gx < x1 + dist + 40; gx += 12) grid.push(`<path class="${Math.round((gx - x0) / 12) % 5 === 0 ? 'hlgrid2' : 'hlgrid'}" d="M${gx} 0V${H}"/>`)
206  for (let gy = mid % 12; gy < H; gy += 12) grid.push(`<path class="hlgrid" d="M${x0} ${gy.toFixed(1)}H${x1 + dist + 40}"/>`)
207
208  const body = [
209    `<defs>`,
210    `<filter id="hlGlow" x="-5%" y="-40%" width="110%" height="180%"><feGaussianBlur stdDeviation="2.4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`,
211    `<linearGradient id="hlFadeG" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".3" stop-color="#fff" stop-opacity=".55"/><stop offset=".8" stop-color="#fff" stop-opacity="1"/></linearGradient>`,
212    `<mask id="hlFade"><rect x="${x0}" y="0" width="${x1 - x0 + 6}" height="${H}" fill="url(#hlFadeG)"/></mask>`,
213    `<linearGradient id="hlBloom" x1="0" x2="1"><stop offset="0" stop-color="${base}" stop-opacity="0"/><stop offset="1" stop-color="${base}" stop-opacity=".16"/></linearGradient>`,
214    `<clipPath id="hlClip"><rect x="${x0}" y="0" width="${x1 - x0 + 6}" height="${H}" rx="10"/></clipPath>`,
215    `</defs>`,
216    `<rect class="hlscr" x="${x0}" y="0" width="${x1 - x0 + 6}" height="${H}" rx="10"/>`,
217    `<g clip-path="url(#hlClip)">`,
218    `<rect x="${x1 - 90}" y="0" width="96" height="${H}" fill="url(#hlBloom)"/>`,
219    `<g mask="url(#hlFade)"><g class="hlmove">`,
220    grid.join(''),
221    `<g filter="url(#hlGlow)"><polyline points="${line}" class="hltrace${idle ? ' hlbreath' : ''}"/>${overlays.join('')}</g>`,
222    `</g></g>`,
223    `<circle cx="${x1}" cy="${mid}" r="3" fill="${base}" filter="url(#hlGlow)" class="hlpen" style="${beatDelay}"/>`,
224    `</g>`,
225    // Readout, on the theme's own panel.
226    `<rect class="p" x="${W - RW}" y="0" width="${RW}" height="${H}" rx="10"/>`,
227    `<text x="${W - RW + 14}" y="${mid - 4}" font-size="17" fill="${s.bpm > 0 ? KZ.red : KZ.mist}" class="hlheart" style="${beatDelay}" font-family="sans-serif">♥</text>`,
228    svgText(W - RW + 34, mid - 5, `${s.bpm}`, { size: 17, weight: 700 }),
229    svgText(W - RW + 36 + `${s.bpm}`.length * 10, mid - 5, 'bpm', { cls: 's', size: 11 }),
230    svgText(W - RW + 14, mid + 15, `ctx ${ctxTxt}`, { size: 11.5, weight: 650, fill: base }),
231    svgText(W - 10, mid + 15, `${s.rpm} req/m`, { cls: 'm', size: 10, anchor: 'end' }),
232  ].join('')
233  return svg(W, H, body, css)
234}
235
236/** What a screen reader hears for the band. */
237export function ekgAlt(s: EkgInput): string {
238  return (`Session heartline: ${s.bpm} tool calls and ${s.rpm} model requests in the last minute; context ${s.ctx === null ? 'unknown' : Math.round(s.ctx) + '%'}${s.isWorking ? '; working' : ''}.`)
239}
240
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
hooks/trace.tsx 81 lines
1// Heartline's terminal scene: a braille EKG that scrolls left at 30 fps,
2// with a beating heart readout. Runs in the drawing surface; no `$` here.
3import type { ClientModule } from 'claude-code'
4
5import { FADE, beatPeriod, traceFrame } from './ekg.ts'
6import type { Beat } from './ekg.ts'
7import { KZ, heat, mix } from './lib/kz.ts'
8
9type P = { beats: Beat[]; now: number; bpm: number; rpm: number; ctx: number | null; width: number; rows: number }
10/** t: frames drawn; sig: the props clock last seen; at: the frame it arrived on. */
11type S = { t: number; sig: number; at: number }
12
13const FRAME = 33
14const READOUT = 12
15
16const Trace: ClientModule<P, S> = (props, surface) => {
17  const s = surface.state
18  if (s === undefined) {
19    surface.setState({ t: 0, sig: props.now, at: 0 })
20    surface.every(FRAME, () => {
21      // Set just above, before the first tick.
22      const c = surface.state as S
23      surface.setState({ ...c, t: c.t + 1 })
24    })
25  } else if (s.sig !== props.now) {
26    surface.setState({ ...s, sig: props.now, at: s.t })
27  }
28  const t = s?.t ?? 0
29  const at = s && s.sig === props.now ? s.at : t
30  // The hooks module publishes its clock now and then; between, frames count.
31  const now = props.now + (t - at) * FRAME
32
33  const { Box, Text } = surface.elements
34  const width = surface.columns > 0 ? surface.columns : props.width
35  const rows = Math.max(2, Math.min(3, props.rows))
36  const cols = Math.max(8, width - READOUT)
37  const quiet = !props.beats.some(b => now - b.at < 8000)
38  const breath = quiet ? 0.2 + 0.3 * (0.5 + 0.5 * Math.sin((now / 4200) * Math.PI * 2)) : 0
39  const frame = traceFrame(props.beats, now, cols, rows, props.ctx, breath)
40
41  const period = beatPeriod(props.bpm)
42  const phase = (now % period) / period
43  const lub = phase < 0.12 || (phase > 0.24 && phase < 0.32)
44  const heart = props.bpm > 0 ? (lub ? KZ.red : mix(KZ.red, FADE, 0.45)) : mix(KZ.magenta, FADE, 0.3 + 0.4 * (0.5 + 0.5 * Math.sin(now / 700)))
45  const ctxTxt = props.ctx === null ? '—' : `${Math.round(props.ctx)}%`
46
47  const readout = [
48    <Text key="r0" wrap="truncate-end">
49      <Text color={heart} bold={lub}>{lub && props.bpm > 0 ? ' ♥ ' : ' ♡ '}</Text>
50      <Text bold>{String(props.bpm)}</Text>
51      <Text dimColor> bpm</Text>
52    </Text>,
53    <Text key="r1" wrap="truncate-end">
54      <Text dimColor>   ctx </Text>
55      <Text color={props.ctx === null ? FADE : heat(props.ctx / 100)} bold>{ctxTxt}</Text>
56    </Text>,
57    <Text key="r2" wrap="truncate-end">
58      <Text dimColor>   {String(props.rpm)} req/m</Text>
59    </Text>,
60  ].slice(0, rows)
61
62  return (
63    <Box flexDirection="row">
64      <Box flexDirection="column" width={cols} flexShrink={0}>
65        {frame.map((segs, y) => (
66          <Text key={`l${y}`} wrap="truncate-end">
67            {segs.map((g, i) => (
68              <Text key={`s${i}`} color={g.c} bold={g.b}>{g.s}</Text>
69            ))}
70          </Text>
71        ))}
72      </Box>
73      <Box flexDirection="column" width={READOUT} flexShrink={0}>
74        {readout}
75      </Box>
76    </Box>
77  )
78}
79
80export default Trace
81
types/index.d.ts 24 lines
1/** One beat of the trace: a tool call (`t`), a model request (`s`) or a failed tool (`f`). */
2export type HeartBeat = { at: number; k: 't' | 's' | 'f'; c: string }
3
4export type HeartSnap = {
5  now: number
6  /** Beats of the last few minutes, oldest first. */
7  beats: HeartBeat[]
8  /** Tool calls in the last minute. */
9  bpm: number
10  /** Model requests in the last minute. */
11  rpm: number
12  /** Context fill, 0..100, or null before the first reading. */
13  ctx: number | null
14  /** Last activity of any kind; the band shows for 10 minutes after it. */
15  lastAt: number
16  isWorking: boolean
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    heartline: { snap: HeartSnap | null; isHidden: boolean }
22  }
23}
24