SLOPSHOPPER

diff-seismograph

A live braille seismograph of every edit above the prompt, quake alerts for big changes, and a heat treemap of the repo

newpanebandguardcommandprocess
v0.2.0MITupdated 2026-10-02ccdwyer/diff-seismograph
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · diff-seismograph
│ ┃ Diff Seismograph ✕ › fix the failing auth test and add an audit log call │ ┃ ≋ SESSION SEISMOGRAPH │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ no edits yet ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ +0 −0 across 0 files · 0 edits · 0 quakes │ ┃ ▦ REPO HEAT MAP ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /quake │ ┃ ⎿ diff-seismograph: Diff Seismograph: 0 edits this session. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Diff Seismograph
≋ SESSION SEISMOGRAPH no edits yet +0 −0 across 0 files · 0 edits · 0 quakes ▦ REPO HEAT MAP
README

Diff Seismograph

Diff Seismograph demo

Band trace for small edits, a QUAKE M4.3 banner on a 150-line Write, then /quake with the repo heat map. Watch the MP4

A live seismograph of your session's edits, drawn in the terminal.

  • The band above the prompt scrolls a two-channel trace in solid block bars: additions swing up in green, removals down in red. Each edit rings out and decays like a seismometer, older samples fade like phosphor, and a faint hum keeps it alive while you work. The HUD row shows +added −removed · files.
  • Quakes: an edit of about 100 lines or more (magnitude 4.0+, Richter-style: 10 lines is M2.5, 1000 is M5.5) flashes a ◆ QUAKE M4.7 server.ts banner for a few seconds.
  • /quake opens a pane with the whole session's seismograph and a heat map of the repo: a squarified treemap of every tracked file, sized by bytes, where the files changed this session glow from purple through red and orange to white-hot. The hottest boxes are labeled with their +added −removed.
  • /quake band off hides the band; /quake band on brings it back.

Edits, Writes, notebook edits and shell commands that change files (via Claude Code's own bashEditDiff record) all count. Failed or staged edits don't. When Claude Code couldn't measure a change (a rewrite too large to diff, or a shell diff it skipped or cut short), the file still lights up and is marked as having unmeasured changes. Line counts are never invented: a change too large to diff (a 10 MB+ rewrite, a file the shell diff left unexpanded, or a git checkout/pull/reset whose diff was skipped) counts as a quake at the threshold with +0 −0, partial. A shell diff Claude Code shares across parallel calls is counted once. Backgrounded shell commands carry no diff, so they don't appear.

The band and pane draw with Raster cells, so they need a terminal; on the desktop app you get a text sparkline and per-file totals instead.

What it hooks

Events this mod hooks, as claude plugin validate reads the module:

  • session.start
  • command.run{command=quake}
  • tool.call
  • ui.render{component=AbovePrompt}
  • ui.render{component=Pane, requestId=diff-seismograph}

Engine calls it makes: $.clock.every, $.command.register, $.fs.stat (via realPath), $.process.run (via scanRepo), $.session.cwd (via scanRepo), $.state.get, $.state.set, $.ui.blit (via tick), $.ui.open, $.ui.resolve.

Its tool.call hook only observes: it reads each finished call's record of changed lines and never refuses or rewrites a call.

Privacy

It runs entirely on your machine and sends nothing over the network. It counts added and removed lines from Claude Code's own records of each edit, and runs git locally (git rev-parse and git ls-tree) only when you open /quake, to size the heat map. Counts live in the session's plugin state; no file contents are stored.

The mod collects no analytics or telemetry, and its author receives no data from it.

Full policy: PRIVACY.md.

License

MIT

Install

/plugin marketplace add ccdwyer/claude-mods
/plugin install diff-seismograph@ccdwyer-mods
/reload-plugins

Develop

claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 465 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FileHeat, MapFile, Quake, RepoMap, Totals as SessionTotals } from '../types'
5import {
6  QUAKE_AT,
7  Trace,
8  bandCells,
9  baseName,
10  countHunks,
11  countTexts,
12  lineCount,
13  magnitude,
14  sessionCells,
15  sparkline,
16  treemapCells,
17} from './seismo'
18import type { MapEntry, Totals } from './seismo'
19
20const events = atom({ plugin: 'diff-seismograph', key: 'events' } as const, [])
21const heat = atom({ plugin: 'diff-seismograph', key: 'heat' } as const, {})
22const totals = atom({ plugin: 'diff-seismograph', key: 'totals' } as const, { add: 0, del: 0, edits: 0, quakes: 0, files: 0 })
23const quake = atom({ plugin: 'diff-seismograph', key: 'quake' } as const, null)
24const map = atom({ plugin: 'diff-seismograph', key: 'map' } as const, null)
25const largest = atom({ plugin: 'diff-seismograph', key: 'largest' } as const, null)
26const band = atom({ plugin: 'diff-seismograph', key: 'band' } as const, true)
27
28const PANE = 'diff-seismograph'
29const BAND_KEY = 'seis'
30const PANE_GRAPH = 'session'
31const PANE_MAP = 'treemap'
32const MAX_EVENTS = 2000
33const MAX_FILES = 600
34// Heat keeps the hottest files; past this the coldest are dropped (the totals keep the count).
35const MAX_HEAT = 2000
36// The engine expands at most this many files of a shell diff; one it lists but did not expand was too large.
37const EXPANDED_FILES = 5
38const FRAME_MS = 80
39// How many shown frames the quake banner flashes for (about 5 seconds at 80 ms).
40const FLASH_FRAMES = 62
41const HUM_MS = 90_000
42
43// Animation lives in the module: it is cosmetic, and a reload simply starts a fresh trace.
44// `site` is set only while this plugin's band Raster is mounted and cleared whenever a
45// render does not draw it, so the clock never paints into a site that is gone.
46const trace = new Trace(256)
47let site: { requestId: string; columns: number; rows: number } | null = null
48let alert: { mag: number; file: string; framesLeft: number } | null = null
49let frame = 0
50
51/** `big`: the engine left the line counts out because the change was too large to diff. */
52type Change = { file: string; add: number; del: number; known: boolean; big?: boolean }
53/** A tool call's changes, and whether the engine said it left some out. */
54type Changes = { list: Change[]; partial: boolean }
55// Real paths already resolved this session, so a big shell diff does not stat every file every time.
56const realPaths = new Map<string, string>()
57// Shell diffs the engine attached to more than one parallel call: counted once.
58const sharedSeen = new Set<string>()
59type Record_ = Record<string, unknown>
60
61function asRecord(value: unknown): Record_ | null {
62  return value !== null && typeof value === 'object' ? (value as Record_) : null
63}
64
65/** Lines changed by a Write, from the best record the tool gave. */
66function writeChange(file: string, r: Record_): Change {
67  const patch = r.structuredPatch as { lines: string[] }[] | undefined
68  if (patch !== undefined && patch.length > 0) return { file, ...countHunks(patch), known: true }
69  const git = asRecord(r.gitDiff)
70  if (git !== null && typeof git.additions === 'number' && typeof git.deletions === 'number') {
71    return { file, add: git.additions, del: git.deletions, known: true }
72  }
73  const content = String(r.content ?? '')
74  if (r.type === 'create') return { file, add: lineCount(content), del: 0, known: true }
75  if (typeof r.originalFile === 'string') {
76    // An empty patch with the old body present: either nothing changed or the diff timed out.
77    if (r.originalFile === content) return { file, add: 0, del: 0, known: true }
78    return measured(file, r.originalFile, content)
79  }
80  // The old body (over 10 MB) was left out: a big rewrite whose line counts are unknown. Never invent them.
81  return { file, add: 0, del: 0, known: false, big: true }
82}
83
84/** A diff of two texts; a reorder too large to diff exactly is a change of unknown size, never "no change". */
85function measured(file: string, before: string, after: string): Change {
86  const c = countTexts(before, after)
87  if (c.approx === true && c.add + c.del === 0) return { file, add: 0, del: 0, known: false }
88  return { file, add: c.add, del: c.del, known: c.approx !== true }
89}
90
91/** What a finished tool call changed on disk, per file, from the tool's own record. */
92function changesOf(e: { tool: string; [k: string]: unknown }, result: unknown): Changes {
93  const none: Changes = { list: [], partial: false }
94  const r = asRecord(result)
95  if (r === null || r.staged === true) return none
96  switch (e.tool) {
97    case 'Edit':
98      return { list: [{ file: String(r.filePath ?? e.file_path ?? ''), ...countHunks(r.structuredPatch as { lines: string[] }[] | undefined), known: true }], partial: false }
99    case 'Write': {
100      const c = writeChange(String(r.filePath ?? e.file_path ?? ''), r)
101      return { list: [c], partial: !c.known }
102    }
103    case 'NotebookEdit': {
104      const c = measured(String(r.notebook_path ?? e.notebook_path ?? ''), String(r.old_source ?? ''), String(r.new_source ?? ''))
105      return { list: [c], partial: !c.known }
106    }
107    case 'Bash': {
108      const diff = asRecord(r.bashEditDiff)
109      if (diff === null) return none
110      const files = (diff.files as { filePath: string; hunks: { lines: string[] }[] }[] | undefined) ?? []
111      const out: Change[] = files.map(f => ({ file: f.filePath, ...countHunks(f.hunks), known: true }))
112      const shown = new Set(out.map(c => c.file))
113      const more = Number(diff.moreFiles ?? 0)
114      const partial = diff.skipped === true || diff.unavailable === true || more > 0
115      // A listed file the engine did not expand, while it had room to: its hunks were too large. A big change.
116      const hadRoom = files.length < EXPANDED_FILES
117      for (const path of (diff.changedFiles as string[] | undefined) ?? []) {
118        if (!shown.has(path)) out.push({ file: path, add: 0, del: 0, known: false, big: hadRoom })
119      }
120      // Files past the listed ones still count toward the file total.
121      if (more > 0) out.push({ file: `(+${more} more files)`, add: 0, del: 0, known: false })
122      // A git checkout, pull, reset and the like: the diff was skipped, and those rewrite the tree.
123      if (partial && out.length === 0) out.push({ file: '(shell command)', add: 0, del: 0, known: false, big: diff.skipped === true })
124      return { list: out, partial: partial || out.some(c => !c.known) }
125    }
126    default:
127      return none
128  }
129}
130
131/** A key for a shell diff the engine shared across parallel calls, so it is counted once. */
132function sharedKey(result: unknown): string | null {
133  const diff = asRecord(asRecord(result)?.bashEditDiff)
134  if (diff === null || diff.shared !== true) return null
135  const files = (diff.files as { filePath: string; hunks: { lines: string[] }[] }[] | undefined) ?? []
136  return JSON.stringify([files.map(f => [f.filePath, countHunks(f.hunks)]), diff.changedFiles ?? [], diff.moreFiles ?? 0])
137}
138
139/** The path a change is keyed by: its real path where it still exists, so `/tmp` and `/private/tmp` match. */
140async function realPath($: EngineInterface, path: string): Promise<string> {
141  if (!path.startsWith('/')) return path
142  const cached = realPaths.get(path)
143  if (cached !== undefined) return cached
144  try {
145    const stat = await $.fs.stat(path, { resolve: true })
146    const real = stat.realPath ?? path
147    realPaths.set(path, real)
148    return real
149  } catch {
150    return path
151  }
152}
153
154function hudOf(t: SessionTotals): Totals {
155  return { add: t.add, del: t.del, files: t.files, quakes: t.quakes }
156}
157
158/** Keeps the hottest MAX_HEAT files, so the heat map (and every edit's copy of it) stays bounded. */
159function capHeat(files: Record<string, FileHeat>): Record<string, FileHeat> {
160  const keys = Object.keys(files)
161  if (keys.length <= MAX_HEAT) return files
162  const kept = keys.sort((a, b) => weightOf(files[b] as FileHeat) - weightOf(files[a] as FileHeat)).slice(0, MAX_HEAT)
163  const out: Record<string, FileHeat> = {}
164  for (const k of kept) out[k] = files[k] as FileHeat
165  return out
166}
167
168function sameQuake(a: Quake | null, b: Quake | null): boolean {
169  return a !== null && b !== null && a.at === b.at && a.file === b.file && a.add === b.add && a.del === b.del
170}
171
172/** Halves the oldest half of the history by merging neighbours, so a long session keeps its shape in bounded space. */
173function compact(list: Quake[]): Quake[] {
174  if (list.length <= MAX_EVENTS) return list
175  const half = Math.floor(list.length / 2)
176  const merged: Quake[] = []
177  for (let i = 0; i < half; i += 2) {
178    const a = list[i]!
179    const b = list[i + 1]
180    if (b === undefined || i + 1 >= half) {
181      merged.push(a)
182      continue
183    }
184    const big = a.mag >= b.mag ? a : b
185    // A merged bucket is drawn by its summed lines; its magnitude is recomputed from them, and the
186    // largest real edit is kept separately (the `largest` atom), so a bucket is never reported as one.
187    const add = a.add + b.add
188    const del = a.del + b.del
189    merged.push({ at: a.at, file: big.file, files: a.files + b.files, add, del, mag: magnitude(add, del), partial: a.partial || b.partial })
190  }
191  return [...merged, ...list.slice(half)]
192}
193
194/** One animation frame: scroll the trace and repaint the band's Raster in place. */
195async function tick($: EngineInterface): Promise<void> {
196  const hum = Date.now() - trace.lastKick < HUM_MS
197  if (trace.settled && alert === null && !hum) return
198  const at = site
199  if (at === null) return
200  trace.step(hum)
201  frame += 1
202  const t = await read($, totals)
203  const flashing = alert !== null && alert.framesLeft > 0
204  const cells = bandCells(trace, at.columns, at.rows, hudOf(t), flashing ? alert : null, flashing && frame % 6 < 3).encode()
205  const res = await $.ui.blit({ requestId: at.requestId, key: BAND_KEY, cells, columns: at.columns, rows: at.rows })
206  if (res.deny !== undefined) {
207    if (site === at) site = null
208    return
209  }
210  // The banner only counts down while it is actually on screen.
211  if (alert !== null) {
212    alert.framesLeft -= 1
213    if (alert.framesLeft <= 0) alert = null
214  }
215}
216
217/** Tracked files of the repository the session works in, with their sizes, from one bounded git call. */
218async function scanRepo($: EngineInterface): Promise<RepoMap | null> {
219  try {
220    return await scanRepoOrThrow($)
221  } catch {
222    // git missing or too slow: the seismograph still opens, just without the heat map.
223    return null
224  }
225}
226
227async function scanRepoOrThrow($: EngineInterface): Promise<RepoMap | null> {
228  const cwd = await $.session.cwd()
229  const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 4000 })
230  if (top.exitCode !== 0) return null
231  const root = await realPath($, top.stdout.trim())
232  const tree = await $.process.run(['git', 'ls-tree', '-r', '-l', '-z', 'HEAD'], { cwd: root, timeoutMs: 8000 })
233  const files: MapFile[] = []
234  let isCut = false
235  if (tree.exitCode === 0) {
236    const entries = tree.stdout.split('\0')
237    // Every whole entry ends in NUL; the piece after the last NUL is empty, or cut off.
238    const tail = entries.pop()
239    isCut = tree.isStdoutTruncated || (tail !== undefined && tail.length > 0)
240    for (const entry of entries) {
241      // "<mode> blob <sha> <size>\t<path>"
242      const tab = entry.indexOf('\t')
243      if (tab < 0) continue
244      const meta = entry.slice(0, tab).trim().split(/\s+/)
245      if (meta[1] !== 'blob') continue
246      const bytes = Number(meta[3])
247      if (!Number.isFinite(bytes)) continue
248      files.push({ path: entry.slice(tab + 1), bytes })
249    }
250  }
251  files.sort((a, b) => b.bytes - a.bytes)
252  return { root, files: files.slice(0, MAX_FILES), more: Math.max(0, files.length - MAX_FILES), isCut, at: Date.now() }
253}
254
255function weightOf(fh: FileHeat): number {
256  return fh.add + fh.del + fh.touched
257}
258
259/** Treemap entries: the repo's files, plus touched files the scan missed (new, or cut by the size limit). */
260function entriesOf(repo: RepoMap, files: Record<string, FileHeat>): MapEntry[] {
261  let peak = 1
262  for (const fh of Object.values(files)) peak = Math.max(peak, weightOf(fh))
263  const heatOf = (fh: FileHeat | undefined) => (fh === undefined ? 0 : Math.max(0.08, Math.log10(1 + weightOf(fh)) / Math.log10(1 + peak)))
264  const seen = new Set<string>()
265  const out: MapEntry[] = []
266  for (const f of repo.files) {
267    const abs = `${repo.root}/${f.path}`
268    seen.add(abs)
269    const fh = files[abs]
270    out.push({ path: f.path, bytes: f.bytes, heat: heatOf(fh), add: fh?.add ?? 0, del: fh?.del ?? 0 })
271  }
272  for (const [abs, fh] of Object.entries(files)) {
273    if (seen.has(abs) || !abs.startsWith(`${repo.root}/`)) continue
274    out.push({ path: abs.slice(repo.root.length + 1), bytes: Math.max(200, (fh.add + 1) * 40), heat: heatOf(fh), add: fh.add, del: fh.del })
275  }
276  return out
277}
278
279function describe(ev: Quake): string {
280  const where = ev.files > 1 ? `${ev.files} files (most in ${baseName(ev.file)})` : baseName(ev.file)
281  return `M${ev.mag.toFixed(1)} ${where} (+${ev.add} −${ev.del}${ev.partial ? ', partial' : ''})`
282}
283
284export const register: Register = on => {
285  on('session.start', async ($, e, next) => {
286    await $.command.register({
287      name: 'quake',
288      description: 'Diff Seismograph: open the session seismograph and repo heat map (`/quake band off` hides the band)',
289      argumentHint: '[band on|off]',
290      immediate: true,
291    })
292    $.clock.every(FRAME_MS, () => tick($))
293    return next(e)
294  })
295
296  on('command.run', { command: 'quake' }, async ($, e) => {
297    const args = e.args.trim().toLowerCase()
298    if (args === 'band off' || args === 'band on') {
299      await update($, band, () => args === 'band on')
300      if (args === 'band off') site = null
301      return { text: `Diff Seismograph: band ${args === 'band on' ? 'on' : 'off'}.` }
302    }
303    const repo = await scanRepo($)
304    await update($, map, () => repo)
305    await $.ui.open({ id: PANE, title: 'Diff Seismograph' })
306    const t = await read($, totals)
307    return { text: `Diff Seismograph: ${t.edits} edit${t.edits === 1 ? '' : 's'} this session${repo === null ? ' (not a git repo, so no heat map)' : ''}.` }
308  })
309
310  on('tool.call', async ($, e, next) => {
311    const ran = await next(e)
312    if (ran.deny !== undefined || ran.isError === true) return ran
313    // A shell diff shared by parallel calls is the same change: count it once.
314    const shared = sharedKey(ran.result)
315    if (shared !== null) {
316      if (sharedSeen.has(shared)) return ran
317      sharedSeen.add(shared)
318      if (sharedSeen.size > 200) sharedSeen.clear()
319    }
320    const got = changesOf(e as { tool: string }, ran.result)
321    const found = got.list.filter(c => c.file.length > 0 && (c.add + c.del > 0 || !c.known))
322    if (found.length === 0) return ran
323    const changes: Change[] = []
324    for (const c of found) changes.push(c.file.startsWith('(') ? c : { ...c, file: await realPath($, c.file) })
325    const now = Date.now()
326    const add = changes.reduce((s, c) => s + c.add, 0)
327    const del = changes.reduce((s, c) => s + c.del, 0)
328    const biggest = changes.reduce((a, b) => (b.add + b.del > a.add + a.del ? b : a))
329    const partial = got.partial || changes.some(c => !c.known)
330    const isBig = changes.some(c => c.big === true)
331    // A change too large to diff counts as a quake at the threshold, with no invented line total.
332    const mag = isBig ? Math.max(QUAKE_AT, magnitude(add, del)) : magnitude(add, del)
333    const ev: Quake = { at: now, file: isBig ? (changes.find(c => c.big === true) as Change).file : biggest.file, files: changes.length, add, del, mag, partial }
334    const isQuake = ev.mag >= QUAKE_AT
335    await update($, events, cur => compact([...cur, ev]))
336    const files = await update($, heat, cur => {
337      const nextHeat: Record<string, FileHeat> = { ...cur }
338      for (const c of changes) {
339        const was = nextHeat[c.file] ?? { add: 0, del: 0, touched: 0 }
340        nextHeat[c.file] = { add: was.add + c.add, del: was.del + c.del, touched: was.touched + (c.known ? 0 : 1) }
341      }
342      return capHeat(nextHeat)
343    })
344    const fileCount = Object.keys(files).length
345    await update($, totals, cur => ({ add: cur.add + add, del: cur.del + del, edits: cur.edits + 1, quakes: cur.quakes + (isQuake ? 1 : 0), files: Math.max(cur.files ?? 0, fileCount) }))
346    await update($, largest, cur => (cur === null || add + del > cur.add + cur.del ? ev : cur))
347    trace.kick(Math.max(add, partial ? 20 : 0), del, now)
348    if (isQuake) {
349      alert = { mag: ev.mag, file: ev.files > 1 ? `${ev.files} files` : ev.file, framesLeft: FLASH_FRAMES }
350      await update($, quake, () => ev)
351    }
352    return ran
353  })
354
355  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
356    const t = await read($, totals)
357    const isOn = await read($, band)
358    if (e.props.hasSurvey || !isOn || t.edits === 0 || e.props.maxRows < 2 || e.props.bodyColumns < 20) {
359      if (site !== null && site.requestId === e.requestId) site = null
360      return next(e)
361    }
362    const hud = hudOf(t)
363    const flashingNow = alert !== null && alert.framesLeft > 0
364    if (e.surface !== 'terminal') {
365      if (site !== null && site.requestId === e.requestId) site = null
366      // One row here: the mods beneath get the rest of the band.
367      const below = await next({ ...e, props: { ...e.props, maxRows: e.props.maxRows - 1 } })
368      const list = await read($, events)
369      const { Box, Text } = $.ui.resolve(e)
370      const line = sparkline(list.map(ev => ev.add + ev.del), 40)
371      const quakeNote = flashingNow && alert !== null ? `  ◆ QUAKE M${alert.mag.toFixed(1)} ${baseName(alert.file)}` : ''
372      return (
373        <Box flexDirection="column">
374          <Text>
375            ≋ {line} +{hud.add} −{hud.del} · {hud.files} files{quakeNote}
376          </Text>
377          {below}
378        </Box>
379      )
380    }
381    const { Box, Raster } = $.ui.resolve(e)
382    const columns = Math.min(512, e.props.bodyColumns)
383    const rows = e.props.maxRows >= 4 ? 3 : 2
384    // Reserve this band's rows before the mods beneath draw, so together they fit.
385    const below = await next({ ...e, props: { ...e.props, maxRows: e.props.maxRows - rows } })
386    site = { requestId: e.requestId, columns, rows }
387    trace.resize(columns)
388    const flashing = alert !== null && alert.framesLeft > 0
389    const cells = bandCells(trace, columns, rows, hud, flashing ? alert : null, flashing).encode()
390    return (
391      <Box flexDirection="column">
392        <Raster key={BAND_KEY} columns={columns} rows={rows} cells={cells} />
393        {below}
394      </Box>
395    )
396  })
397
398  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
399    const list = await read($, events)
400    const files = await read($, heat)
401    const repo = await read($, map)
402    const latest = await read($, quake)
403    const t = await read($, totals)
404    const biggest = await read($, largest)
405    const hottest = Object.entries(files)
406      .sort((a, b) => weightOf(b[1]) - weightOf(a[1]))
407      .slice(0, 6)
408    const columns = Math.max(30, Math.min(512, e.props.bodyColumns))
409    const viewRows = e.viewport?.rows ?? 40
410    const fileCount = Object.keys(files).length
411    const summary = `+${t.add} −${t.del} across ${fileCount} file${fileCount === 1 ? '' : 's'} · ${t.edits} edit${t.edits === 1 ? '' : 's'} · ${t.quakes} quake${t.quakes === 1 ? '' : 's'}`
412    const largestLine = biggest === null ? '' : `largest: ${describe(biggest)}`
413    const latestLine = latest === null || sameQuake(latest, biggest) ? '' : `latest quake: ${describe(latest)}`
414    const fileLine = (file: string, fh: FileHeat) => `${baseName(file)} +${fh.add} −${fh.del}${fh.touched > 0 ? ' (+ unmeasured changes)' : ''}`
415    if (e.surface !== 'terminal') {
416      const { Box, Text } = $.ui.resolve(e)
417      return (
418        <Box flexDirection="column">
419          <Text bold>Session seismograph</Text>
420          <Text>{sparkline(list.map(ev => ev.add + ev.del), Math.min(80, columns))}</Text>
421          <Text>{summary}</Text>
422          {largestLine.length > 0 && <Text>{largestLine}</Text>}
423          {latestLine.length > 0 && <Text>{latestLine}</Text>}
424          {hottest.map(([file, fh]) => (
425            <Text key={file}>{fileLine(file, fh)}</Text>
426          ))}
427        </Box>
428      )
429    }
430    const { Box, Text, Raster } = $.ui.resolve(e)
431    const graphRows = 6
432    const mapRows = Math.max(6, Math.min(40, viewRows - graphRows - 11))
433    const graph = sessionCells(list, columns, graphRows).encode()
434    return (
435      <Box flexDirection="column">
436        <Text bold color="cyan">
437          ≋ SESSION SEISMOGRAPH
438        </Text>
439        <Raster key={PANE_GRAPH} columns={columns} rows={graphRows} cells={graph} />
440        <Text>
441          <Text color="green">+{t.add}</Text> <Text color="red">−{t.del}</Text>
442          <Text dimColor>
443            {' '}
444            across {fileCount} file{fileCount === 1 ? '' : 's'} · {t.edits} edit{t.edits === 1 ? '' : 's'} · {t.quakes} quake
445            {t.quakes === 1 ? '' : 's'}
446          </Text>
447        </Text>
448        {largestLine.length > 0 && <Text color="red">◆ {largestLine}</Text>}
449        {latestLine.length > 0 && <Text color="yellow">◇ {latestLine}</Text>}
450        <Text bold color="cyan">
451          ▦ REPO HEAT MAP
452        </Text>
453        {repo === null ? (
454          <Text dimColor>Not a git repository, or git is unavailable: no heat map.</Text>
455        ) : (
456          <Raster key={PANE_MAP} columns={columns} rows={mapRows} cells={treemapCells(entriesOf(repo, files), columns, mapRows).encode()} />
457        )}
458        {repo !== null && repo.isCut && <Text dimColor>The file listing was cut off: the map shows part of the repo.</Text>}
459        {repo !== null && repo.more > 0 && <Text dimColor>Largest {repo.files.length} files shown; {repo.more} smaller ones left out.</Text>}
460        {hottest.length > 0 && <Text dimColor>hottest: {hottest.map(([file, fh]) => fileLine(file, fh)).join('  ')}</Text>}
461      </Box>
462    )
463  })
464}
465
hooks/seismo.ts 553 lines
1// Pure drawing and counting for Diff Seismograph: no engine calls in this file.
2
3export const DEFAULT = 0x01000000
4
5type Hunk = { lines: readonly string[] }
6
7/** Lines added and removed in a set of unified-diff hunks. */
8export function countHunks(hunks: readonly Hunk[] | undefined): { add: number; del: number } {
9  let add = 0
10  let del = 0
11  for (const hunk of hunks ?? []) {
12    for (const line of hunk.lines) {
13      if (line.startsWith('+')) add += 1
14      else if (line.startsWith('-')) del += 1
15    }
16  }
17  return { add, del }
18}
19
20export function lineCount(text: string | undefined): number {
21  return text === undefined ? 0 : linesOf(text).length
22}
23
24function linesOf(text: string): string[] {
25  if (text.length === 0) return []
26  const lines = text.split('\n')
27  if (lines[lines.length - 1] === '') lines.pop()
28  return lines
29}
30
31const LCS_LIMIT = 4_000_000
32
33/**
34 * Lines added and removed between two texts, order-sensitive (a longest-common-subsequence
35 * diff), so reordering lines counts as a change. Very large inputs fall back to a multiset
36 * count, which can only under-count reorders.
37 */
38/** Lines added and removed. `approx` is set when the texts were too large for an exact, order-aware diff. */
39export function countTexts(before: string, after: string): { add: number; del: number; approx?: true } {
40  const a = linesOf(before)
41  const b = linesOf(after)
42  // Trim the common head and tail first: most edits touch a small middle.
43  let start = 0
44  while (start < a.length && start < b.length && a[start] === b[start]) start += 1
45  let endA = a.length
46  let endB = b.length
47  while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
48    endA -= 1
49    endB -= 1
50  }
51  const midA = a.slice(start, endA)
52  const midB = b.slice(start, endB)
53  if (midA.length === 0 || midB.length === 0) return { add: midB.length, del: midA.length }
54  if (midA.length * midB.length > LCS_LIMIT) {
55    const seen = new Map<string, number>()
56    for (const line of midA) seen.set(line, (seen.get(line) ?? 0) + 1)
57    let common = 0
58    for (const line of midB) {
59      const n = seen.get(line) ?? 0
60      if (n > 0) {
61        seen.set(line, n - 1)
62        common += 1
63      }
64    }
65    return { add: midB.length - common, del: midA.length - common, approx: true }
66  }
67  let prev = new Uint32Array(midB.length + 1)
68  let cur = new Uint32Array(midB.length + 1)
69  for (let i = 1; i <= midA.length; i += 1) {
70    for (let j = 1; j <= midB.length; j += 1) {
71      cur[j] = midA[i - 1] === midB[j - 1] ? (prev[j - 1] as number) + 1 : Math.max(prev[j] as number, cur[j - 1] as number)
72    }
73    const t = prev
74    prev = cur
75    cur = t
76  }
77  const common = prev[midB.length] as number
78  return { add: midB.length - common, del: midA.length - common }
79}
80
81/** Richter-style magnitude of a change: 10 lines is M2.5, 100 is M4.0, 1000 is M5.5. */
82export function magnitude(add: number, del: number): number {
83  const total = add + del
84  if (total <= 0) return 0
85  return Math.round((1 + 1.5 * Math.log10(total)) * 10) / 10
86}
87
88export const QUAKE_AT = 4
89
90// ---- cell packing ----
91
92const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
93
94/** Standard padded base64 of a byte array; no reliance on the environment's encoders. */
95export function base64(bytes: Uint8Array): string {
96  let out = ''
97  let i = 0
98  for (; i + 2 < bytes.length; i += 3) {
99    const n = ((bytes[i] as number) << 16) | ((bytes[i + 1] as number) << 8) | (bytes[i + 2] as number)
100    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
101  }
102  const rest = bytes.length - i
103  if (rest === 1) {
104    const n = (bytes[i] as number) << 16
105    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
106  } else if (rest === 2) {
107    const n = ((bytes[i] as number) << 16) | ((bytes[i + 1] as number) << 8)
108    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
109  }
110  return out
111}
112
113/** A grid of `[codePoint, fg, bg]` cells, row-major. */
114export class Grid {
115  readonly cells: Uint32Array
116  constructor(readonly columns: number, readonly rows: number) {
117    this.cells = new Uint32Array(columns * rows * 3)
118    for (let i = 0; i < columns * rows; i += 1) {
119      this.cells[i * 3] = 0x20
120      this.cells[i * 3 + 1] = DEFAULT
121      this.cells[i * 3 + 2] = DEFAULT
122    }
123  }
124
125  set(x: number, y: number, code: number, fg: number, bg: number = DEFAULT): void {
126    if (x < 0 || y < 0 || x >= this.columns || y >= this.rows) return
127    const i = (y * this.columns + x) * 3
128    this.cells[i] = code
129    this.cells[i + 1] = fg
130    this.cells[i + 2] = bg
131  }
132
133  bg(x: number, y: number, bg: number): void {
134    if (x < 0 || y < 0 || x >= this.columns || y >= this.rows) return
135    this.cells[(y * this.columns + x) * 3 + 2] = bg
136  }
137
138  /** Writes text left to right, one printable width-1 character per cell; anything else becomes '?'. */
139  text(x: number, y: number, text: string, fg: number, bg: number = DEFAULT): number {
140    let col = x
141    for (const ch of text) {
142      if (col >= this.columns) break
143      this.set(col, y, safeCode(ch), fg, bg)
144      col += 1
145    }
146    return col
147  }
148
149  encode(): string {
150    // The engine reads little-endian u32s; build the bytes explicitly so the host's byte order never matters.
151    const bytes = new Uint8Array(this.cells.length * 4)
152    for (let i = 0; i < this.cells.length; i += 1) {
153      const v = this.cells[i] as number
154      bytes[i * 4] = v & 0xff
155      bytes[i * 4 + 1] = (v >>> 8) & 0xff
156      bytes[i * 4 + 2] = (v >>> 16) & 0xff
157      bytes[i * 4 + 3] = (v >>> 24) & 0xff
158    }
159    return base64(bytes)
160  }
161}
162
163/** A code point the Raster accepts: printable, width 1, in the BMP. */
164export function safeCode(ch: string): number {
165  const code = ch.codePointAt(0) ?? 0x3f
166  // A conservative allowlist of characters every terminal draws in exactly one cell: ASCII,
167  // Latin, Greek and Cyrillic letters (no combining marks), common punctuation, arrows,
168  // math, box drawing, blocks, geometric shapes and braille. Anything else draws as "?".
169  const ranges: Array<[number, number]> = [
170    [0x20, 0x7e], [0xa0, 0xac], [0xae, 0x2ff], [0x370, 0x482], [0x48a, 0x52f],
171    [0x2010, 0x2027], [0x2030, 0x205e], [0x20a0, 0x20bf], [0x2100, 0x214f], [0x2190, 0x21ff],
172    [0x2200, 0x22ff], [0x2500, 0x25ff], [0x2800, 0x28ff],
173  ]
174  for (const [lo, hi] of ranges) if (code >= lo && code <= hi) return code
175  return 0x3f
176}
177
178// ---- color ----
179
180export function rgb(r: number, g: number, b: number): number {
181  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
182  return (c(r) << 16) | (c(g) << 8) | c(b)
183}
184
185function scale(color: number, f: number): number {
186  return rgb(((color >> 16) & 0xff) * f, ((color >> 8) & 0xff) * f, (color & 0xff) * f)
187}
188
189function mix(a: number, b: number, t: number): number {
190  const ch = (c: number, s: number) => ((c >> s) & 0xff) * (1 - t) + ((b >> s) & 0xff) * t
191  return rgb(ch(a, 16), ch(a, 8), ch(a, 0))
192}
193
194const HEAT = [0x2b1055, 0x7b1fa2, 0xd32f2f, 0xff6f00, 0xffd600, 0xfffde0]
195
196/** Heat palette from cold purple (0) to white-hot (1). */
197export function heatColor(t: number): number {
198  const x = Math.max(0, Math.min(1, t)) * (HEAT.length - 1)
199  const i = Math.min(HEAT.length - 2, Math.floor(x))
200  return mix(HEAT[i] as number, HEAT[i + 1] as number, x - i)
201}
202
203export const GREEN = 0x39ff88
204export const RED = 0xff3b5c
205export const AMBER = 0xffc040
206export const CYAN = 0x34e2e2
207export const DIM = 0x4a5568
208
209// ---- block bars ----
210
211const EIGHTHS = [0x20, 0x2581, 0x2582, 0x2583, 0x2584, 0x2585, 0x2586, 0x2587, 0x2588]
212
213function glow(color: number, age: number, isPeak: boolean): number {
214  const base = scale(color, age)
215  return isPeak ? mix(base, 0xffffff, 0.35 * age) : base
216}
217
218/**
219 * Solid bars around a baseline: `upRows` rows above it filled from the bottom in eighths
220 * (additions, green) and `downRows` rows below it filled from the top in halves (removals,
221 * red). One sample per column; the right edge is newest and brightest.
222 */
223export function barsInto(grid: Grid, top: number, upRows: number, downRows: number, up: ArrayLike<number>, down: ArrayLike<number>, fade: boolean): void {
224  const n = grid.columns
225  const offset = up.length - n
226  for (let x = 0; x < n; x += 1) {
227    const i = x + offset
228    const u = i < 0 ? 0 : Math.max(0, Math.min(1, up[i] as number))
229    const d = i < 0 ? 0 : Math.max(0, Math.min(1, down[i] as number))
230    const age = fade ? 0.3 + 0.7 * (x / Math.max(1, n - 1)) ** 1.4 : 1
231    if (downRows === 0) {
232      // Compact: one row carries both channels, the stronger one wins the cell.
233      const v = Math.max(u, d)
234      const k = Math.round(v * 8)
235      if (k === 0) grid.set(x, top, x % 3 === 0 ? 0x2581 : 0x20, 0x16202b)
236      else grid.set(x, top, EIGHTHS[k] as number, glow(d > u ? RED : GREEN, age, true))
237      continue
238    }
239    // Up: eighths from the baseline upward.
240    let eighths = Math.round(u * upRows * 8)
241    for (let r = 0; r < upRows; r += 1) {
242      const y = top + upRows - 1 - r
243      const k = Math.max(0, Math.min(8, eighths))
244      eighths -= 8
245      const isPeak = k > 0 && (k < 8 || eighths <= 0)
246      if (k === 0) grid.set(x, y, r === 0 && x % 3 === 0 ? 0x2581 : 0x20, r === 0 ? 0x16202b : DEFAULT)
247      else grid.set(x, y, EIGHTHS[k] as number, glow(GREEN, age, isPeak))
248    }
249    // Down: halves from the baseline downward.
250    let halves = Math.round(d * downRows * 2)
251    for (let r = 0; r < downRows; r += 1) {
252      const y = top + upRows + r
253      const k = Math.max(0, Math.min(2, halves))
254      halves -= 2
255      const isPeak = k > 0 && (k < 2 || halves <= 0)
256      if (k === 0) grid.set(x, y, 0x20, DEFAULT)
257      else grid.set(x, y, k === 2 ? 0x2588 : 0x2580, glow(RED, age, isPeak))
258    }
259  }
260}
261
262// ---- the live trace ----
263
264/** Normalizes a line count to 0..1: 1 line barely moves the needle, 1000 lines is full scale. */
265export function kickSize(lines: number): number {
266  if (lines <= 0) return 0
267  return Math.min(1, 0.4 + Math.log10(1 + lines) / 3.4)
268}
269
270/**
271 * A scrolling two-channel trace: additions swing up, removals swing down, and each kick rings
272 * out like a seismometer (damped oscillation) before settling to a faint hum.
273 */
274export class Trace {
275  up: Float32Array
276  down: Float32Array
277  private energyUp = 0
278  private energyDown = 0
279  private phase = 0
280  private seed = 0x9e3779b9
281  lastKick = 0
282
283  constructor(width: number) {
284    this.up = new Float32Array(width)
285    this.down = new Float32Array(width)
286  }
287
288  resize(width: number): void {
289    if (width === this.up.length) return
290    const up = new Float32Array(width)
291    const down = new Float32Array(width)
292    const n = Math.min(width, this.up.length)
293    up.set(this.up.subarray(this.up.length - n), width - n)
294    down.set(this.down.subarray(this.down.length - n), width - n)
295    this.up = up
296    this.down = down
297  }
298
299  kick(add: number, del: number, now: number): void {
300    this.energyUp = Math.min(1, this.energyUp + kickSize(add))
301    this.energyDown = Math.min(1, this.energyDown + kickSize(del))
302    this.lastKick = now
303  }
304
305  /** True when there is nothing left moving: no energy and an all-quiet buffer. */
306  get settled(): boolean {
307    if (this.energyUp > 0.002 || this.energyDown > 0.002) return false
308    for (let i = 0; i < this.up.length; i += 1) if ((this.up[i] as number) > 0.06 || (this.down[i] as number) > 0.06) return false
309    return true
310  }
311
312  private noise(): number {
313    // xorshift32: deterministic jitter, no Math.random needed.
314    let x = this.seed
315    x ^= x << 13
316    x ^= x >>> 17
317    x ^= x << 5
318    this.seed = x >>> 0
319    return (this.seed % 1000) / 1000
320  }
321
322  /** Advances one sample: everything scrolls left one dot and a new sample enters on the right. */
323  step(hum: boolean): void {
324    const w = this.up.length
325    this.up.copyWithin(0, 1)
326    this.down.copyWithin(0, 1)
327    // A ringing envelope: big swings that wobble as they die out, plus a faint live hum.
328    const ring = 0.55 + 0.45 * Math.abs(Math.sin(this.phase)) * (0.7 + 0.3 * this.noise())
329    this.up[w - 1] = Math.min(1, this.energyUp * ring + (hum ? 0.05 + 0.1 * this.noise() : 0))
330    this.down[w - 1] = Math.min(1, this.energyDown * ring + (hum ? 0.05 + 0.1 * this.noise() : 0))
331    this.energyUp *= 0.95
332    this.energyDown *= 0.95
333    if (this.energyUp < 0.004) this.energyUp = 0
334    if (this.energyDown < 0.004) this.energyDown = 0
335    this.phase += 0.9
336  }
337
338}
339
340export type Totals = { add: number; del: number; files: number; quakes: number }
341
342/** The band: one HUD row over a braille trace. `flash` alternates the quake banner's colors. */
343export function bandCells(
344  trace: Trace,
345  columns: number,
346  rows: number,
347  totals: Totals,
348  quake: { mag: number; file: string } | null,
349  flash: boolean,
350): Grid {
351  const grid = new Grid(columns, rows)
352  let x = grid.text(0, 0, '≋ SEISMO ', CYAN)
353  x = grid.text(x, 0, `+${totals.add}`, GREEN)
354  x = grid.text(x + 1, 0, `−${totals.del}`, RED)
355  x = grid.text(x + 1, 0, `· ${totals.files} file${totals.files === 1 ? '' : 's'}`, DIM)
356  if (quake !== null) {
357    const name = baseName(quake.file)
358    const banner = ` ◆ QUAKE M${quake.mag.toFixed(1)} ${name} `
359    // The alert wins the row: it overwrites the stats on a narrow band rather than being cut off.
360    const start = Math.max(0, columns - banner.length)
361    const fg = flash ? 0x000000 : RED
362    const bg = flash ? RED : DEFAULT
363    for (let i = start; i < columns; i += 1) grid.set(i, 0, 0x20, DEFAULT)
364    grid.text(start, 0, banner.slice(0, columns - start), fg, bg)
365  } else if (totals.quakes > 0) {
366    const note = `${totals.quakes} quake${totals.quakes === 1 ? '' : 's'}`
367    grid.text(Math.max(x + 2, columns - note.length), 0, note, DIM)
368  }
369  const body = rows - 1
370  if (body <= 0) return grid
371  if (body === 1) {
372    barsInto(grid, 1, 1, 0, trace.up, trace.down, true)
373    return grid
374  }
375  const upRows = Math.ceil(body / 2)
376  barsInto(grid, 1, upRows, body - upRows, trace.up, trace.down, true)
377  return grid
378}
379
380export function baseName(path: string): string {
381  const parts = path.split(/[\\/]/)
382  return parts[parts.length - 1] || path
383}
384
385// ---- the session graph ----
386
387/** The whole session as stacked bars: additions up, removals down, log-scaled, oldest at the left. */
388export function sessionCells(
389  events: readonly { at: number; add: number; del: number }[],
390  columns: number,
391  rows: number,
392): Grid {
393  const grid = new Grid(columns, rows)
394  if (events.length === 0) {
395    grid.text(1, Math.floor(rows / 2), 'no edits yet', DIM)
396    return grid
397  }
398  const start = events[0]!.at
399  const end = Math.max(events[events.length - 1]!.at, start + 1)
400  const up = new Float32Array(columns)
401  const down = new Float32Array(columns)
402  events.forEach((ev, k) => {
403    // Few enough events: one column each, in order, so a burst sharing a timestamp still spreads out.
404    const x =
405      events.length <= columns
406        ? Math.round((k / Math.max(1, events.length - 1)) * (columns - 1))
407        : Math.min(columns - 1, Math.floor((k * columns) / events.length))
408    up[x] = (up[x] as number) + ev.add
409    down[x] = (down[x] as number) + ev.del
410  })
411  let peak = 1
412  for (let i = 0; i < columns; i += 1) peak = Math.max(peak, up[i] as number, down[i] as number)
413  const norm = (v: number) => Math.log10(1 + v) / Math.log10(1 + peak)
414  const upRows = Math.max(1, Math.ceil(rows / 2))
415  barsInto(grid, 0, upRows, Math.max(1, rows - upRows), up.map(norm), down.map(norm), false)
416  return grid
417}
418
419// ---- the treemap ----
420
421export type Rect = { x: number; y: number; w: number; h: number }
422export type Placed<T> = { item: T; rect: Rect }
423
424/** Squarified treemap (Bruls, Huizing, van Wijk) of positive weights into a rectangle. */
425export function squarify<T extends { weight: number }>(items: readonly T[], box: Rect): Placed<T>[] {
426  const sorted = items.filter(i => i.weight > 0).sort((a, b) => b.weight - a.weight)
427  const total = sorted.reduce((s, i) => s + i.weight, 0)
428  if (total <= 0 || box.w <= 0 || box.h <= 0) return []
429  const scaleArea = (box.w * box.h) / total
430  const out: Placed<T>[] = []
431  let rest = { ...box }
432  let row: T[] = []
433  const worst = (r: T[], side: number) => {
434    const areas = r.map(i => i.weight * scaleArea)
435    const sum = areas.reduce((a, b) => a + b, 0)
436    const max = Math.max(...areas)
437    const min = Math.min(...areas)
438    return Math.max((side * side * max) / (sum * sum), (sum * sum) / (side * side * min))
439  }
440  const layout = (r: T[]) => {
441    const areas = r.map(i => i.weight * scaleArea)
442    const sum = areas.reduce((a, b) => a + b, 0)
443    if (rest.w >= rest.h) {
444      const w = sum / rest.h
445      let y = rest.y
446      r.forEach((item, k) => {
447        const h = (areas[k] as number) / w
448        out.push({ item, rect: { x: rest.x, y, w, h } })
449        y += h
450      })
451      rest = { x: rest.x + w, y: rest.y, w: rest.w - w, h: rest.h }
452    } else {
453      const h = sum / rest.w
454      let x = rest.x
455      r.forEach((item, k) => {
456        const w = (areas[k] as number) / h
457        out.push({ item, rect: { x, y: rest.y, w, h } })
458        x += w
459      })
460      rest = { x: rest.x, y: rest.y + h, w: rest.w, h: rest.h - h }
461    }
462  }
463  for (const item of sorted) {
464    const side = Math.min(rest.w, rest.h)
465    if (row.length === 0 || worst([...row, item], side) <= worst(row, side)) {
466      row.push(item)
467    } else {
468      layout(row)
469      row = [item]
470    }
471  }
472  if (row.length > 0) layout(row)
473  return out
474}
475
476export type MapEntry = { path: string; bytes: number; heat: number; add: number; del: number }
477
478/**
479 * The treemap grid: every tracked file sized by bytes (softened, so small files still show),
480 * touched files filled with heat colors, untouched ones in two dark tones so borders read,
481 * and the hottest files labeled where their box is wide enough.
482 */
483export function treemapCells(entries: readonly MapEntry[], columns: number, rows: number): Grid {
484  const grid = new Grid(columns, rows)
485  // Each cell is one column wide and roughly two "units" tall on screen, so lay out in a
486  // box twice as tall as the row count and halve when rasterizing: rectangles look square.
487  // Keep only as many files as the grid can show: hot files first, then the largest.
488  const room = Math.max(1, Math.floor((columns * rows) / 3))
489  const kept = [...entries].sort((a, b) => (b.heat > 0 ? 1 : 0) - (a.heat > 0 ? 1 : 0) || b.bytes - a.bytes).slice(0, room)
490  const items = kept.map(e => ({ ...e, weight: Math.max(1, e.bytes) ** 0.6 }))
491  const placed = squarify(items, { x: 0, y: 0, w: columns, h: rows * 2 })
492  const owner = new Int32Array(columns * rows).fill(-1)
493  // Smallest first, so where rounding overlaps, the larger file keeps the cell.
494  const order = placed.map((_, k) => k).reverse()
495  order.forEach(k => {
496    const p = placed[k]!
497    const x0 = Math.round(p.rect.x)
498    const x1 = Math.max(x0 + 1, Math.round(p.rect.x + p.rect.w))
499    const y0 = Math.round(p.rect.y / 2)
500    const y1 = Math.max(y0 + 1, Math.round((p.rect.y + p.rect.h) / 2))
501    for (let y = y0; y < Math.min(rows, y1); y += 1) for (let x = x0; x < Math.min(columns, x1); x += 1) owner[y * columns + x] = k
502  })
503  const hottest = new Set(
504    placed
505      .map((p, k) => ({ k, heat: p.item.heat }))
506      .filter(p => p.heat > 0)
507      .sort((a, b) => b.heat - a.heat)
508      .slice(0, 10)
509      .map(p => p.k),
510  )
511  for (let y = 0; y < rows; y += 1) {
512    for (let x = 0; x < columns; x += 1) {
513      const k = owner[y * columns + x] as number
514      if (k < 0) continue
515      const item = placed[k]!.item
516      const edge = x === 0 || owner[y * columns + x - 1] !== k
517      const bg = item.heat > 0 ? heatColor(0.15 + 0.85 * item.heat) : k % 2 === 0 ? 0x161b22 : 0x1f2630
518      grid.set(x, y, edge && item.heat === 0 ? 0x258f : 0x20, 0x0b0e14, bg)
519    }
520  }
521  // Labels: top-left of each hot (or large) box that has room for at least a few characters.
522  placed.forEach((p, k) => {
523    const x0 = Math.round(p.rect.x)
524    const width = Math.round(p.rect.x + p.rect.w) - x0
525    const y0 = Math.round(p.rect.y / 2)
526    const height = Math.round((p.rect.y + p.rect.h) / 2) - y0
527    if (width < 4 || height < 1) return
528    const isHot = hottest.has(k)
529    if (!isHot && (width < 10 || height < 2)) return
530    const name = baseName(p.item.path)
531    const label = name.length > width - 1 ? name.slice(0, width - 2) + '…' : name
532    const bg = p.item.heat > 0 ? heatColor(0.15 + 0.85 * p.item.heat) : k % 2 === 0 ? 0x161b22 : 0x1f2630
533    const fg = p.item.heat > 0.55 ? 0x0b0e14 : p.item.heat > 0 ? 0xffffff : 0x8b95a5
534    grid.text(x0 + 1, y0, label, fg, bg)
535    if (isHot && height >= 2 && width >= 8) {
536      const delta = `+${p.item.add} −${p.item.del}`
537      grid.text(x0 + 1, y0 + 1, delta.slice(0, width - 1), fg, bg)
538    }
539  })
540  return grid
541}
542
543/** A text sparkline, for surfaces with no Raster. */
544export function sparkline(values: readonly number[], width: number): string {
545  const blocks = '▁▂▃▄▅▆▇█'
546  if (values.length === 0) return ''
547  const per = Math.max(1, Math.ceil(values.length / width))
548  const buckets: number[] = []
549  for (let i = 0; i < values.length; i += per) buckets.push(values.slice(i, i + per).reduce((a, b) => a + b, 0))
550  const peak = Math.max(1, ...buckets)
551  return buckets.map(v => blocks[Math.min(7, Math.floor((Math.log10(1 + v) / Math.log10(1 + peak)) * 7.99))]).join('')
552}
553
types/index.d.ts 25 lines
1// One recorded tool call: when, the file that changed most, lines added and removed across
2// every file it changed, how many files, and whether the line counts are partial.
3export type Quake = { at: number; file: string; files: number; add: number; del: number; mag: number; partial: boolean }
4// Per-file totals for the session. `touched` counts changes whose line counts were not available.
5export type FileHeat = { add: number; del: number; touched: number }
6// Running session totals, kept separately so trimming the event history never lowers them.
7export type Totals = { add: number; del: number; edits: number; quakes: number; files: number }
8// One tracked file of the repo for the treemap: path relative to the repo root and its size in bytes.
9export type MapFile = { path: string; bytes: number }
10export type RepoMap = { root: string; files: MapFile[]; more: number; isCut: boolean; at: number }
11
12declare module 'claude-code' {
13  interface PluginState {
14    'diff-seismograph': {
15      events: Quake[]
16      heat: Record<string, FileHeat>
17      totals: Totals
18      quake: Quake | null
19      map: RepoMap | null
20      largest: Quake | null
21      band: boolean
22    }
23  }
24}
25