SLOPSHOPPER

tidewater

KOZMOS Tidewater: the live diff of the current turn as tides above the prompt. Every file Claude edits gets its own pool of rolling waves, a green swell for…

newbandguardcommandprompt
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/tidewater
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tidewater
› 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 › /tidewater ⎿ tidewater: Tidewater hidden. This turn: +14 −1 · 3 files. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

KOZMOS Tidewater

The live diff of the current turn, as tides above the prompt. Every file Claude edits gets its own pool of rolling waves: a green swell for the lines it added, a red ebb for the lines it removed, plus file chips and totals like +128 −42 · 6 files. The tide rises at the first edit of a turn and recedes when you type the next prompt.

  • Terminal: a Client surface module at ~30 fps. Row 1 rolls ▁▂▃▄▅▆▇█ swells per file (width by the size of its change), row 2 hangs ▔▀█ ebbs, row 3 carries ≋ +128 −42 · 6 files ● api.ts +40−12 …. A file that was just edited surges, then settles; the newest chip blinks ◉.
  • Desktop: one SVG: a totals panel and a tide pool per file, the green swell rising above a dashed waterline and the red ebb sinking below it, both rolling by CSS keyframes (phases taken from the clock, so redraws stay smooth). Works in light and dark themes.
  • How lines are counted (no shelling out): on each successful Edit, MultiEdit, Write or NotebookEdit tool call, Tidewater reads the tool's own structuredPatch and counts its +/- lines. When there is none it diffs the input: old_string vs new_string (× occurrences for replace_all), Write content vs the originalFile the tool reports (all lines added for a new file), notebook new_source vs old_source. Common head and tail lines are trimmed and the middle is diffed by LCS. Subagent edits count too; edits held for review (staged) do not.
  • ✕ or /tidewater hides it (remembered); /tidewater shows it again and reports the turn's totals.

Türkçe

O anki turun canlı farkı (diff), istemin üstünde gelgit olarak. Claude'un düzenlediği her dosyanın kendi dalga havuzu olur: eklenen satırlar için yeşil kabarma, silinenler için kırmızı çekilme, dosya etiketleri ve +128 −42 · 6 files gibi toplamlar. Gelgit turun ilk düzenlemesiyle yükselir, bir sonraki istemi yazdığınızda çekilir.

  • Terminal: ~30 fps çalışan Client modülü; dosya başına ▁▂▃▄▅▆▇█ kabarmalar, ▔▀█ çekilmeler ve etiket satırı. Yeni düzenlenen dosya kabarır.
  • Masaüstü: tek SVG; toplam paneli ve dosya başına bir havuz, CSS ile yuvarlanan yeşil/kırmızı dalgalar. Açık/koyu tema uyumlu.
  • Satır sayımı (kabuk çağrısı yok): aracın kendi structuredPatch çıktısındaki +/- satırları; yoksa girdiden: old_string/new_string, Write içeriği ile aracın bildirdiği originalFile, not defteri new_source/old_source. Ortak baş ve son satırlar atılır, orta kısım LCS ile karşılaştırılır.
  • ✕ ya da /tidewater gizler (hatırlanır); /tidewater yeniden gösterir.
Source 5 files
hooks/register.tsx 119 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { TideFile } from '../types'
5import { pxOf } from './lib/kz.ts'
6import { EDIT_TOOLS, editDelta, editPath, mergeEdit, tideAlt, tideSvg, totalsLine } from './tide.ts'
7
8const snapAtom = atom({ plugin: 'tidewater', key: 'snap' } as const, null)
9const hiddenAtom = atom({ plugin: 'tidewater', key: 'isHidden' } as const, false)
10
11// The tide since the last prompt; the band draws the snapshot in $.state.
12let files: TideFile[] = []
13
14async function setHidden($: EngineInterface, isHidden: boolean): Promise<void> {
15  await update($, hiddenAtom, () => isHidden)
16  try {
17    await $.store.set('hidden', isHidden)
18  } catch {
19    // Not persisted; this session still honours it.
20  }
21}
22
23async function publish($: EngineInterface): Promise<void> {
24  const now = await $.clock.now()
25  await update($, snapAtom, () => ({ files: files.map(f => ({ ...f })), now }))
26}
27
28async function recede($: EngineInterface): Promise<void> {
29  if (!files.length) return
30  files = []
31  await update($, snapAtom, () => null)
32}
33
34export const register: Register = on => {
35  on('session.start', async ($, e, next) => {
36    const started = await next(e)
37    files = []
38    await $.command.register({ name: 'tidewater', description: 'KOZMOS: show or hide the Tidewater band (this turn\'s diff as tides)', immediate: true })
39    try {
40      const stored = await $.store.get('hidden')
41      if (typeof stored === 'boolean') await update($, hiddenAtom, () => stored)
42    } catch {
43      // A fresh store.
44    }
45    return started
46  })
47
48  on('command.run', { command: 'tidewater' }, async $ => {
49    const isHidden = !(await read($, hiddenAtom))
50    await setHidden($, isHidden)
51    const snap = await read($, snapAtom)
52    const now = snap?.files.length ? ` This turn: ${totalsLine(snap.files)}.` : ''
53    return { text: (isHidden ? 'Tidewater hidden.' : 'Tidewater shown.') + now }
54  })
55
56  // A new prompt typed while idle starts a new tide; one delivered into a
57  // running turn (a queued message) keeps the current one.
58  on('prompt.submit', async ($, e, next) => {
59    // Should recede fail, the .catch handler passes the prompt on all the same.
60    if (e.turnId === undefined) await recede($)
61    return next(e)
62  }).catch(($, e, next) => next(e))
63
64  on('tool.call', async ($, e, next) => {
65    const ran = await next(e)
66    const tool = String(e.tool)
67    if (!EDIT_TOOLS.includes(tool) || ran.deny !== undefined || ran.isError === true) return ran
68    const path = editPath(e)
69    if (!path) return ran
70    const result = ran.result as { staged?: boolean } | undefined
71    if (result?.staged === true) return ran
72    const d = editDelta(tool, e, ran.result)
73    files = mergeEdit(files, path, d, await $.clock.now())
74    await publish($).catch(() => undefined)
75    return ran
76  }).catch(($, e, next) => next(e))
77
78  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
79    const drawn = await next(e)
80    if (e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
81    const snap = await read($, snapAtom)
82    if (!snap || snap.files.length === 0) return drawn
83
84    const ui = $.ui.resolve(e)
85    const { Box, Button } = ui
86    const hide = <Button key="tidewater-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
87    const rows = e.props.maxRows >= 10 ? 3 : 2
88
89    if ('Svg' in ui && e.surface !== 'terminal') {
90      const { Svg } = ui
91      const W = pxOf(e.props.bodyColumns) - 28
92      const H = rows >= 3 ? 84 : 68
93      return (
94        <Box flexDirection="column">
95          {drawn}
96          <Box flexDirection="row" alignItems="flex-start">
97            <Svg source={tideSvg(snap.files, W, H, snap.now)} alt={tideAlt(snap.files)} width={W} height={H} />
98            {hide}
99          </Box>
100        </Box>
101      )
102    }
103
104    // Past the Svg surfaces only the terminal is left, and it draws Client.
105    const { Client } = ui as Elements['terminal']
106    const cols = Math.max(20, (e.props.bodyColumns || 80) - 3)
107    const newest = snap.files[snap.files.length - 1]!.path
108    return (
109      <Box flexDirection="column">
110        {drawn}
111        <Box flexDirection="row">
112          <Client key="tidewater" module="./waves.tsx" width={cols} height={rows} props={{ cols, rows, files: snap.files, newest }} />
113          {hide}
114        </Box>
115      </Box>
116    )
117  })
118}
119
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/tide.ts 341 lines
1// Tidewater's pure core: how many lines an edit added and removed, the turn's
2// files, the terminal wave frame and the desktop SVG. No `$` here.
3import type { TideFile } from '../types'
4import { KZ, baseName, clamp01, fitText, hash, mix, svg, svgText } from './lib/kz.ts'
5
6export type Delta = { added: number; removed: number }
7export type { TideFile }
8export type Run = { s: string; c: string }
9
10export function lines(text: string): string[] {
11  if (!text) return []
12  const ls = text.split(/\r?\n/)
13  if (ls.length > 1 && ls[ls.length - 1] === '') ls.pop()
14  return ls
15}
16
17/**
18 * Lines added and removed going from `before` to `after`: the common head and
19 * tail are trimmed, and the middle is diffed by LCS when small enough (else
20 * counted whole, which is what a rewrite of that size is anyway).
21 */
22export function lineDelta(before: string, after: string): Delta {
23  const a = lines(before)
24  const b = lines(after)
25  let head = 0
26  while (head < a.length && head < b.length && a[head] === b[head]) head++
27  let tail = 0
28  while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
29  const ma = a.slice(head, a.length - tail)
30  const mb = b.slice(head, b.length - tail)
31  if (!ma.length || !mb.length || ma.length * mb.length > 250_000) return { added: mb.length, removed: ma.length }
32  // LCS length, one row at a time (every index read is inside the zero-filled rows).
33  let prev = new Array<number>(mb.length + 1).fill(0)
34  for (let i = 1; i <= ma.length; i++) {
35    const cur = new Array<number>(mb.length + 1).fill(0)
36    for (let j = 1; j <= mb.length; j++) {
37      cur[j] = ma[i - 1] === mb[j - 1] ? prev[j - 1]! + 1 : Math.max(prev[j]!, cur[j - 1]!)
38    }
39    prev = cur
40  }
41  const common = prev[mb.length]!
42  return { added: mb.length - common, removed: ma.length - common }
43}
44
45type Hunk = { lines?: unknown }
46
47/** Counts `+` and `-` lines of a tool's structuredPatch, or undefined when there is none. */
48export function patchDelta(patch: unknown): Delta | undefined {
49  if (!Array.isArray(patch)) return undefined
50  let added = 0
51  let removed = 0
52  for (const h of patch as Hunk[]) {
53    if (!h || !Array.isArray(h.lines)) return undefined
54    for (const l of h.lines) {
55      if (typeof l !== 'string') continue
56      if (l.startsWith('+')) added++
57      else if (l.startsWith('-')) removed++
58    }
59  }
60  return { added, removed }
61}
62
63const str = (o: Record<string, unknown>, k: string): string | undefined => (typeof o[k] === 'string' ? (o[k] as string) : undefined)
64
65/**
66 * What one Edit / MultiEdit / Write / NotebookEdit changed. The tool's own
67 * result is read first (its structuredPatch, or the original file it reports);
68 * failing that, the input: old_string vs new_string, or the written content.
69 */
70export function editDelta(tool: string, input: unknown, result: unknown): Delta {
71  const e = (input ?? {}) as Record<string, unknown>
72  const r = (result ?? {}) as Record<string, unknown>
73  const fromPatch = patchDelta(r.structuredPatch)
74  if (fromPatch && fromPatch.added + fromPatch.removed > 0) return fromPatch
75  if (tool === 'Edit') {
76    const d = lineDelta(str(e, 'old_string') ?? '', str(e, 'new_string') ?? '')
77    if (e.replace_all === true && typeof r.originalFile === 'string') {
78      const n = Math.max(1, (r.originalFile as string).split(str(e, 'old_string') || '\u0000').length - 1)
79      return { added: d.added * n, removed: d.removed * n }
80    }
81    return d
82  }
83  if (tool === 'MultiEdit' && Array.isArray(e.edits)) {
84    return (e.edits as Record<string, unknown>[]).reduce<Delta>((acc, ed) => {
85      const d = lineDelta(str(ed, 'old_string') ?? '', str(ed, 'new_string') ?? '')
86      return { added: acc.added + d.added, removed: acc.removed + d.removed }
87    }, { added: 0, removed: 0 })
88  }
89  if (tool === 'Write') {
90    const content = str(e, 'content') ?? ''
91    return lineDelta(str(r, 'originalFile') ?? '', content)
92  }
93  if (tool === 'NotebookEdit') {
94    const mode = str(e, 'edit_mode') ?? 'replace'
95    const next = mode === 'delete' ? '' : str(e, 'new_source') ?? ''
96    return lineDelta(str(r, 'old_source') ?? '', next)
97  }
98  return { added: 0, removed: 0 }
99}
100
101export const EDIT_TOOLS: readonly string[] = ['Edit', 'MultiEdit', 'Write', 'NotebookEdit']
102
103export function editPath(input: unknown): string | undefined {
104  const e = (input ?? {}) as Record<string, unknown>
105  return str(e, 'file_path') ?? str(e, 'notebook_path')
106}
107
108/** The turn's files with one more edit folded in (the most recent last). */
109export function mergeEdit(files: readonly TideFile[], path: string, d: Delta, at: number): TideFile[] {
110  const old = files.find(f => f.path === path)
111  const rest = files.filter(f => f.path !== path)
112  const merged: TideFile = old
113    ? { ...old, added: old.added + d.added, removed: old.removed + d.removed, edits: old.edits + 1, at }
114    : { path, name: baseName(path), added: d.added, removed: d.removed, edits: 1, at }
115  return [...rest, merged]
116}
117
118export function totals(files: readonly TideFile[]): { added: number; removed: number; count: number } {
119  return files.reduce((a, f) => ({ added: a.added + f.added, removed: a.removed + f.removed, count: a.count + 1 }), { added: 0, removed: 0, count: 0 })
120}
121
122/** `+128 −42 · 6 files` */
123export function totalsLine(files: readonly TideFile[]): string {
124  const t = totals(files)
125  return `+${t.added} −${t.removed} · ${t.count} ${t.count === 1 ? 'file' : 'files'}`
126}
127
128/** 0..1, log-scaled so a 3-line fix still makes a ripple beside a 300-line rewrite. */
129export function level(n: number, max: number): number {
130  if (n <= 0) return 0
131  return Math.max(0.18, clamp01(Math.log1p(n) / Math.log1p(Math.max(n, max))))
132}
133
134const CHIP = [KZ.cyan, KZ.violet, KZ.blue, KZ.magenta, KZ.teal, KZ.yellow, KZ.amber, KZ.lime]
135export function chipColor(path: string): string {
136  return CHIP[hash(path) % CHIP.length]!
137}
138
139const SEA = ['#14532d', '#15803d', '#22c55e', '#4ade80', '#86efac', '#dcfce7']
140const EBB = ['#7f1d1d', '#b91c1c', '#ef4444', '#f87171', '#fca5a5']
141const RISE = ' ▁▂▃▄▅▆▇█'
142const SINK = [' ', '▔', '▔', '▀', '▀', '▀', '█', '█', '█']
143
144/** Columns per file: by the size of its change, at least 4 each; those that do not fit fold into "more". */
145export function layout(files: readonly TideFile[], cols: number): { file: TideFile; x: number; w: number }[] {
146  const shown = [...files].reverse().slice(0, Math.max(1, Math.floor(cols / 5))).reverse()
147  const weights = shown.map(f => Math.sqrt(f.added + f.removed + 1))
148  const sum = weights.reduce((a, b) => a + b, 0) || 1
149  const free = Math.max(shown.length * 4, cols - (shown.length - 1))
150  const widths = weights.map(w => Math.max(4, Math.floor((w / sum) * free)))
151  let over = widths.reduce((a, b) => a + b, 0) + shown.length - 1 - cols
152  for (let i = widths.length - 1; over > 0 && i >= 0; i--) {
153    const take = Math.min(over, widths[i]! - 4)
154    widths[i] = widths[i]! - take
155    over -= take
156  }
157  let x = 0
158  return shown.map((file, i) => {
159    const w = widths[i]!
160    const out = { file, x, w }
161    x += w + 1
162    return out
163  })
164}
165
166/** `surge[path]` is 0..1, a fresh edit's swell that fades. */
167export function tideFrame(files: readonly TideFile[], cols: number, rows: number, t: number, surge: Readonly<Record<string, number>> = {}): Run[][] {
168  const W = Math.max(8, Math.floor(cols))
169  const segs = layout(files, W)
170  const maxA = Math.max(1, ...files.map(f => f.added))
171  const maxR = Math.max(1, ...files.map(f => f.removed))
172  const isTall = rows >= 3
173  const rowSea: Run[] = []
174  const rowEbb: Run[] = []
175  const push = (row: Run[], s: string, c: string) => {
176    const last = row[row.length - 1]
177    if (last && (last.c === c || s === ' ')) last.s += s
178    else row.push({ s, c })
179  }
180  let x = 0
181  for (const seg of segs) {
182    while (x < seg.x) {
183      push(rowSea, ' ', '')
184      if (isTall) push(rowEbb, ' ', '')
185      x++
186    }
187    const f = seg.file
188    const boost = 1 + 0.5 * (surge[f.path] ?? 0)
189    const la = level(f.added, maxA)
190    const lr = level(f.removed, maxR)
191    const phase = (hash(f.path) % 628) / 100
192    // On a single row, removals take the right end of the segment as an undertow.
193    const split = isTall ? seg.w : Math.round(seg.w * (f.added + 0.01) / (f.added + f.removed + 0.02))
194    for (let i = 0; i < seg.w; i++) {
195      const swell = 0.6 + 0.4 * Math.sin(i * 0.62 - t * 0.21 + phase) * Math.cos(i * 0.17 + t * 0.05)
196      const ebb = 0.6 + 0.4 * Math.sin(i * 0.55 + t * 0.16 + phase * 1.7)
197      if (i < split) {
198        const h = Math.round(clamp01(la * boost * swell) * 8)
199        const crest = h >= 7 && (i + Math.floor(t / 3)) % 4 === 0
200        push(rowSea, RISE.charAt(h), crest ? SEA[5]! : SEA[Math.min(4, Math.max(0, Math.floor(h / 2)))]!)
201      } else {
202        const d = Math.round(clamp01(lr * boost * ebb) * 8)
203        push(rowSea, SINK[d]!, EBB[Math.min(4, Math.floor(d / 2))]!)
204      }
205      if (isTall) {
206        const d = Math.round(clamp01(lr * boost * ebb) * 8)
207        push(rowEbb, SINK[d]!, EBB[Math.min(4, Math.floor(d / 2))]!)
208      }
209      x++
210    }
211  }
212  while (x < W) {
213    push(rowSea, ' ', '')
214    if (isTall) push(rowEbb, ' ', '')
215    x++
216  }
217  const out = [rowSea]
218  if (isTall) out.push(rowEbb)
219  return out
220}
221
222/** The chip row: totals, then `● name +a −r` per file, newest last; trimmed to `cols`. */
223export function chipRow(files: readonly TideFile[], cols: number, newest: string | undefined, t: number): Run[] {
224  const runs: Run[] = []
225  const t0 = totals(files)
226  let used = 0
227  const add = (s: string, c: string) => {
228    if (used >= cols) return
229    const room = cols - used
230    const cut = [...s].length > room ? [...s].slice(0, Math.max(0, room - 1)).join('') + '…' : s
231    used += [...cut].length
232    runs.push({ s: cut, c })
233  }
234  add('≋ ', mix(KZ.cyan, KZ.teal, (Math.sin(t / 9) + 1) / 2))
235  add(`+${t0.added}`, KZ.green)
236  add(' ', '')
237  add(`−${t0.removed}`, KZ.red)
238  add(` · ${t0.count} ${t0.count === 1 ? 'file' : 'files'}`, KZ.mist)
239  // The newest chips that fit whole beside a "+N more" for the rest; the newest
240  // always (trimmed if need be).
241  const chipW = (f: TideFile) => [...`  ● ${f.name} +${f.added}−${f.removed}`].length
242  const shown: TideFile[] = []
243  let room = cols - used
244  for (const f of [...files].reverse()) {
245    const rest = files.length - shown.length - 1
246    if (shown.length && chipW(f) + (rest > 0 ? `  +${rest} more`.length : 0) > room) break
247    shown.unshift(f)
248    room -= chipW(f)
249  }
250  const hidden = files.length - shown.length
251  for (const f of shown) {
252    add('  ', '')
253    const isNew = f.path === newest
254    add(isNew && t % 16 < 8 ? '◉ ' : '● ', chipColor(f.path))
255    add(f.name, isNew ? '#e5e7eb' : '#cbd5e1')
256    add(` +${f.added}`, KZ.green)
257    add(`−${f.removed}`, KZ.red)
258  }
259  if (hidden > 0) add(`  +${hidden} more`, KZ.mist)
260  return runs
261}
262
263// ---------------------------------------------------------------------------
264// Desktop: one SVG. A totals panel, then a tide pool per file: green swell
265// rising above the waterline, red ebb sinking below it, both rolling by CSS.
266
267function wavePath(w: number, period: number, mid: number, amp: number, up: boolean): string {
268  const pts: string[] = []
269  const span = w + period
270  for (let x = 0; x <= span; x += 3) {
271    const y = mid + (up ? -1 : 1) * amp * (0.78 + 0.22 * Math.sin((2 * Math.PI * x) / period))
272    pts.push(`${x.toFixed(1)},${y.toFixed(1)}`)
273  }
274  return `M0,${mid}L${pts.join('L')}L${span.toFixed(1)},${mid}Z`
275}
276
277export function tideSvg(files: readonly TideFile[], W: number, H: number, now: number): string {
278  const t0 = totals(files)
279  const PANEL = 116
280  const gap = 8
281  const avail = W - PANEL - gap
282  const maxCards = Math.max(1, Math.floor((avail + gap) / (84 + gap)))
283  const shown = [...files].reverse().slice(0, maxCards).reverse()
284  const cw = Math.min(170, (avail - gap * (shown.length - 1)) / shown.length)
285  const maxA = Math.max(1, ...files.map(f => f.added))
286  const maxR = Math.max(1, ...files.map(f => f.removed))
287  const newest = files[files.length - 1]?.path
288  const sec = now / 1000
289  const parts: string[] = []
290  const css = `
291.swl{animation:swl 3.2s linear infinite}@keyframes swl{to{transform:translateX(-48px)}}
292.ebb{animation:ebb 4.4s linear infinite}@keyframes ebb{to{transform:translateX(-40px)}}
293.bob{animation:bob 2.6s ease-in-out infinite}@keyframes bob{50%{transform:translateY(-1.5px)}}
294.wl{stroke:#94a3b8;stroke-opacity:.35}
295.hi{animation:hi 1.8s ease-in-out infinite}@keyframes hi{50%{stroke-opacity:.25}}`
296
297  // Totals.
298  parts.push(`<rect class="p" x="0" y="0" width="${PANEL}" height="${H}" rx="12"/>`)
299  parts.push(svgText(12, 17, '≋ TIDEWATER', { cls: 'm', size: 9.5, weight: 700 }))
300  parts.push(svgText(12, H / 2 + 6, `+${t0.added}`, { size: 19, weight: 800, fill: KZ.green }))
301  parts.push(svgText(PANEL - 10, H / 2 + 6, `−${t0.removed}`, { size: 15, weight: 750, fill: KZ.red, anchor: 'end' }))
302  parts.push(svgText(12, H - 10, `${t0.count} ${t0.count === 1 ? 'file' : 'files'} this turn`, { cls: 's', size: 10.5 }))
303
304  const top = 4
305  const poolH = H - 22
306  const mid = top + poolH / 2
307  shown.forEach((f, i) => {
308    const x = PANEL + gap + i * (cw + gap)
309    const id = `twc${i}`
310    const ha = (poolH / 2 - 3) * level(f.added, maxA)
311    const hr = (poolH / 2 - 3) * level(f.removed, maxR)
312    const isNew = f.path === newest
313    const c = chipColor(f.path)
314    const dSwl = -((sec + (hash(f.path) % 97) / 10) % 3.2)
315    const dEbb = -((sec + (hash(f.path) % 89) / 10) % 4.4)
316    parts.push(`<defs><clipPath id="${id}"><rect x="${x.toFixed(1)}" y="${top}" width="${cw.toFixed(1)}" height="${poolH}" rx="9"/></clipPath></defs>`)
317    parts.push(`<rect class="p" x="${x.toFixed(1)}" y="${top}" width="${cw.toFixed(1)}" height="${poolH}" rx="9"/>`)
318    parts.push(`<g clip-path="url(#${id})">`)
319    if (ha > 0) {
320      parts.push(`<g transform="translate(${x.toFixed(1)} 0)"><g class="bob"><path class="swl" style="animation-delay:${dSwl.toFixed(2)}s" d="${wavePath(cw, 48, mid, ha, true)}" fill="${KZ.green}" fill-opacity=".55"/>`)
321      parts.push(`<path class="swl" style="animation-delay:${(dSwl - 1.1).toFixed(2)}s;animation-duration:2.3s" d="${wavePath(cw, 48, mid, ha * 0.7, true)}" fill="#86efac" fill-opacity=".55"/></g></g>`)
322    }
323    if (hr > 0) {
324      parts.push(`<g transform="translate(${x.toFixed(1)} 0)"><path class="ebb" style="animation-delay:${dEbb.toFixed(2)}s" d="${wavePath(cw, 40, mid, hr, false)}" fill="${KZ.red}" fill-opacity=".5"/>`)
325      parts.push(`<path class="ebb" style="animation-delay:${(dEbb - 1.7).toFixed(2)}s;animation-duration:3.1s" d="${wavePath(cw, 40, mid, hr * 0.65, false)}" fill="#fca5a5" fill-opacity=".45"/></g>`)
326    }
327    parts.push(`<line class="wl" x1="${x.toFixed(1)}" x2="${(x + cw).toFixed(1)}" y1="${mid}" y2="${mid}" stroke-dasharray="3 3"/></g>`)
328    parts.push(`<rect x="${x.toFixed(1)}" y="${top}" width="${cw.toFixed(1)}" height="${poolH}" rx="9" fill="none" stroke="${c}" stroke-opacity="${isNew ? 0.9 : 0.3}" stroke-width="${isNew ? 1.6 : 1}" class="${isNew ? 'hi' : ''}"/>`)
329    parts.push(svgText(x + 6, top + 13, `+${f.added}`, { size: 10.5, weight: 700, fill: KZ.green }))
330    parts.push(svgText(x + cw - 6, top + poolH - 6, `−${f.removed}`, { size: 10.5, weight: 700, fill: KZ.red, anchor: 'end' }))
331    parts.push(`<circle cx="${(x + 6).toFixed(1)}" cy="${H - 8}" r="3" fill="${c}"/>`)
332    parts.push(svgText(x + 13, H - 4.5, fitText(f.name, 10.5, cw - 16), { cls: isNew ? 't' : 's', size: 10.5, weight: isNew ? 650 : 500, mono: true }))
333  })
334  if (files.length > shown.length) parts.push(svgText(W - 4, 14, `+${files.length - shown.length} more`, { cls: 'm', size: 9.5, anchor: 'end' }))
335  return svg(W, H, parts.join(''), css)
336}
337
338export function tideAlt(files: readonly TideFile[]): string {
339  return `This turn: ${totalsLine(files)}. ${files.map(f => `${f.name} +${f.added} −${f.removed}`).join(', ')}`
340}
341
hooks/waves.tsx 60 lines
1// Tidewater's terminal scene: rolling wave bars per file at ~30 fps, green
2// swells for added lines, red ebbs for removed ones, and the chip row. A file
3// that was just edited surges, then settles. No `$` here.
4import type { ClientModule } from 'claude-code'
5
6import { chipRow, tideFrame } from './tide.ts'
7import type { TideFile } from './tide.ts'
8
9export type WavesProps = { cols: number; rows: number; files: TideFile[]; newest: string }
10type S = { t: number; edits: Record<string, number>; surgeAt: Record<string, number> }
11
12const FRAME = 33
13const SURGE = 45
14
15let latest: WavesProps = { cols: 0, rows: 0, files: [], newest: '' }
16
17const Waves: ClientModule<WavesProps, S> = (props, surface) => {
18  latest = props
19  if (surface.state === undefined) {
20    const edits: Record<string, number> = {}
21    for (const f of props.files) edits[f.path] = f.edits
22    surface.setState({ t: 0, edits, surgeAt: {} })
23    surface.every(FRAME, () => {
24      const s = surface.state
25      if (!s) return
26      let edits = s.edits
27      let surgeAt = s.surgeAt
28      for (const f of latest.files) {
29        if ((edits[f.path] ?? 0) !== f.edits) {
30          edits = { ...edits, [f.path]: f.edits }
31          surgeAt = { ...surgeAt, [f.path]: s.t }
32        }
33      }
34      surface.setState({ t: s.t + 1, edits, surgeAt })
35    })
36  }
37  const s = surface.state ?? { t: 0, edits: {}, surgeAt: {} }
38  const cols = Math.max(10, surface.columns || props.cols)
39  const surge: Record<string, number> = {}
40  for (const [p, at] of Object.entries(s.surgeAt)) surge[p] = Math.max(0, 1 - (s.t - at) / SURGE)
41  const waves = tideFrame(props.files, cols, props.rows >= 3 ? 3 : 2, s.t, surge)
42  const chips = chipRow(props.files, cols, props.newest, s.t)
43
44  const { Box, Text } = surface.elements
45  return (
46    <Box flexDirection="column">
47      {waves.map((runs, y) => (
48        <Text key={`w${y}`} wrap="truncate-end">
49          {runs.map((r, i) => (r.c ? <Text key={`c${i}`} color={r.c}>{r.s}</Text> : r.s))}
50        </Text>
51      ))}
52      <Text key="chips" wrap="truncate-end">
53        {chips.map((r, i) => (r.c ? <Text key={`k${i}`} color={r.c}>{r.s}</Text> : r.s))}
54      </Text>
55    </Box>
56  )
57}
58
59export default Waves
60
types/index.d.ts 16 lines
1/** One file of the turn's tide: lines added and removed, edits, last edit time. */
2export type TideFile = { path: string; name: string; added: number; removed: number; edits: number; at: number }
3
4export type TideSnap = {
5  /** Files edited since the last prompt, the most recently edited last. */
6  files: TideFile[]
7  /** The clock at the last edit (ms): the desktop waves resume from it. */
8  now: number
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    tidewater: { snap: TideSnap | null; isHidden: boolean }
14  }
15}
16