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

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-heatmaphooks/repo-heatmap.mjs 1294 lines1// 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 lines1export 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