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


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.
+added −removed · files.◆ 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.
Events this mod hooks, as claude plugin validate reads the module:
session.startcommand.run{command=quake}tool.callui.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.
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.
MIT
/plugin marketplace add ccdwyer/claude-mods
/plugin install diff-seismograph@ccdwyer-mods
/reload-plugins
claude plugin validate .
claude plugin test .hooks/register.tsx 465 lines1import { 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}
465hooks/seismo.ts 553 lines1// 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}
553types/index.d.ts 25 lines1// 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