SLOPSHOPPER

repo-heatmap

Watch where Claude looks: a live treemap of the repo that lights up as files are read, searched and edited.

newpaneguardcommandprocesstimer
★ 11v0.1.0NOASSERTIONupdated 2026-10-02promptadvisers/claude-mods-starter-kit/plugins/repo-heatmap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · repo-heatmap
│ ┃ Repo Heatmap ✕ › fix the failing auth test and add an audit log call │ ┃ REPO HEATMAP 2 files · sized by lines │ ┃ src 2/2▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Read(src/auth.ts) │ ┃ ▀cache.ts▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀audit.ts▀▀▀ ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /heatmap │ ┃ ⎿ repo-heatmap: Repo Heatmap open: scanning files, sized by lines. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Repo Heatmap
REPO HEATMAP 2 files · sized by lines src 2/2▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀cache.ts▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀audit.ts▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ■ Read ■ Search ■ Edit ■ Failed ■ Visited touched 2 of 2 files · 2 edited · hottest: src/cache.ts recently touched ● edit src/cache.ts ● edit src/audit.ts
README

Repo Heatmap

Draws a map of files in the current project. File areas reflect size, and colors react to reads, searches, edits and failed operations.

See the complete guide and creation prompt.

Test from the kit root:

claude plugin validate plugins/repo-heatmap
claude plugin test plugins/repo-heatmap
Source 2 files
hooks/repo-heatmap.mjs 1294 lines
1// Repo Heatmap: watch where Claude looks.
2//
3// A squarified treemap of the repository in a side pane: every file is a
4// rectangle sized by its line count (or bytes), grouped by folder. Files light
5// up as Claude touches them (cyan for Read, yellow for search hits, coral for
6// Edit/Write, a red flash for a failed edit) and the glow decays over ~20s into
7// a faint "visited" tint, so the session's footprint stays on the map.
8//
9// Drawing: one Raster, two pixels per cell (upper half block, fg = top pixel,
10// bg = bottom pixel). The render hook paints the current frame; while anything
11// glows a `$.clock.every` timer repaints it in place with `$.ui.blit` (15 fps
12// during a pulse, 5 fps during the slow decay, stopped once everything settles).
13
14import { update } from "claude-code";
15
16const PANE = "repo-heatmap";
17const TITLE = "Repo Heatmap";
18const RASTER_KEY = "map";
19
20export const MAX_FILES = 1500;
21export const GLOW_MS = 20000;
22export const FAIL_MS = 6000;
23export const PULSE_MS = 1400;
24export const FLASH_MS = 700;
25const FAST_MS = 66; // ~15 fps while a pulse ring or flash runs
26const SLOW_MS = 200; // ~5 fps for the slow glow decay
27const RECENT_MAX = 5;
28const SCAN_BATCH = 200;
29
30const filesRef = { plugin: "repo-heatmap", key: "files" };
31const metaRef = { plugin: "repo-heatmap", key: "meta" };
32const touchesRef = { plugin: "repo-heatmap", key: "touches" };
33const recentRef = { plugin: "repo-heatmap", key: "recent" };
34
35const EMPTY_META = {
36  cwd: "",
37  mode: "lines",
38  total: 0,
39  more: 0,
40  moreWeight: 0,
41  status: "idle",
42  scannedAt: 0,
43  note: "",
44};
45
46// ---- palette ----------------------------------------------------------------
47
48export const COLORS = {
49  bg: 0x0b0e14,
50  cream: 0xf3e9d2,
51  creamDim: 0xb9b09c,
52  ink: 0x101317,
53  read: 0x2fd8f2,
54  search: 0xffd23f,
55  edit: 0xe8603c,
56  fail: 0xff2a3d,
57};
58const KIND_COLOR = { read: COLORS.read, search: COLORS.search, edit: COLORS.edit, fail: COLORS.fail };
59const HEX = (c) => `#${c.toString(16).padStart(6, "0")}`;
60// Folder hues, picked away from the heat colors so a glow always pops.
61const HUES = [222, 276, 150, 322, 204, 252, 112, 300, 172, 238, 136, 262];
62
63const WATCHED = new Set(["Read", "Edit", "MultiEdit", "Write", "NotebookEdit", "Grep", "Glob", "Bash", "LSP"]);
64const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "target", "vendor", "__pycache__", ".venv", "venv", "coverage", ".cache", ".turbo", "out"]);
65const SKIP_FILE =
66  /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb|Cargo\.lock|poetry\.lock|Gemfile\.lock|composer\.lock|go\.sum)$|\.(png|jpe?g|gif|ico|webp|bmp|tiff?|pdf|zip|gz|tgz|bz2|xz|7z|rar|jar|woff2?|ttf|otf|eot|mp[34]|mov|avi|wav|ogg|flac|so|dylib|dll|exe|bin|o|a|class|pyc|wasm|db|sqlite|map|min\.js|min\.css|lockb|DS_Store)$/i;
67
68// ---- registration -------------------------------------------------------------
69
70export function register(on) {
71  // Animation and caches live in the module: a hot reload starts them fresh and
72  // the next render (which reads $.state) fills them again.
73  const anim = {
74    timer: null,
75    layout: null,
76    touches: {},
77    lastBlit: 0,
78    busy: false,
79    denies: 0,
80    settledAt: -1,
81  };
82  const cache = { key: "", layout: null, known: null, knownKey: "" };
83
84  function stopAnim() {
85    if (anim.timer) {
86      try {
87        anim.timer.cancel();
88      } catch {
89        // already gone
90      }
91    }
92    anim.timer = null;
93  }
94
95  // `every` and `blit` are closures over the calling hook's `$`.
96  function startAnim(every, blit) {
97    if (anim.timer || !anim.layout) return;
98    if (!animState(anim.touches, Date.now()).active) return;
99    anim.denies = 0;
100    anim.timer = every(FAST_MS, () => {
101      const now = Date.now();
102      const layout = anim.layout;
103      if (!layout) return stopAnim();
104      const st = animState(anim.touches, now);
105      if (!st.pulsing && now - anim.lastBlit < SLOW_MS && st.active) return;
106      if (anim.busy) return;
107      anim.busy = true;
108      anim.lastBlit = now;
109      let cells;
110      try {
111        cells = encodeCells(renderFrame(layout, anim.touches, now));
112      } catch {
113        anim.busy = false;
114        return stopAnim();
115      }
116      Promise.resolve(blit({ requestId: PANE, key: RASTER_KEY, cells })).then(
117        (r) => {
118          anim.busy = false;
119          if (r && r.deny) {
120            anim.denies += 1;
121            if (anim.denies > 3) stopAnim();
122          } else anim.denies = 0;
123        },
124        () => {
125          anim.busy = false;
126          stopAnim();
127        },
128      );
129      // The frame just sent is the settled one: nothing left to animate.
130      if (!st.active) stopAnim();
131    });
132  }
133
134  function knownSet(files) {
135    const key = `${files.length}:${files.length ? files[0].p : ""}:${files.length ? files[files.length - 1].p : ""}`;
136    if (cache.known && cache.knownKey === key) return cache.known;
137    cache.known = new Set(files.map((f) => f.p));
138    cache.knownKey = key;
139    return cache.known;
140  }
141
142  // ---- session start: register the command, scan the repo in the background --
143  on("session.start", async ($, e, next) => {
144    const started = await next(e);
145    try {
146      await $.command.register({
147        name: "heatmap",
148        description: "Repo Heatmap: a live treemap of the repo that lights up as Claude reads, searches and edits",
149        argumentHint: "[reset|size lines|size bytes|rescan|close]",
150        immediate: true,
151      });
152    } catch {
153      // registered on a previous load
154    }
155    const cwd = e.cwd;
156    const { value: meta = EMPTY_META } = await $.state.get(metaRef);
157    if (meta.cwd === cwd && meta.status === "ready") return started;
158    await $.state.set(metaRef, { ...EMPTY_META, mode: meta.mode || "lines", cwd, status: "scanning" });
159    const run = (argv) => $.process.run(argv, { cwd, timeoutMs: 20000 });
160    const list = (path) => $.fs.list(path);
161    $.clock.after(5, async () => {
162      try {
163        const scan = await scanRepo(run, list, cwd);
164        await $.state.set(filesRef, scan.files);
165        const { value: m = EMPTY_META } = await $.state.get(metaRef);
166        await $.state.set(metaRef, {
167          ...m,
168          cwd,
169          total: scan.total,
170          more: scan.more,
171          moreWeight: scan.moreWeight,
172          status: "ready",
173          scannedAt: Date.now(),
174          note: scan.note,
175        });
176      } catch (err) {
177        const { value: m = EMPTY_META } = await $.state.get(metaRef);
178        await $.state.set(metaRef, { ...m, status: "error", note: String(err?.message ?? err).slice(0, 120) });
179      }
180    });
181    return started;
182  });
183
184  // ---- /heatmap -----------------------------------------------------------------
185  on("command.run", { command: "heatmap" }, async ($, e) => {
186    const [verb = "", arg = ""] = (e.args ?? "").trim().toLowerCase().split(/\s+/);
187
188    if (verb === "reset") {
189      await $.state.set(touchesRef, {});
190      await $.state.set(recentRef, []);
191      anim.touches = {};
192      stopAnim();
193      return { text: "Repo Heatmap: footprint cleared." };
194    }
195
196    if (verb === "size") {
197      if (arg !== "lines" && arg !== "bytes") return { text: "Usage: /heatmap size lines|bytes" };
198      const { value: m = EMPTY_META } = await $.state.get(metaRef);
199      await $.state.set(metaRef, { ...m, mode: arg });
200      return { text: `Repo Heatmap: files sized by ${arg}.` };
201    }
202
203    if (verb === "rescan") {
204      const cwd = await $.session.cwd();
205      const { value: m = EMPTY_META } = await $.state.get(metaRef);
206      await $.state.set(metaRef, { ...m, cwd, status: "scanning" });
207      const scan = await scanRepo(
208        (argv) => $.process.run(argv, { cwd, timeoutMs: 20000 }),
209        (path) => $.fs.list(path),
210        cwd,
211      );
212      await $.state.set(filesRef, scan.files);
213      await $.state.set(metaRef, {
214        ...m,
215        cwd,
216        total: scan.total,
217        more: scan.more,
218        moreWeight: scan.moreWeight,
219        status: "ready",
220        scannedAt: Date.now(),
221        note: scan.note,
222      });
223      return { text: `Repo Heatmap: ${scan.total} files mapped.` };
224    }
225
226    const pane = (await $.ui.panes()).find((p) => p.id === PANE);
227    // Open and on screen: the bare command closes it. Open behind another
228    // panel (the engine's diff view, say): it comes back to the front.
229    if (verb === "close" || (verb === "" && pane && pane.isShown)) {
230      stopAnim();
231      await $.ui.close({ id: PANE });
232      return { text: "Repo Heatmap closed." };
233    }
234    if (verb !== "" && verb !== "open") {
235      return { text: `Unknown option "${verb}". Try /heatmap, /heatmap reset, /heatmap size lines|bytes, /heatmap rescan.` };
236    }
237
238    const opened = await $.ui.open({ id: PANE, title: TITLE });
239    if (!opened.isPlaced) return { text: `Repo Heatmap could not be placed (${opened.reason}). Widen the terminal.` };
240    const { value: m = EMPTY_META } = await $.state.get(metaRef);
241    return { text: `Repo Heatmap open: ${m.total || "scanning"} files, sized by ${m.mode}.` };
242  });
243
244  // ---- tool calls: what Claude touches --------------------------------------------
245  on("tool.call", async ($, e, next) => {
246    if (!WATCHED.has(e.tool)) return next(e);
247    let cwd = "";
248    let known = null;
249    try {
250      const { value: meta = EMPTY_META } = await $.state.get(metaRef);
251      const { value: files = [] } = await $.state.get(filesRef);
252      cwd = meta.cwd || (await $.session.cwd());
253      known = knownSet(files);
254    } catch {
255      return next(e);
256    }
257
258    const record = async (hits) => {
259      if (hits.length === 0) return;
260      const now = await $.clock.now();
261      const touches = await update($, touchesRef, (t) => applyTouches(t ?? {}, hits, now));
262      await update($, recentRef, (r) => pushRecent(r ?? [], hits, now));
263      anim.touches = touches;
264      startAnim(
265        (ms, fn) => $.clock.every(ms, fn),
266        (args) => $.ui.blit(args),
267      );
268    };
269
270    // A read glows the moment it starts.
271    const pre = e.tool === "Read" ? touchesFromCall(e, null, cwd, known) : [];
272    try {
273      await record(pre);
274    } catch {
275      // never hold the tool up over a drawing
276    }
277
278    const result = await next(e);
279    try {
280      if (e.tool !== "Read" && !result.deny) {
281        // A Write that creates a file adds it to the map.
282        const fresh = newFileFromWrite(e, result, cwd, known);
283        if (fresh) {
284          await update($, filesRef, (f) => {
285            const list = f ?? [];
286            return list.some((x) => x.p === fresh.p) ? list : [...list, fresh];
287          });
288          known = new Set([...known, fresh.p]);
289        }
290        await record(touchesFromCall(e, result, cwd, known));
291      }
292    } catch {
293      // ignore: the map is decoration
294    }
295    return result;
296  });
297
298  // ---- the pane -------------------------------------------------------------------
299  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
300    const els = $.ui.resolve(e);
301    const { Box, Text } = els;
302    const { value: files = [] } = await $.state.get(filesRef);
303    const { value: meta = EMPTY_META } = await $.state.get(metaRef);
304    const { value: touches = {} } = await $.state.get(touchesRef);
305    const { value: recent = [] } = await $.state.get(recentRef);
306    anim.touches = touches;
307
308    const cols = clamp(Math.floor(e.props.bodyColumns ?? 60), 20, 200);
309    const stats = statsOf(files, touches, meta);
310    const header = Box({
311      flexDirection: "row",
312      children: [
313        Text({ bold: true, color: HEX(COLORS.cream), children: "REPO HEATMAP " }),
314        Text({ dimColor: true, wrap: "truncate", children: headerNote(meta, files.length) }),
315      ],
316    });
317
318    if (e.surface !== "terminal") {
319      const folders = folderSummary(files, touches, meta.mode).slice(0, 12);
320      return Box({
321        flexDirection: "column",
322        children: [
323          header,
324          Text({ children: stats.line }),
325          ...folders.map((g) =>
326            Text({
327              color: g.touched ? HEX(COLORS.edit) : undefined,
328              dimColor: !g.touched,
329              children: `${g.label}  ${g.touched}/${g.count} touched${g.edited ? `, ${g.edited} edited` : ""}`,
330            }),
331          ),
332          ...recentRows(Box, Text, recent, cols),
333        ],
334      });
335    }
336
337    const screenRows = e.viewport?.rows ?? 40;
338    const textRows = 3 + RECENT_MAX + 1; // reserved, so the map never jumps when the list fills
339    const mapRows = clamp(screenRows - textRows - 9, 6, 120);
340
341    if (files.length === 0) {
342      anim.layout = null;
343      return Box({
344        flexDirection: "column",
345        children: [
346          header,
347          Text({ dimColor: true, children: meta.status === "error" ? `Scan failed: ${meta.note}` : "Scanning the repository..." }),
348        ],
349      });
350    }
351
352    const key = `${cols}x${mapRows}:${meta.mode}:${files.length}:${meta.scannedAt}:${meta.more}`;
353    if (cache.key !== key || !cache.layout) {
354      cache.layout = buildLayout(files, meta.mode, cols, mapRows * 2, { count: meta.more, weight: meta.moreWeight });
355      cache.key = key;
356    }
357    anim.layout = cache.layout;
358    const now = Date.now();
359    const cells = encodeCells(renderFrame(cache.layout, touches, now));
360    startAnim(
361      (ms, fn) => $.clock.every(ms, fn),
362      (args) => $.ui.blit(args),
363    );
364
365    return Box({
366      flexDirection: "column",
367      children: [
368        header,
369        els.Raster({ key: RASTER_KEY, columns: cols, rows: mapRows, cells }),
370        Box({
371          flexDirection: "row",
372          children: [
373            Text({ color: HEX(COLORS.read), children: "■ Read  " }),
374            Text({ color: HEX(COLORS.search), children: "■ Search  " }),
375            Text({ color: HEX(COLORS.edit), children: "■ Edit  " }),
376            Text({ color: HEX(COLORS.fail), children: "■ Failed  " }),
377            Text({ color: HEX(palette().visited.read), children: "■ Visited" }),
378          ],
379        }),
380        Text({ wrap: "truncate", children: clip(stats.line, cols) }),
381        ...recentRows(Box, Text, recent, cols),
382      ],
383    });
384  });
385
386  on("ui.close", async ($, e, next) => {
387    if (e.id === PANE) stopAnim();
388    return next(e);
389  });
390}
391
392function recentRows(Box, Text, recent, cols) {
393  const shown = recent.slice(0, RECENT_MAX);
394  if (shown.length === 0) return [Text({ dimColor: true, children: "recently touched: nothing yet" })];
395  return [
396    Text({ dimColor: true, children: "recently touched" }),
397    ...shown.map((r) =>
398      Box({
399        flexDirection: "row",
400        children: [
401          Text({ color: HEX(KIND_COLOR[r.kind] ?? COLORS.cream), children: "● " }),
402          Text({ color: HEX(KIND_COLOR[r.kind] ?? COLORS.cream), children: `${KIND_LABEL[r.kind] ?? r.kind}`.padEnd(7) }),
403          Text({ wrap: "truncate", children: clip(r.path, Math.max(8, cols - 10)) }),
404        ],
405      }),
406    ),
407  ];
408}
409
410const VISITED_MIX = { search: 0.24, read: 0.36, edit: 0.62, fail: 0.5 };
411
412const KIND_LABEL = { read: "read", search: "search", edit: "edit", fail: "failed" };
413
414function headerNote(meta, n) {
415  if (meta.status === "scanning" && n === 0) return "scanning...";
416  const more = meta.more > 0 ? ` (+${meta.more} more)` : "";
417  return `${meta.total || n} files${more} · sized by ${meta.mode}`;
418}
419
420// ---- scanning -------------------------------------------------------------------
421
422// `run(argv)` and `list(path)` wrap $.process.run and $.fs.list.
423export async function scanRepo(run, list, cwd) {
424  let paths = [];
425  let note = "";
426  try {
427    const g = await run(["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
428    if (g.exitCode === 0) paths = g.stdout.split("\0").filter(Boolean);
429  } catch {
430    paths = [];
431  }
432  if (paths.length === 0) {
433    paths = await walk(list, cwd);
434    note = "walked (not a git repo)";
435  }
436  paths = [...new Set(paths)].filter((p) => !SKIP_FILE.test(p) && !p.split("/").some((s) => SKIP_DIRS.has(s)));
437
438  const sized = [];
439  if (paths.length > 4000) {
440    // Big repo: one call for byte sizes, lines estimated from bytes.
441    try {
442      const t = await run(["git", "ls-tree", "-r", "-l", "HEAD"]);
443      const bytes = new Map();
444      for (const line of t.stdout.split("\n")) {
445        const m = /^\S+ blob \S+\s+(\d+)\t(.+)$/.exec(line);
446        if (m) bytes.set(m[2], Number(m[1]));
447      }
448      for (const p of paths) {
449        const b = bytes.get(p) ?? 0;
450        sized.push({ p, l: Math.max(1, Math.round(b / 36)), b });
451      }
452    } catch {
453      for (const p of paths) sized.push({ p, l: 1, b: 1 });
454    }
455  } else {
456    for (let i = 0; i < paths.length; i += SCAN_BATCH) {
457      const batch = paths.slice(i, i + SCAN_BATCH);
458      let out = "";
459      try {
460        const r = await run(["wc", "-l", "-c", "--", ...batch]);
461        out = r.stdout;
462      } catch {
463        out = "";
464      }
465      const got = parseWc(out);
466      for (const p of batch) {
467        const s = got.get(p);
468        if (s) sized.push({ p, l: s.l, b: s.b });
469      }
470    }
471  }
472  return capFiles(sized, MAX_FILES, note);
473}
474
475export function parseWc(text) {
476  const got = new Map();
477  for (const line of String(text).split("\n")) {
478    const m = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line);
479    if (!m || m[3] === "total") continue;
480    got.set(m[3], { l: Number(m[1]), b: Number(m[2]) });
481  }
482  return got;
483}
484
485export function capFiles(sized, max, note = "") {
486  const sorted = [...sized].sort((a, b) => b.l - a.l || a.p.localeCompare(b.p));
487  const files = sorted.slice(0, max).sort((a, b) => a.p.localeCompare(b.p));
488  const rest = sorted.slice(max);
489  return {
490    files,
491    total: sized.length,
492    more: rest.length,
493    moreWeight: rest.reduce((s, f) => s + Math.max(1, f.l), 0),
494    note,
495  };
496}
497
498async function walk(list, root) {
499  const out = [];
500  const queue = [""];
501  while (queue.length > 0 && out.length < 20000) {
502    const rel = queue.shift();
503    let entries = [];
504    try {
505      entries = await list(rel ? `${root}/${rel}` : root);
506    } catch {
507      entries = [];
508    }
509    for (const ent of entries) {
510      const p = rel ? `${rel}/${ent.name}` : ent.name;
511      if (ent.kind === "directory") {
512        if (!SKIP_DIRS.has(ent.name) && !ent.name.startsWith(".") && p.split("/").length < 9) queue.push(p);
513      } else if (ent.kind === "file") out.push(p);
514    }
515  }
516  return out;
517}
518
519// ---- tool call -> files ---------------------------------------------------------
520
521export function normalizePath(raw, cwd) {
522  if (typeof raw !== "string") return null;
523  let s = raw.trim().replace(/^['"`]+|['"`,;)]+$/g, "");
524  if (!s) return null;
525  if (s.startsWith("~")) return null;
526  if (s.startsWith("/")) {
527    const roots = [cwd];
528    if (cwd.startsWith("/private/")) roots.push(cwd.slice("/private".length));
529    else roots.push(`/private${cwd}`);
530    const root = roots.find((r) => r && (s === r || s.startsWith(`${r}/`)));
531    if (!root) return null;
532    s = s.slice(root.length + 1);
533  }
534  const parts = [];
535  for (const seg of s.split("/")) {
536    if (seg === "" || seg === ".") continue;
537    if (seg === "..") {
538      if (parts.length === 0) return null;
539      parts.pop();
540    } else parts.push(seg);
541  }
542  return parts.length ? parts.join("/") : null;
543}
544
545// Files named in tool output, split by shape: `path:12:text` (grep -n / rg)
546// and `path:` lines are content hits; a bare path line is a listing entry.
547export function outputHits(text, cwd, known, limit = 400) {
548  const colon = [];
549  const bare = [];
550  const seenBare = new Set();
551  const seenColon = new Set();
552  const lines = String(text ?? "").split("\n");
553  for (let i = 0; i < lines.length && i < 6000 && colon.length + bare.length < limit; i++) {
554    const line = lines[i].trim();
555    if (!line) continue;
556    const whole = normalizePath(line, cwd);
557    if (whole && known.has(whole)) {
558      if (!seenBare.has(whole)) {
559        seenBare.add(whole);
560        bare.push(whole);
561      }
562      continue;
563    }
564    const m = /^(.+?)[:-]\d+[:-]/.exec(line) ?? /^([^:\s]+):/.exec(line);
565    if (!m) continue;
566    const p = normalizePath(m[1], cwd);
567    if (p && known.has(p) && !seenColon.has(p)) {
568      seenColon.add(p);
569      colon.push(p);
570    }
571  }
572  return { colon, bare };
573}
574
575// Every file hit in a search tool's output (Grep, Glob).
576export function filesInText(text, cwd, known, limit = 80) {
577  const { colon, bare } = outputHits(text, cwd, known);
578  return [...colon, ...bare].slice(0, limit);
579}
580
581function words(cmd) {
582  return String(cmd).match(/"[^"]*"|'[^']*'|[^\s|;&<>()]+|[|;&<>]+/g) ?? [];
583}
584
585const READ_CMDS = new Set(["cat", "head", "tail", "less", "more", "bat", "nl", "wc", "sed", "awk", "view", "diff", "cmp", "jq"]);
586const CONTENT_CMDS = new Set(["grep", "egrep", "fgrep", "rg", "ag", "ack"]);
587const LIST_CMDS = new Set(["ls", "find", "fd", "tree"]);
588const LISTING_MAX = 12; // a listing of more files than this is an overview, not a look
589const SEARCH_MAX = 40;
590const SCRIPT_WRITE = /\.write\(|writeFile|write_text|open\([^)]*['"][wa]|perl\s+-\w*i/;
591const RANK = { fail: 4, edit: 3, read: 2, search: 1 };
592
593function knownIn(text, cwd, known) {
594  const out = [];
595  for (const tok of String(text).match(/[A-Za-z0-9_@+.\/-]+/g) ?? []) {
596    const p = normalizePath(tok, cwd);
597    if (p && known.has(p)) out.push(p);
598  }
599  return out;
600}
601
602function bashTouches(command, text, failed, cwd, known, add, push) {
603  let content = false;
604  let bareOk = false;
605  let listing = false;
606  for (const seg of command.split(/\|\||&&|;|\n|\|/)) {
607    const toks = words(seg).filter((t) => !/^[A-Z_][A-Z0-9_]*=/.test(t));
608    const cmd = (toks[0] ?? "").replace(/^.*\//, "");
609    const args = toks.slice(1).filter((t) => !t.startsWith("-") && !/^[<>|&]+$/.test(t));
610    const redirect = toks.findIndex((t) => t === ">" || t === ">>");
611    const target = redirect >= 0 ? toks[redirect + 1] : undefined;
612    if (target) add(target, failed ? "fail" : "edit");
613    if (cmd === "tee") for (const a of args) add(a, failed ? "fail" : "edit");
614    if (cmd === "git") {
615      const sub = args[0];
616      if (sub === "grep") {
617        content = true;
618        if (toks.some((t) => /^-(l|-files-with-matches|-name-only)$/.test(t))) bareOk = true;
619      } else if (sub === "ls-files") listing = true;
620      continue;
621    }
622    if (cmd === "sed" && toks.some((t) => /^-i/.test(t))) {
623      for (const a of args.slice(1)) add(a, failed ? "fail" : "edit");
624    } else if (READ_CMDS.has(cmd)) {
625      for (const a of args) if (a !== target) add(a, "read");
626    } else if (CONTENT_CMDS.has(cmd)) {
627      content = true;
628      if (toks.some((t) => /^-\w*l\w*$|^--files-with-matches$|^-\w*c$/.test(t))) bareOk = true;
629      for (const a of args.slice(1)) add(a, "search");
630    } else if (LIST_CMDS.has(cmd)) listing = true;
631  }
632  if (!failed) {
633    const { colon, bare } = outputHits(text, cwd, known);
634    if (content) for (const p of colon.slice(0, SEARCH_MAX)) push(p, "search");
635    if (content && bareOk) for (const p of bare.slice(0, SEARCH_MAX)) push(p, "search");
636    else if (listing && bare.length <= LISTING_MAX) for (const p of bare) push(p, "search");
637  }
638  // An inline script that writes (python, node, perl): the files it names were edited.
639  if (SCRIPT_WRITE.test(command)) for (const p of knownIn(command, cwd, known)) push(p, failed ? "fail" : "edit");
640}
641
642// What a tool call touched: [{ path, kind }], paths relative to cwd and known.
643export function touchesFromCall(call, outcome, cwd, known) {
644  const hits = [];
645  const push = (p, kind) => hits.push({ path: p, kind });
646  const add = (raw, kind) => {
647    const p = normalizePath(raw, cwd);
648    if (p && known.has(p)) push(p, kind);
649  };
650  const failed = Boolean(outcome && outcome.isError);
651  const text = outcome && typeof outcome.text === "string" ? outcome.text : "";
652  switch (call.tool) {
653    case "Read":
654      add(call.file_path, "read");
655      break;
656    case "LSP":
657      add(call.filePath ?? call.file_path, "read");
658      break;
659    case "Edit":
660    case "MultiEdit":
661    case "Write":
662      add(call.file_path, failed ? "fail" : "edit");
663      break;
664    case "NotebookEdit":
665      add(call.notebook_path, failed ? "fail" : "edit");
666      break;
667    case "Grep":
668    case "Glob": {
669      if (failed) break;
670      const found = filesInText(text, cwd, known, 200);
671      const cap = call.tool === "Glob" ? LISTING_MAX * 2 : SEARCH_MAX;
672      if (found.length <= cap) for (const p of found) push(p, "search");
673      if (found.length === 0) add(call.path, "search");
674      break;
675    }
676    case "Bash":
677      if (typeof call.command === "string") bashTouches(call.command, text, failed, cwd, known, add, push);
678      break;
679    default:
680      break;
681  }
682  // One hit per file, the strongest kind winning (a failed edit, an edit, a read, a search).
683  const best = new Map();
684  for (const h of hits) {
685    const had = best.get(h.path);
686    if (!had || RANK[h.kind] > RANK[had.kind]) best.set(h.path, h);
687  }
688  return [...best.values()].slice(0, 80);
689}
690
691export function newFileFromWrite(call, outcome, cwd, known) {
692  if (call.tool !== "Write" || !outcome || outcome.isError || outcome.deny) return null;
693  const p = normalizePath(call.file_path, cwd);
694  if (!p || known.has(p) || SKIP_FILE.test(p)) return null;
695  const content = typeof call.content === "string" ? call.content : "";
696  return { p, l: Math.max(1, content.split("\n").length), b: content.length };
697}
698
699export function applyTouches(prev, hits, now) {
700  const next = { ...prev };
701  for (const h of hits) {
702    const old = next[h.path];
703    const t = old ? { ...old } : { first: now, last: now, kind: h.kind, r: 0, s: 0, e: 0, f: 0, n: 0, edits: 0 };
704    t.last = now;
705    t.kind = h.kind;
706    t.n += 1;
707    if (h.kind === "read") t.r = now;
708    if (h.kind === "search") t.s = now;
709    if (h.kind === "edit") {
710      t.e = now;
711      t.edits += 1;
712    }
713    if (h.kind === "fail") t.f = now;
714    next[h.path] = t;
715  }
716  return next;
717}
718
719export function pushRecent(prev, hits, now) {
720  let list = [...prev];
721  for (const h of hits) {
722    list = [{ path: h.path, kind: h.kind, at: now }, ...list.filter((r) => r.path !== h.path)];
723  }
724  return list.slice(0, RECENT_MAX);
725}
726
727// ---- decay ------------------------------------------------------------------------
728
729// 1 at the touch, easing to 0 at `span` (smoothstep): bright for a while, then a soft fade.
730export function glow(age, span = GLOW_MS) {
731  if (age <= 0) return 1;
732  if (age >= span) return 0;
733  const u = age / span;
734  return 1 - u * u * (3 - 2 * u);
735}
736
737export function animState(touches, now) {
738  let active = false;
739  let pulsing = false;
740  for (const k in touches) {
741    const t = touches[k];
742    if (now - t.last < GLOW_MS || (t.f && now - t.f < FAIL_MS)) active = true;
743    if (now - t.first < PULSE_MS || now - t.last < FLASH_MS || (t.e && now - t.e < PULSE_MS * 1.5) || (t.f && now - t.f < FAIL_MS)) {
744      pulsing = true;
745      break;
746    }
747  }
748  return { active: active || pulsing, pulsing };
749}
750
751// The settled tint a touched file keeps: edit beats read beats search.
752export function visitedKind(t) {
753  if (t.edits > 0) return "edit";
754  if (t.r > 0) return "read";
755  if (t.f > 0) return "fail";
756  return "search";
757}
758
759// ---- palette ----------------------------------------------------------------------
760//
761// The terminal paints a Raster from a table of 1024 (fg, bg) color pairs per
762// session and quantizes each channel to 16 levels, so every color the map can
763// show comes from this small fixed palette (already on the 16-level grid), and
764// the drawing is arranged so only a bounded set of pairs can ever meet in a cell:
765// a tile's bottom row is the gap color, and rings and halos fill whole cells.
766
767export const GLOW_STEPS = 4;
768let PAL = null;
769
770// A color snapped to the 16 levels per channel the terminal paints.
771export function quantize(c) {
772  const f = (v) => Math.round(v / 17) * 17;
773  return (f((c >> 16) & 255) << 16) | (f((c >> 8) & 255) << 8) | f(c & 255);
774}
775
776export function palette() {
777  if (PAL) return PAL;
778  const q = quantize;
779  const kinds = ["read", "search", "edit", "fail"];
780  const neutral = 0x2b2f36;
781  const P = {
782    gap: q(COLORS.bg),
783    cream: q(COLORS.cream),
784    creamDim: q(COLORS.creamDim),
785    ink: q(COLORS.ink),
786    visited: {},
787    ramp: {},
788    flash: {},
789    ring: {},
790    halo: {},
791    blink: [q(COLORS.fail), q(darken(COLORS.fail, 0.5))],
792    folder: HUES.map((h) => ({ header: q(hsl(h, 0.34, 0.3)), tiles: [q(hsl(h, 0.26, 0.17)), q(hsl(h, 0.26, 0.22))] })),
793    more: { header: q(hsl(220, 0.12, 0.3)), tiles: [q(hsl(220, 0.06, 0.16)), q(hsl(220, 0.06, 0.16))] },
794    shade: new Map(),
795  };
796  for (const k of kinds) {
797    const c = KIND_COLOR[k];
798    P.visited[k] = q(mix(neutral, c, VISITED_MIX[k]));
799    // ramp[0] is the visited tint, ramp[GLOW_STEPS] the full glow.
800    P.ramp[k] = Array.from({ length: GLOW_STEPS + 1 }, (_, i) => q(mix(P.visited[k], c, i / GLOW_STEPS)));
801    P.flash[k] = q(mix(c, 0xffffff, k === "fail" ? 0.2 : 0.45));
802    P.ring[k] = [q(mix(c, 0xffffff, 0.35)), q(c), q(darken(c, 0.4)), q(darken(c, 0.65))];
803    P.halo[k] = [q(darken(c, 0.35)), q(darken(c, 0.6))];
804  }
805  PAL = P;
806  return P;
807}
808
809// A fill's bevel: a lighter top row and left column, from the same palette grid.
810function shadeOf(fill) {
811  const P = palette();
812  let s = P.shade.get(fill);
813  if (!s) {
814    s = { top: quantize(lighten(fill, 0.14)), left: quantize(lighten(fill, 0.06)) };
815    P.shade.set(fill, s);
816  }
817  return s;
818}
819
820// The fill a touched tile shows at `now`: one of a few palette steps per kind.
821export function heatFill(base, t, now) {
822  const P = palette();
823  if (t.f) {
824    const age = now - t.f;
825    // Blink between hot red and a darker red while the failure is fresh.
826    if (age >= 0 && age < FAIL_MS) return P.blink[Math.floor(age / 250) % 2];
827  }
828  const age = now - t.last;
829  if (age >= 0 && age < FLASH_MS * 0.6) return P.flash[t.kind];
830  const g = t.kind === "fail" ? glow(age, FAIL_MS * 2) : glow(age);
831  const step = Math.round(g * GLOW_STEPS);
832  if (step === 0) return P.visited[visitedKind(t)];
833  return P.ramp[t.kind][step];
834}
835
836// ---- layout -----------------------------------------------------------------------
837
838// Squarified treemap (Bruls et al.). Returns one float rect per weight, in input order.
839export function squarify(weights, rect) {
840  const n = weights.length;
841  const out = new Array(n);
842  const total = weights.reduce((s, w) => s + Math.max(0, w), 0);
843  if (n === 0) return out;
844  if (total <= 0 || rect.w <= 0 || rect.h <= 0) {
845    for (let i = 0; i < n; i++) out[i] = { x: rect.x, y: rect.y, w: 0, h: 0 };
846    return out;
847  }
848  const scale = (rect.w * rect.h) / total;
849  const order = weights.map((_, i) => i).sort((a, b) => weights[b] - weights[a] || a - b);
850  let { x, y, w, h } = rect;
851  let row = [];
852  let rowSum = 0;
853
854  const worst = (sum, min, max, side) => {
855    if (sum <= 0 || min <= 0) return Infinity;
856    const s2 = sum * sum;
857    const side2 = side * side;
858    return Math.max((side2 * max) / s2, s2 / (side2 * min));
859  };
860
861  const flush = () => {
862    if (row.length === 0) return;
863    if (w >= h) {
864      const colW = h > 0 ? rowSum / h : 0;
865      let yy = y;
866      for (const i of row) {
867        const a = Math.max(0, weights[i]) * scale;
868        const hh = colW > 0 ? a / colW : 0;
869        out[i] = { x, y: yy, w: colW, h: hh };
870        yy += hh;
871      }
872      x += colW;
873      w -= colW;
874    } else {
875      const rowH = w > 0 ? rowSum / w : 0;
876      let xx = x;
877      for (const i of row) {
878        const a = Math.max(0, weights[i]) * scale;
879        const ww = rowH > 0 ? a / rowH : 0;
880        out[i] = { x: xx, y, w: ww, h: rowH };
881        xx += ww;
882      }
883      y += rowH;
884      h -= rowH;
885    }
886    row = [];
887    rowSum = 0;
888  };
889
890  let min = Infinity;
891  let max = 0;
892  for (let k = 0; k < order.length; k++) {
893    const i = order[k];
894    const a = Math.max(0, weights[i]) * scale;
895    const side = Math.min(w, h);
896    if (row.length === 0) {
897      row.push(i);
898      rowSum = a;
899      min = a;
900      max = a;
901      continue;
902    }
903    const before = worst(rowSum, min, max, side);
904    const after = worst(rowSum + a, Math.min(min, a), Math.max(max, a), side);
905    if (after <= before) {
906      row.push(i);
907      rowSum += a;
908      min = Math.min(min, a);
909      max = Math.max(max, a);
910    } else {
911      flush();
912      row.push(i);
913      rowSum = a;
914      min = a;
915      max = a;
916    }
917  }
918  flush();
919  return out;
920}
921
922export function weightOf(f, mode) {
923  return Math.max(1, mode === "bytes" ? f.b : f.l);
924}
925
926// Folder groups: top-level folders, with a dominant one (say `src`) split into
927// its subfolders so `src/api` and `src/auth` read as their own regions.
928export function groupFiles(files, mode) {
929  const total = files.reduce((s, f) => s + weightOf(f, mode), 0);
930  const top = new Map();
931  for (const f of files) {
932    const i = f.p.indexOf("/");
933    const k = i < 0 ? "" : f.p.slice(0, i);
934    if (!top.has(k)) top.set(k, []);
935    top.get(k).push(f);
936  }
937  const groups = [];
938  for (const [name, list] of top) {
939    const tw = list.reduce((s, f) => s + weightOf(f, mode), 0);
940    const subs = new Map();
941    for (const f of list) {
942      const parts = f.p.split("/");
943      const k = name === "" ? "" : parts.length > 2 ? `${parts[0]}/${parts[1]}` : parts[0];
944      if (!subs.has(k)) subs.set(k, []);
945      subs.get(k).push(f);
946    }
947    if (name !== "" && tw > total * 0.3 && subs.size >= 2 && list.length >= 8) {
948      for (const [k, sl] of subs) groups.push({ name: k, label: k, files: sl });
949    } else groups.push({ name, label: name === "" ? "(root)" : name, files: list });
950  }
951  for (const g of groups) g.weight = g.files.reduce((s, f) => s + weightOf(f, mode), 0);
952  groups.sort((a, b) => b.weight - a.weight || a.label.localeCompare(b.label));
953  return groups;
954}
955
956function hashOf(s) {
957  let h = 2166136261;
958  for (let i = 0; i < s.length; i++) {
959    h ^= s.charCodeAt(i);
960    h = Math.imul(h, 16777619);
961  }
962  return h >>> 0;
963}
964
965// The static picture: folder regions with header strips, file tiles, label
966// slots. W columns, H pixel rows (2 per terminal row).
967export function buildLayout(files, mode, W, H, more = { count: 0, weight: 0 }) {
968  const groups = groupFiles(files, mode);
969  if (more && more.count > 0) {
970    groups.push({
971      name: "+more",
972      label: `+${more.count} more`,
973      files: [{ p: `+${more.count} more`, l: more.weight, b: more.weight, isMore: true }],
974      weight: more.weight,
975    });
976  }
977  const P = palette();
978  const base = new Uint32Array(W * H).fill(P.gap);
979  const L = { W, H, base, folders: [], files: [], index: new Map(), labels: [] };
980  const rects = squarify(
981    groups.map((g) => g.weight),
982    { x: 0, y: 0, w: W, h: H },
983  );
984
985  groups.forEach((g, gi) => {
986    const r = rects[gi];
987    // Snap folder edges: x to whole columns, y to whole cells (even pixels)
988    // so the header strip is one clean terminal row.
989    const x0 = Math.round(r.x);
990    const x1 = Math.round(r.x + r.w);
991    const y0 = 2 * Math.round(r.y / 2);
992    const y1 = 2 * Math.round((r.y + r.h) / 2);
993    const tone = g.name === "+more" ? P.more : P.folder[gi % P.folder.length];
994    const header = tone.header;
995    const folder = { label: g.label, x0, y0, x1, y1, header, count: g.files.length, hasHeader: false };
996    L.folders.push(folder);
997    if (x1 - x0 < 1 || y1 - y0 < 1) {
998      for (const f of g.files) L.index.set(f.p, -1);
999      return;
1000    }
1001    // A one-column gutter on the right of every folder but the last in its row.
1002    const gx1 = x1 < W && x1 - x0 >= 4 ? x1 - 1 : x1;
1003    const hasHeader = y1 - y0 >= 6 && gx1 - x0 >= 3;
1004    folder.hasHeader = hasHeader;
1005    const fy0 = hasHeader ? y0 + 2 : y0;
1006    if (hasHeader) {
1007      for (let y = y0; y < y0 + 2; y++) for (let x = x0; x < gx1; x++) base[y * W + x] = header;
1008      L.labels.push({ kind: "folder", folder: L.folders.length - 1, cx: x0, cy: y0 / 2, width: gx1 - x0 });
1009    }
1010    const inner = squarify(
1011      g.files.map((f) => weightOf(f, mode)),
1012      { x: x0, y: fy0, w: gx1 - x0, h: y1 - fy0 },
1013    );
1014    g.files.forEach((f, fi) => {
1015      const fr = inner[fi];
1016      const tile = {
1017        path: f.p,
1018        g: L.folders.length - 1,
1019        x0: Math.round(fr.x),
1020        x1: Math.round(fr.x + fr.w),
1021        // Whole cells vertically: every cell belongs to one tile, so a cell's
1022        // (fg, bg) pair never mixes two tiles' colors.
1023        y0: 2 * Math.round(fr.y / 2),
1024        y1: 2 * Math.round((fr.y + fr.h) / 2),
1025        color: 0,
1026        isMore: Boolean(f.isMore),
1027      };
1028      tile.color = tone.tiles[hashOf(f.p) % 2];
1029      const idx = L.files.length;
1030      L.files.push(tile);
1031      L.index.set(f.p, idx);
1032      if (tile.x1 - tile.x0 < 1 || tile.y1 - tile.y0 < 1) return;
1033      paintTile(base, W, tile, tile.color);
1034      // A name label where the tile has a whole cell row and room for a word.
1035      const w = tile.x1 - tile.x0;
1036      const cy = Math.ceil(tile.y0 / 2);
1037      if (w >= 5 && 2 * cy + 1 <= tile.y1 - 2) {
1038        L.labels.push({ kind: "file", file: idx, cx: tile.x0 + (w >= 8 ? 1 : 0), cy, width: w - (w >= 8 ? 2 : 1) });
1039      }
1040    });
1041  });
1042  return L;
1043}
1044
1045export function paintTile(pix, W, t, fill) {
1046  const w = t.x1 - t.x0;
1047  const h = t.y1 - t.y0;
1048  if (w < 1 || h < 1) return;
1049  const sh = shadeOf(fill);
1050  const top = h >= 3 ? sh.top : fill;
1051  const left = w >= 3 && h >= 2 ? sh.left : fill;
1052  // The bottom row is the gap color, so two tiles never share a cell's pair.
1053  const bottom = h >= 2 ? palette().gap : fill;
1054  for (let y = t.y0; y < t.y1; y++) {
1055    const row = y * W;
1056    const c = y === t.y1 - 1 ? bottom : y === t.y0 ? top : null;
1057    for (let x = t.x0; x < t.x1; x++) {
1058      pix[row + x] = c ?? (x === t.x0 ? left : fill);
1059    }
1060  }
1061}
1062
1063// A rectangle outline of whole cells (both half pixels one color), `dx` columns
1064// and `dy` rows out from the tile: a ring adds only (c, c) pairs.
1065function cellOutline(out, W, rows, t, dx, dy, c) {
1066  const xa = t.x0 - dx;
1067  const xb = t.x1 - 1 + dx;
1068  const ya = (t.y0 >> 1) - dy;
1069  const yb = ((t.y1 - 1) >> 1) + dy;
1070  const put = (x, y) => {
1071    if (x < 0 || y < 0 || x >= W || y >= rows) return;
1072    const o = (y * W + x) * 3;
1073    out[o] = 0x2580;
1074    out[o + 1] = c;
1075    out[o + 2] = c;
1076  };
1077  for (let x = xa; x <= xb; x++) {
1078    put(x, ya);
1079    put(x, yb);
1080  }
1081  for (let y = ya + 1; y < yb; y++) {
1082    put(xa, y);
1083    put(xb, y);
1084  }
1085}
1086
1087// ---- frames ---------------------------------------------------------------------
1088
1089// The frame at `now`: `[codePoint, fg, bg]` per cell, row-major (RasterProps).
1090export function renderFrame(L, touches, now) {
1091  const P = palette();
1092  const { W, H } = L;
1093  const rows = H >> 1;
1094  const pix = L.base.slice();
1095  const counts = new Int32Array(L.folders.length);
1096  const pulses = [];
1097  const halos = [];
1098  const lit = new Map();
1099  for (const path in touches) {
1100    const i = L.index.get(path);
1101    if (i === undefined || i < 0) continue;
1102    const t = touches[path];
1103    const tile = L.files[i];
1104    counts[tile.g] += 1;
1105    const fill = heatFill(tile.color, t, now);
1106    lit.set(i, fill);
1107    paintTile(pix, W, tile, fill);
1108    // A ring on the first touch, and on every edit or failure (the climax).
1109    const age = now - t.first;
1110    if (age >= 0 && age < PULSE_MS) pulses.push([tile, age / PULSE_MS, t.kind, 3]);
1111    for (const [at, kind] of [
1112      [t.e, "edit"],
1113      [t.f, "fail"],
1114    ]) {
1115      const a = now - at;
1116      if (at && a >= 0 && a < PULSE_MS * 1.5 && at !== t.first) pulses.push([tile, a / (PULSE_MS * 1.5), kind, 5]);
1117    }
1118    // A hot edit keeps a halo, so even a tiny file reads from across the room.
1119    const hot = t.e && t.kind !== "read" && t.kind !== "search" ? glow(now - t.e) : 0;
1120    if (hot > 0.3) halos.push([tile, hot > 0.65 ? 0 : 1, t.kind === "fail" ? "fail" : "edit"]);
1121  }
1122
1123  const out = new Uint32Array(W * rows * 3);
1124  for (let cy = 0; cy < rows; cy++) {
1125    const top = 2 * cy * W;
1126    const bot = top + W;
1127    let o = cy * W * 3;
1128    for (let x = 0; x < W; x++) {
1129      out[o] = 0x2580;
1130      out[o + 1] = pix[top + x];
1131      out[o + 2] = pix[bot + x];
1132      o += 3;
1133    }
1134  }
1135
1136  for (const lab of L.labels) {
1137    let text;
1138    let fg;
1139    let bg;
1140    if (lab.kind === "folder") {
1141      const f = L.folders[lab.folder];
1142      const n = counts[lab.folder];
1143      const tail = n > 0 ? ` ${n}/${f.count}` : "";
1144      const name = clip(f.label, Math.max(1, lab.width - 1 - tail.length));
1145      text = ` ${name}${tail.length + name.length + 1 <= lab.width ? tail : ""}`;
1146      fg = n > 0 ? P.cream : P.creamDim;
1147      bg = f.header;
1148    } else {
1149      const tile = L.files[lab.file];
1150      const fill = lit.get(lab.file);
1151      bg = pix[(2 * lab.cy + 1) * W + lab.cx];
1152      text = clip(baseName(tile.path), lab.width);
1153      fg = fill !== undefined ? (luma(bg) > 0.5 ? P.ink : P.cream) : P.creamDim;
1154    }
1155    writeText(out, W, lab.cx, lab.cy, text, fg, bg);
1156  }
1157
1158  // Halos and rings last, as whole cells over the map (and any label under them).
1159  for (const [tile, step, kind] of halos) cellOutline(out, W, rows, tile, 1, 1, P.halo[kind][step]);
1160  for (const [tile, p, kind, reach] of pulses) {
1161    const d = 1 + Math.round(p * reach);
1162    const step = Math.min(P.ring[kind].length - 1, Math.floor(p * P.ring[kind].length));
1163    cellOutline(out, W, rows, tile, d, Math.max(1, Math.round(d / 2)), P.ring[kind][step]);
1164  }
1165  return out;
1166}
1167
1168function writeText(out, W, cx, cy, text, fg, bg) {
1169  for (let k = 0; k < text.length && cx + k < W; k++) {
1170    let code = text.charCodeAt(k);
1171    if (code < 0x20 || (code > 0x7e && code !== 0x2026)) code = 0x3f;
1172    const o = (cy * W + cx + k) * 3;
1173    out[o] = code;
1174    out[o + 1] = fg;
1175    out[o + 2] = bg;
1176  }
1177}
1178
1179const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1180
1181export function toBase64(bytes) {
1182  if (typeof bytes.toBase64 === "function") return bytes.toBase64();
1183  let s = "";
1184  const n = bytes.length;
1185  let i = 0;
1186  for (; i + 2 < n; i += 3) {
1187    const v = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
1188    s += B64[(v >> 18) & 63] + B64[(v >> 12) & 63] + B64[(v >> 6) & 63] + B64[v & 63];
1189  }
1190  if (i < n) {
1191    const a = bytes[i];
1192    const b = i + 1 < n ? bytes[i + 1] : 0;
1193    const v = (a << 16) | (b << 8);
1194    s += B64[(v >> 18) & 63] + B64[(v >> 12) & 63] + (i + 1 < n ? B64[(v >> 6) & 63] : "=") + "=";
1195  }
1196  return s;
1197}
1198
1199// Little-endian u32 triplets, base64 (RasterProps.cells).
1200export function encodeCells(words) {
types/index.d.ts 36 lines
1export type RepoHeatmapFile = { p: string; l: number; b: number };
2export type RepoHeatmapKind = "read" | "search" | "edit" | "fail";
3export type RepoHeatmapTouch = {
4  first: number;
5  last: number;
6  kind: RepoHeatmapKind;
7  r: number;
8  s: number;
9  e: number;
10  f: number;
11  n: number;
12  edits: number;
13};
14export type RepoHeatmapRecent = { path: string; kind: RepoHeatmapKind; at: number };
15export type RepoHeatmapMeta = {
16  cwd: string;
17  mode: "lines" | "bytes";
18  total: number;
19  more: number;
20  moreWeight: number;
21  status: "idle" | "scanning" | "ready" | "error";
22  scannedAt: number;
23  note: string;
24};
25
26declare module "claude-code" {
27  interface PluginState {
28    "repo-heatmap": {
29      files: RepoHeatmapFile[];
30      meta: RepoHeatmapMeta;
31      touches: Record<string, RepoHeatmapTouch>;
32      recent: RepoHeatmapRecent[];
33    };
34  }
35}
36