SLOPSHOPPER

warpdrive

KOZMOS Warpdrive: a starfield at warp above the prompt. Stars stream out of the center at the speed of Claude's live output token rate: a slow drift while it…

newbandcommandtimer
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/warpdrive
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · warpdrive
› 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 › /warpdrive ⎿ warpdrive: Warpdrive hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

KOZMOS Warpdrive

A starfield at warp, above the prompt. Stars stream out of the center at the speed of Claude's live output token rate: a slow drift while it thinks, long hyperspace streaks while it writes. When a main turn ends the ship jumps: a ring of light, and a line like ⇢ arrived · 42s · 18.4k tok.

  • Terminal: a Client surface module at ~30 fps, 3–4 rows of · ∙ • stars and ─ ═ ╲ ╱ │ streaks colored by depth (far indigo → near white, tinted cyan in hyperspace), with a HUD: ▸ WARP 6.2 │ 38 tok/s │ 4.1k tok │ 12s.
  • Desktop: one SVG; 72 stars fly radially by CSS keyframes, each star's animation delay is taken from the clock so a redraw resumes the flight instead of restarting it. Light and dark themes each get their own sky.
  • The rate comes from every turn.step stream (text, thinking and tool-input chunks, ≈ 4 characters a token, measured over 3 s), topped up with the request's reported output_tokens. Subagents count too.
  • Shows while working and for 5 s after the jump; nothing otherwise.
  • ✕ or /warpdrive hides it (remembered); /warpdrive shows it again.

Türkçe

İstemin üstünde warp hızında bir yıldız alanı. Yıldızlar merkezden dışarı, Claude'un canlı çıktı token hızıyla akar: düşünürken yavaş bir süzülme, yazarken uzun hiperuzay çizgileri. Ana tur bittiğinde gemi atlar: bir ışık halkası ve ⇢ arrived · 42s · 18.4k tok gibi bir satır.

  • Terminal: ~30 fps çalışan bir Client modülü; derinliğe göre renklenen 3–4 satır yıldız ve iz, üstünde ▸ WARP 6.2 │ 38 tok/s │ … göstergesi.
  • Masaüstü: tek bir SVG; yıldızlar CSS animasyonuyla uçar, gecikmeler saatten hesaplandığı için yeniden çizimde akış kesilmez. Açık/koyu tema uyumlu.
  • Hız, her turn.step akışından (metin, düşünme, araç girdisi; ≈ 4 karakter bir token, 3 sn pencere) ve isteğin bildirdiği output_tokens değerinden gelir.
  • Çalışırken ve atlayıştan sonra 5 sn görünür; başka zaman hiçbir şey çizmez.
  • ✕ ya da /warpdrive gizler (hatırlanır); /warpdrive yeniden gösterir.
Source 5 files
hooks/register.tsx 182 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { WarpSnap } from '../types'
5import { pxOf } from './lib/kz.ts'
6import { RateMeter, SHOW_AFTER, arrivalLine, warpAlt, warpSpeed, warpSvg } from './warp.ts'
7import type { WarpView } from './warp.ts'
8
9const snapAtom = atom({ plugin: 'warpdrive', key: 'snap' } as const, null)
10const hiddenAtom = atom({ plugin: 'warpdrive', key: 'isHidden' } as const, false)
11
12// Live collector; the band draws the snapshot published to $.state.
13const meter = new RateMeter()
14let pendingTok = 0
15let isWorking = false
16let turnStartedAt = 0
17let turnTokens = 0
18let arrival: WarpSnap['arrival']
19let seq = 0
20let lastKey = ''
21
22async function setHidden($: EngineInterface, isHidden: boolean): Promise<void> {
23  await update($, hiddenAtom, () => isHidden)
24  try {
25    await $.store.set('hidden', isHidden)
26  } catch {
27    // Not persisted; this session still honours it.
28  }
29}
30
31async function publish($: EngineInterface): Promise<void> {
32  const now = await $.clock.now()
33  if (pendingTok > 0) {
34    meter.add(now, pendingTok)
35    pendingTok = 0
36  }
37  const rate = isWorking ? meter.rate(now) : 0
38  const visible = isWorking || (arrival !== undefined && now - arrival.at < SHOW_AFTER)
39  const snap: WarpSnap = {
40    now,
41    isWorking,
42    rate: Math.round(rate),
43    speed: Math.round(warpSpeed(rate) * 100) / 100,
44    tokens: Math.round(turnTokens),
45    turnStartedAt,
46    arrival,
47    visible,
48  }
49  // The clock matters only while the band shows (the HUD counts seconds).
50  const key = JSON.stringify({ ...snap, now: visible ? Math.floor(now / 1000) : 0 })
51  if (key === lastKey) return
52  await update($, snapAtom, () => snap)
53  // Only once it is written, so a refused write is tried again next time.
54  lastKey = key
55}
56
57export const register: Register = on => {
58  on('session.start', async ($, e, next) => {
59    const started = await next(e)
60    meter.reset()
61    pendingTok = 0
62    isWorking = false
63    turnStartedAt = 0
64    turnTokens = 0
65    arrival = undefined
66    lastKey = ''
67    await $.command.register({ name: 'warpdrive', description: 'KOZMOS: show or hide the Warpdrive starfield above the prompt', immediate: true })
68    try {
69      const stored = await $.store.get('hidden')
70      if (typeof stored === 'boolean') await update($, hiddenAtom, () => stored)
71    } catch {
72      // A fresh store.
73    }
74    await publish($).catch(() => undefined)
75    $.clock.every(500, () => void publish($).catch(() => undefined))
76    return started
77  })
78
79  on('command.run', { command: 'warpdrive' }, async $ => {
80    const isHidden = !(await read($, hiddenAtom))
81    await setHidden($, isHidden)
82    return { text: isHidden ? 'Warpdrive hidden.' : 'Warpdrive engaged.' }
83  })
84
85  on('turn.start', async ($, e, next) => {
86    isWorking = true
87    turnStartedAt = await $.clock.now()
88    turnTokens = 0
89    arrival = undefined
90    meter.reset()
91    void publish($).catch(() => undefined)
92    return next(e)
93  })
94
95  // Every model request, main loop or subagent, feeds the engines: the text,
96  // thinking and tool arguments as they stream (≈ 4 characters a token), then
97  // the request's reported output tokens for whatever did not stream.
98  on('turn.step', async function* ($, e, next) {
99    const stream = next(e)
100    let streamed = 0
101    for await (const c of stream) {
102      const tok = c.kind === 'text' || c.kind === 'thinking' ? c.text.length / 4 : c.kind === 'input' ? c.json.length / 4 : 0
103      streamed += tok
104      pendingTok += tok
105      yield c
106    }
107    const r = await stream.result
108    const out = r.usage?.output_tokens ?? 0
109    if (out > streamed) pendingTok += out - streamed
110    if (isWorking) turnTokens += Math.max(out, streamed)
111    return r
112  })
113
114  on('turn.complete', async ($, e, next) => {
115    if (e.agentId === undefined && isWorking) {
116      isWorking = false
117      seq++
118      arrival = {
119        seq,
120        at: await $.clock.now(),
121        durationMs: e.durationMs,
122        tokens: Math.round(Math.max(turnTokens, e.usage?.output_tokens ?? 0)),
123        isAborted: e.isAborted,
124      }
125      void publish($).catch(() => undefined)
126    }
127    return next(e)
128  })
129
130  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
131    const drawn = await next(e)
132    if (e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
133    const snap = await read($, snapAtom)
134    if (!snap || !snap.visible) return drawn
135
136    const { Box, Button } = $.ui.resolve(e)
137    // The jump that landed, while no turn runs.
138    const landed = snap.isWorking ? undefined : snap.arrival
139    const label = landed ? arrivalLine(landed.durationMs, landed.tokens, landed.isAborted) : ''
140    const view: WarpView = {
141      now: snap.now,
142      speed: snap.speed,
143      rate: snap.rate,
144      tokens: snap.tokens,
145      elapsedMs: snap.isWorking ? snap.now - snap.turnStartedAt : 0,
146      isWorking: snap.isWorking,
147      label,
148      flashSeq: landed ? landed.seq : 0,
149    }
150    const hide = <Button key="warpdrive-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
151    const rows = Math.max(2, Math.min(e.props.maxRows >= 12 ? 4 : 3, e.props.maxRows - 2))
152
153    if (e.surface === 'terminal') {
154      const { Client } = $.ui.resolve(e)
155      const cols = Math.max(20, (e.props.bodyColumns || 80) - 3)
156      return (
157        <Box flexDirection="column">
158          {drawn}
159          <Box flexDirection="row">
160            <Client key="warpdrive" module="./field.tsx" width={cols} height={rows} props={{ cols, rows, speed: view.speed, rate: view.rate, tokens: view.tokens, elapsedMs: view.elapsedMs, isWorking: view.isWorking, label, flashSeq: view.flashSeq }} />
161            {hide}
162          </Box>
163        </Box>
164      )
165    }
166
167    // Desktop, VS Code and mobile: one animated Svg.
168    const { Svg } = $.ui.resolve(e)
169    const W = pxOf(e.props.bodyColumns) - 28
170    const H = rows >= 4 ? 84 : 66
171    return (
172      <Box flexDirection="column">
173        {drawn}
174        <Box flexDirection="row" alignItems="flex-start">
175          <Svg source={warpSvg(view, W, H)} alt={warpAlt(view)} width={W} height={H} />
176          {hide}
177        </Box>
178      </Box>
179    )
180  })
181}
182
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/warp.ts 260 lines
1// Warpdrive's pure core: the token-rate meter, the star physics, the terminal
2// frame (rows of colored runs) and the desktop SVG. No `$` here.
3import { clamp01, fmtSpan, mix, noise, svg, svgText, xml } from './lib/kz.ts'
4
5/** Output tokens per second that count as full hyperspace. */
6export const TOP_RATE = 160
7/** The window the live rate is measured over. */
8export const RATE_WINDOW = 3000
9/** How long the band stays after a jump. */
10export const SHOW_AFTER = 5000
11
12export type Star = { a: number; d: number; z: number }
13export type Run = { s: string; c: string }
14
15/** 0 (drift) .. 1 (hyperspace) from tokens per second; a square root so small rates still move. */
16export function warpSpeed(rate: number): number {
17  return clamp01(Math.sqrt(Math.max(0, rate) / TOP_RATE))
18}
19
20/** WARP 1.0 .. 9.9 */
21export function warpFactor(speed: number): string {
22  return (1 + clamp01(speed) * 8.9).toFixed(1)
23}
24
25/** 18400 → 18.4k, 950 → 950, 2_300_000 → 2.3M */
26export function fmtTok(n: number): string {
27  if (!Number.isFinite(n) || n <= 0) return '0'
28  if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
29  if (n >= 1e3) return `${(n / 1e3).toFixed(n >= 1e5 ? 0 : 1)}k`
30  return `${Math.round(n)}`
31}
32
33export function arrivalLine(durationMs: number, tokens: number, isAborted = false): string {
34  return `⇢ ${isAborted ? 'dropped out' : 'arrived'} · ${fmtSpan(durationMs)} · ${fmtTok(tokens)} tok`
35}
36
37/** Output tokens over a sliding window: tokens per second now. */
38export class RateMeter {
39  private samples: { at: number; tok: number }[] = []
40
41  add(at: number, tok: number): void {
42    if (tok > 0) this.samples.push({ at, tok })
43  }
44
45  rate(now: number, windowMs = RATE_WINDOW): number {
46    this.samples = this.samples.filter(s => now - s.at < windowMs)
47    const sum = this.samples.reduce((a, s) => a + s.tok, 0)
48    return sum / (windowMs / 1000)
49  }
50
51  reset(): void {
52    this.samples = []
53  }
54}
55
56// ---------------------------------------------------------------------------
57// Stars. `d` is the distance from the center (0..1 = the edge), `z` the depth
58// (1 = near: brighter, faster, longer streaks), `a` the heading.
59
60export function spawnStar(r: () => number, anywhere: boolean): Star {
61  return { a: r() * Math.PI * 2, d: anywhere ? 0.05 + r() * 0.95 : 0.01 + r() * 0.12, z: r() }
62}
63
64export function seedStars(n: number, r: () => number): Star[] {
65  return Array.from({ length: n }, () => spawnStar(r, true))
66}
67
68/** One frame of flight: stars accelerate outward (perspective) and respawn near the center. */
69export function stepStars(stars: readonly Star[], speed: number, r: () => number): Star[] {
70  return stars.map(s => {
71    const v = (0.004 + speed * 0.06) * (0.35 + s.z) * (0.25 + s.d)
72    const d = s.d + v
73    return d > 1.1 ? spawnStar(r, false) : { a: s.a, d, z: s.z }
74  })
75}
76
77const FAR = '#3f4a8f'
78const MID = '#818cf8'
79const NEAR = '#e0e7ff'
80const HYPER = '#67e8f9'
81const DEEP = '#1e1b4b'
82const GOLD = '#fde68a'
83
84export function depthColor(z: number, speed: number): string {
85  const base = z < 0.5 ? mix(FAR, MID, z / 0.5) : mix(MID, NEAR, (z - 0.5) / 0.5)
86  return mix(base, HYPER, clamp01(speed) * 0.55 * z)
87}
88
89type Cell = { ch: string; c: string; z: number }
90
91/**
92 * The star field as `rows` rows of colored runs, `cols` cells wide. `flash`
93 * is the jump flash's progress 0..1 (or < 0 for none); `hud` a line written
94 * on the last row; `label` a line centered on the middle row.
95 */
96export function warpFrame(stars: readonly Star[], cols: number, rows: number, speed: number, flash: number, hud: string, label: string): Run[][] {
97  const W = Math.max(1, Math.floor(cols))
98  const H = Math.max(1, Math.floor(rows))
99  const grid: (Cell | undefined)[][] = Array.from({ length: H }, () => Array.from({ length: W }, () => undefined))
100  const put = (x: number, y: number, ch: string, c: string, z: number) => {
101    const cx = Math.round(x)
102    const cy = Math.round(y)
103    if (cx < 0 || cy < 0 || cx >= W || cy >= H) return
104    const row = grid[cy]! // inside the grid, checked above
105    const old = row[cx]
106    if (!old || old.z <= z) row[cx] = { ch, c, z }
107  }
108  const cx = (W - 1) / 2
109  const cy = (H - 1) / 2
110  const rx = W / 2 + 1
111  const ry = H / 2 + 0.6
112  const sp = clamp01(speed)
113
114  for (const s of stars) {
115    const ux = Math.cos(s.a) * rx
116    const uy = Math.sin(s.a) * ry
117    const x = cx + ux * s.d
118    const y = cy + uy * s.d
119    const color = depthColor(s.z, sp)
120    const len = sp * 16 * (0.3 + s.z) * (0.2 + s.d)
121    // Streak glyph by the heading on the cell grid.
122    const slope = Math.abs(uy) / Math.max(1e-6, Math.abs(ux))
123    const streak = slope < 0.35 ? (sp > 0.65 && s.z > 0.5 ? '═' : '─') : slope > 2.5 ? '│' : ux * uy > 0 ? '╲' : '╱'
124    if (len >= 1) {
125      const norm = Math.hypot(ux, uy) // > 0: rx and ry are positive and cos, sin never both 0
126      const dx = ux / norm
127      const dy = uy / norm
128      const n = Math.min(24, Math.ceil(len))
129      for (let k = n; k >= 1; k--) {
130        const fade = k / (n + 1)
131        put(x - dx * k, y - dy * k, streak, mix(color, DEEP, fade * 0.75), s.z - 0.01 * k)
132      }
133    }
134    const head = s.z < 0.35 ? '·' : s.z < 0.7 ? '∙' : '•'
135    put(x, y, len >= 3 && s.z > 0.6 ? '•' : head, s.d < 0.08 ? mix(color, DEEP, 0.5) : color, s.z + 0.5)
136  }
137
138  // The jump flash: a ring of light racing out of the center.
139  if (flash >= 0 && flash <= 1) {
140    const r = flash * 1.25
141    for (let yy = 0; yy < H; yy++) {
142      for (let xx = 0; xx < W; xx++) {
143        const e = Math.hypot((xx - cx) / rx, (yy - cy) / ry)
144        const band = Math.abs(e - r)
145        if (band < 0.045) put(xx, yy, (xx + yy) % 3 === 0 ? '✦' : flash < 0.5 ? '*' : '·', mix('#ffffff', GOLD, flash), 9)
146        else if (e < r && (xx + yy * 3) % 5 === 0) put(xx, yy, '·', mix(GOLD, DEEP, flash), 2)
147      }
148    }
149  }
150
151  // `y` is a row of the grid (the middle or the last); text past the right edge is cut.
152  const write = (y: number, x0: number, text: string, c: string) => {
153    const row = grid[y]!
154    let x = x0
155    for (const ch of text) {
156      if (x < W) row[x] = { ch, c, z: 99 }
157      x++
158    }
159  }
160  if (label) {
161    const line = ` ${label} `
162    const len = [...line].length
163    write(Math.round(cy), Math.max(0, Math.floor((W - len) / 2)), line, GOLD)
164  }
165  if (hud) write(H - 1, 1, hud, '#a5b4fc')
166
167  return grid.map(row => {
168    const runs: Run[] = []
169    for (const cell of row) {
170      const ch = cell?.ch ?? ' '
171      const c = cell?.c ?? ''
172      const last = runs[runs.length - 1]
173      if (last && (last.c === c || ch === ' ')) last.s += ch
174      else runs.push({ s: ch, c })
175    }
176    return runs
177  })
178}
179
180export function hudLine(speed: number, rate: number, tokens: number, elapsedMs: number): string {
181  return `▸ WARP ${warpFactor(speed)} │ ${Math.round(rate)} tok/s │ ${fmtTok(tokens)} tok │ ${fmtSpan(elapsedMs)}`
182}
183
184// ---------------------------------------------------------------------------
185// Desktop: one SVG. Stars fly out of the center by CSS keyframes; each star's
186// delay is taken from the clock, so a redraw resumes the flight where it was.
187
188export type WarpView = {
189  now: number
190  speed: number
191  rate: number
192  tokens: number
193  elapsedMs: number
194  isWorking: boolean
195  label: string
196  flashSeq: number
197}
198
199const STARS = 72
200
201export function warpSvg(v: WarpView, W: number, H: number): string {
202  const q = Math.round(clamp01(v.speed) * 6) / 6
203  const cx = W / 2
204  const cy = H / 2
205  const k = (H / W) * 1.6
206  const reach = W / 2 + 40
207  const base = 7.5 - q * 6.9
208  const t = v.now / 1000
209  const stars: string[] = []
210  for (let i = 0; i < STARS; i++) {
211    const ang = noise(i, 1) * 360
212    const z = noise(i, 2)
213    const dur = base / (0.45 + z)
214    const delay = -((t + noise(i, 3) * dur) % dur)
215    const len = (2 + q * 70 * (0.35 + z)).toFixed(1)
216    const h = (0.9 + z * 1.5).toFixed(2)
217    const c = depthColor(z, q)
218    stars.push(`<g transform="rotate(${ang.toFixed(1)})"><rect class="w" x="0" y="${(-Number(h) / 2).toFixed(2)}" width="${len}" height="${h}" rx="${(Number(h) / 2).toFixed(2)}" fill="${c}" style="animation-duration:${dur.toFixed(2)}s;animation-delay:${delay.toFixed(2)}s"/></g>`)
219  }
220  const css = `
221.g0{stop-color:#1b1a4a}.g1{stop-color:#0e1238}.edge{stroke:#c7c9f5}
222@media (prefers-color-scheme: dark){.g0{stop-color:#0b0d26}.g1{stop-color:#02030a}.edge{stroke:#26284a}}
223.w{transform-box:fill-box;transform-origin:0 50%;animation:fly linear infinite}
224@keyframes fly{0%{transform:translateX(3px) scaleX(.04);opacity:0}12%{opacity:1}100%{transform:translateX(${reach.toFixed(0)}px) scaleX(1);opacity:1}}
225.core{animation:core ${(2.6 - q * 2).toFixed(2)}s ease-in-out infinite}@keyframes core{50%{opacity:.45}}
226.fl{animation:fl 1.2s ease-out forwards}@keyframes fl{0%{opacity:.9}100%{opacity:0}}
227.ring{transform-box:fill-box;transform-origin:center;animation:ring 1.3s cubic-bezier(.2,.7,.3,1) forwards}@keyframes ring{0%{transform:scale(.1);opacity:1}100%{transform:scale(16);opacity:0}}
228.lbl{animation:lbl 5s ease-out forwards}@keyframes lbl{0%{opacity:0}10%{opacity:1}80%{opacity:1}100%{opacity:.25}}`
229  const glow = `<radialGradient id="wdCore" cx=".5" cy=".5" r=".5"><stop offset="0" stop-color="${mix('#a5b4fc', HYPER, q)}" stop-opacity="${(0.35 + q * 0.45).toFixed(2)}"/><stop offset="1" stop-color="#a5b4fc" stop-opacity="0"/></radialGradient>`
230  const parts = [
231    `<defs><linearGradient id="wdSky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" class="g0"/><stop offset="1" class="g1"/></linearGradient>${glow}<clipPath id="wdClip"><rect width="${W}" height="${H}" rx="10"/></clipPath><filter id="wdGlow" x="-20%" y="-50%" width="140%" height="200%"><feGaussianBlur stdDeviation="2.2"/></filter></defs>`,
232    `<g clip-path="url(#wdClip)">`,
233    `<rect width="${W}" height="${H}" fill="url(#wdSky)"/>`,
234    `<ellipse class="core" cx="${cx}" cy="${cy}" rx="${(40 + q * 120).toFixed(0)}" ry="${(H * 0.45).toFixed(0)}" fill="url(#wdCore)"/>`,
235    `<g transform="translate(${cx} ${cy}) scale(1 ${k.toFixed(3)})">${stars.join('')}</g>`,
236  ]
237  if (v.label && v.flashSeq > 0) {
238    parts.push(`<rect class="fl" width="${W}" height="${H}" fill="#fff8e1"/>`)
239    parts.push(`<circle class="ring" cx="${cx}" cy="${cy}" r="6" fill="none" stroke="${GOLD}" stroke-width="1.4"/>`)
240  }
241  parts.push(`</g><rect x=".5" y=".5" width="${W - 1}" height="${H - 1}" rx="10" fill="none" class="edge" stroke-opacity=".5"/>`)
242  if (v.isWorking) {
243    parts.push(svgText(12, 18, `WARP ${warpFactor(v.speed)}`, { size: 11.5, weight: 800, fill: HYPER }))
244    parts.push(svgText(W - 12, 18, `${Math.round(v.rate)} tok/s · ${fmtTok(v.tokens)} tok · ${fmtSpan(v.elapsedMs)}`, { size: 11, weight: 600, anchor: 'end', fill: '#c7d2fe', mono: true }))
245    const meter = Math.max(0, Math.min(1, v.speed))
246    parts.push(`<rect x="12" y="${H - 9}" width="${(W - 24).toFixed(0)}" height="2" rx="1" fill="#ffffff" opacity=".12"/>`)
247    parts.push(`<rect x="12" y="${H - 9}" width="${((W - 24) * meter).toFixed(0)}" height="2" rx="1" fill="${mix('#818cf8', HYPER, meter)}"/>`)
248  }
249  if (v.label) {
250    parts.push(`<g class="lbl"><text x="${cx}" y="${cy + 5}" text-anchor="middle" font-family="-apple-system,'Segoe UI',Inter,sans-serif" font-size="14" font-weight="700" fill="${GOLD}" filter="url(#wdGlow)" opacity=".7">${xml(v.label)}</text>`)
251    parts.push(`<text x="${cx}" y="${cy + 5}" text-anchor="middle" font-family="-apple-system,'Segoe UI',Inter,sans-serif" font-size="14" font-weight="700" fill="#fffbeb">${xml(v.label)}</text></g>`)
252  }
253  return svg(W, H, parts.join(''), css)
254}
255
256export function warpAlt(v: WarpView): string {
257  if (v.label) return v.label
258  return `Warp ${warpFactor(v.speed)}: ${Math.round(v.rate)} output tokens per second, ${fmtTok(v.tokens)} this turn`
259}
260
hooks/field.tsx 79 lines
1// Warpdrive's terminal scene: a star field at ~30 fps. Stars stream out of the
2// center; their speed and streak length ease toward the live token rate the
3// hooks module sends. A new jump (flashSeq) fires a ring of light. No `$` here.
4import type { ClientModule, ClientSurface } from 'claude-code'
5
6import { rng } from './lib/kz.ts'
7import { hudLine, seedStars, stepStars, warpFrame } from './warp.ts'
8import type { Star } from './warp.ts'
9
10export type FieldProps = {
11  cols: number
12  rows: number
13  speed: number
14  rate: number
15  tokens: number
16  elapsedMs: number
17  isWorking: boolean
18  label: string
19  flashSeq: number
20}
21
22type S = { t: number; stars: Star[]; sp: number; seen: number; flashAt: number }
23
24const FRAME = 33
25const FLASH_FRAMES = 22
26
27/**
28 * The first frame: seeds the stars and starts the ~30 fps flight. The timer
29 * owns the state from here on, so it keeps the current one in `s`.
30 */
31function start(surface: ClientSurface<S>, props: FieldProps): S {
32  const count = Math.max(16, Math.min(140, Math.round((props.cols * props.rows) / 4.5)))
33  let s: S = { t: 0, stars: seedStars(count, rng(7)), sp: props.speed, seen: props.label ? 0 : props.flashSeq, flashAt: -999 }
34  surface.setState(s)
35  surface.every(FRAME, () => {
36    const p = latest
37    // Ease toward the target speed: engines spool, they do not jump.
38    const target = p.isWorking ? p.speed : 0
39    const sp = s.sp + (target - s.sp) * 0.08
40    // A jump burst: a moment of extra thrust as the ring goes out.
41    const burst = s.t - s.flashAt < FLASH_FRAMES ? 0.6 * (1 - (s.t - s.flashAt) / FLASH_FRAMES) : 0
42    let { seen, flashAt } = s
43    if (p.flashSeq !== seen) {
44      seen = p.flashSeq
45      flashAt = p.flashSeq > 0 ? s.t : flashAt
46    }
47    s = { t: s.t + 1, stars: stepStars(s.stars, Math.min(1, sp + burst), rng(1000 + s.t)), sp, seen, flashAt }
48    surface.setState(s)
49  })
50  return s
51}
52
53const Field: ClientModule<FieldProps, S> = (props, surface) => {
54  const cols = Math.max(8, surface.columns || props.cols)
55  const rows = Math.max(1, surface.rows || props.rows)
56  latest = props
57  const s = surface.state ?? start(surface, props)
58  const age = s.t - s.flashAt
59  const flash = age >= 0 && age <= FLASH_FRAMES ? age / FLASH_FRAMES : -1
60  const hud = props.isWorking ? hudLine(s.sp, props.rate, props.tokens, props.elapsedMs) : ''
61  const frame = warpFrame(s.stars, cols, rows, s.sp, flash, hud, props.label)
62
63  const { Box, Text } = surface.elements
64  return (
65    <Box flexDirection="column">
66      {frame.map((runs, y) => (
67        <Text key={`r${y}`} wrap="truncate-end">
68          {runs.map((r, i) => (r.c ? <Text key={`c${i}`} color={r.c}>{r.s}</Text> : r.s))}
69        </Text>
70      ))}
71    </Box>
72  )
73}
74
75/** The props the timer reads: the last ones drawn with. */
76let latest: FieldProps = { cols: 0, rows: 0, speed: 0, rate: 0, tokens: 0, elapsedMs: 0, isWorking: false, label: '', flashSeq: 0 }
77
78export default Field
79
types/index.d.ts 25 lines
1export type WarpSnap = {
2  /** The clock when this snapshot was taken (ms). */
3  now: number
4  /** True while a main turn runs. */
5  isWorking: boolean
6  /** Output tokens per second over the last few seconds. */
7  rate: number
8  /** 0 (drift) .. 1 (hyperspace). */
9  speed: number
10  /** Output tokens this turn, subagents included. */
11  tokens: number
12  /** When the running turn began, 0 when none ran yet. */
13  turnStartedAt: number
14  /** The last jump: a turn that ended, numbered. */
15  arrival?: { seq: number; at: number; durationMs: number; tokens: number; isAborted: boolean }
16  /** Whether the band shows: working, or within a few seconds of a jump. */
17  visible: boolean
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    warpdrive: { snap: WarpSnap | null; isHidden: boolean }
23  }
24}
25