Codebase intelligence for Claude Code. Indexes your codebase into five layers (Graph, Git, Docs, Decisions, Code Health) and exposes them through task-shaped…

Gives Claude Code deep understanding of your codebase — architecture, ownership, hotspots, dependencies, architectural decisions, and a defect-validated code-health score. Claude answers "why does auth work this way?" instead of "here's what auth.ts contains" — with fewer greps, fewer file reads, and lower cost per query.
/plugin marketplace add repowise-dev/repowise
/plugin install repowise@repowise
claude --plugin-dir ./plugins/claude-code
After installing the plugin, run:
/repowise:init
Claude walks you through everything: installing repowise, choosing a mode, configuring your LLM provider, and indexing your codebase. Once indexed, the MCP tools and skills activate automatically.
Graph (tree-sitter dependency graph, 16 languages) · Git (hotspots, ownership, co-change, bus factor) · Docs (LLM-generated wiki + semantic search) · Decisions (architectural rationale mined from five sources) · Code Health (1–10 defect-validated score from deterministic markers).
| Command | What it does |
|---|---|
/repowise:init | Interactive setup — installs repowise, asks your preferences, indexes your codebase |
/repowise:status | Health check — sync state, page counts, provider info |
/repowise:update | Incremental update — sync the index with recent code changes |
/repowise:search | Search the codebase wiki (fulltext, semantic, or symbol) |
/repowise:ask | Cited, synthesised answer to a codebase question (LLM call) |
/repowise:context | Triage card for files/modules/symbols (layer, hotspot, freshness) |
/repowise:symbol | Read one symbol body with live-verified line bounds |
/repowise:reindex | Rebuild the vector store (re-embed; no LLM calls) |
/repowise:health | Code-health KPIs, lowest-scoring files, refactoring targets, trends |
/repowise:coverage | Ingest or inspect coverage reports (lights up untested hotspots + per-test map) |
/repowise:impacted-tests | Tests whose coverage intersects a change (commit / range / staged) |
/repowise:risk | Repo-relative review priority plus a supporting diff-shape score |
/repowise:security | Full-history secret scan (repowise security scan --history) |
/repowise:dead-code | Unreachable files, unused exports, zombie packages by confidence |
/repowise:export | Export wiki pages or a Structurizr architecture model |
/repowise:decision | List, inspect, add, or confirm architectural decisions |
/repowise:why | Why the code is shaped this way (decisions + archaeology) |
/repowise:doctor | Diagnose (and optionally repair) the setup, keys, and index drift |
Claude uses these when relevant — no slash command needed:
get_overview / get_answer / search_codebase / get_context / get_symbol instead of raw file reads.get_risk (and get_health for refactors) before editing to assess blast radius.get_change_risk (whole-change score) with get_risk's per-file directive block (will-break / missing co-changes / missing tests).get_health.get_why for the why before architectural changes.get_dead_code, conservatively, during cleanup.list_repos)Registered automatically when the plugin is enabled. The default single-repo surface is the ten flagship tools below plus list_repos (see MCP_TOOLS.md):
| Tool | What it answers |
|---|---|
get_overview | Architecture summary, module map, entry points, git health |
get_answer | Cited, synthesised answer to a code question + a calibrated confidence |
get_context | Triage card (docs, signatures, hotspot bit, callers, decisions) for files/modules/symbols |
get_symbol | Raw source of one symbol with exact line bounds |
search_codebase | Hybrid code search — mode="auto" routes identifiers to indexed symbols, paths to files, prose to semantic wiki search |
get_risk | Per-file hotspot, dependents, co-changes, owners; PR directive block with changed_files |
get_change_risk | Benchmarked live-change percentile/classification plus supporting diff-shape score |
get_why | Architectural decisions — search, path-anchored, or health dashboard |
get_dead_code | Unused/unreachable findings tiered by confidence |
get_health | 1–10 code-health score and marker findings per file |
| Mode | What you get | Requirements |
|---|---|---|
| Default (keyless) | Graph + Git + Code Health + Dead Code + a full wiki rendered from structure | Nothing |
| Model-written wiki | Same, with LLM-written pages and decision mining | Provider key |
| Local (Ollama) | Model-written wiki, fully offline | Ollama running |
The keyless default makes zero LLM calls and still produces a complete wiki; a provider key changes who writes the pages, not whether you get them, and that layer can continue in the background. Run /repowise:init and Claude helps you choose.
The plugin registers two hooks that run repowise-augment:
SessionStart (startup|resume|clear) — emits live index-freshness / trust context at session start.PostToolUse after Grep|Glob|Read|Edit|Write|mcp__.*[Rr]epowise.*__.* — stays silent unless it has something asymmetric to add (rescuing a zero-result grep with the closest indexed symbol, ranking a flood of matches by graph centrality, flagging a stale read, or recording read-after-served MCP traffic).No LLM, no network. (repowise init installs the same hooks in ~/.claude/settings.json; running both is safe — duplicate enrichment is de-duplicated.)
Lens is the part of the plugin you see, a Claude Code mod that ships in hooks/lens/. It adds the file's reach to the spinner, margin notes under edits, a row under output repowise distill shortened, a change review under Claude's answer after a turn that edits files, and a /lens pane with Flow (a dashboard of each turn: what an edit reaches, where Claude's context came from, what Repowise answered), a map of the repo lit by Claude's turn with its story underneath, Ask, and a session recap. Lens never blocks or rewrites Claude's tool calls, makes no model calls of its own, and sends Claude nothing unless you press a button. Ask questions that do not start with "why" go to get_answer, which may use the model your repo configures. See the footprint for the full list.
It needs Claude Code 2.1.287 or later and an indexed repo. The map and the savings row also need repowise serve --no-ui. Six userConfig toggles control it: lens_margin, lens_squeeze, lens_review and lens_flow (on by default), and lens_pane_autoopen and lens_map_health (off). In the map, j and k walk the files Claude's turn lit, z and u zoom into and out of a folder, x clears the selection and h switches health colours. On an older Claude Code, or where mods are switched off, the rest of the plugin works as before.
Guide: docs/agent/LENS.md
repowise CLI on PATH (/repowise:init installs it)MCP tools not connecting: run /repowise:init — the plugin auto-registers the MCP server, but the repowise binary must be installed and on PATH.
pip install fails on Windows: try python -m pip install repowise.
Semantic search / get_answer returns nothing: the wiki may be template-rendered (no embeddings). Run /repowise:reindex, or repowise generate to upgrade pages with a model.
Stale results after code changes: run /repowise:update.
The plugin itself is Markdown (commands and skills), a hooks.json, an .mcp.json and the Lens module. Everything it runs comes from the repowise CLI you install with pip:
.mcp.json starts repowise mcp, which reads the index in your repo's .repowise/ folder.repowise-augment when it is on PATH and do nothing otherwise. They read the local index only.repowise serve on a loopback address. It contacts no other host. The Lens list below says exactly what it runs and reads.Network traffic from the repowise CLI and MCP server:
https://api.repowise.dev/telemetry/events: command and tool names, coarse counts and timings, and an anonymous install id. Never source code, file paths, repo or symbol names, or query text. Turn it off with DO_NOT_TRACK=1 or REPOWISE_TELEMETRY_DISABLED=1; details in docs/reference/TELEMETRY.md.init and update, and get_answer, send code excerpts and your question to that provider. With no provider configured nothing is sent, and get_answer answers from local retrieval.repowise serve downloads its web UI from the project's GitHub release.repowise login, repowise publish, or sending feedback).Lens is the mod in hooks/lens/lens.js, built from packages/claude-mod. Everything it does goes through Claude Code's mods API:
$.process.run, read-only, 5 second timeout): git rev-parse --is-inside-work-tree to tell whether the session is in a git repo, and git rev-parse HEAD plus git diff --name-only <indexed> <HEAD> -- to count the files changed since the last index (<indexed> is the commit in .repowise/state.json, used only when it is a bare hex sha). To check that a repowise serve or repowise update named in a lock file is still running it runs tasklist /FI "PID eq <pid>" /NH /FO CSV on Windows and ps -p <pid> -o pid= elsewhere.$.fs): .repowise/state.json, .repowise/serve.lock.json and .repowise/.update.lock in the repo and the folders above the session's directory. It writes no files.$.mcp.call, on this plugin's own repowise server only): get_context for a file Claude starts reading or editing (the caller count and owner in the spinner), get_change_risk after a turn that edits files (the review card), and get_why or get_answer when you ask a question in the Ask tab or with /lens ask. get_answer may use the model your repo configures; the others are local. These calls go through Claude Code's permission rules like Claude's own; see Permissions below.$.http.fetch): the repowise serve address in .repowise/serve.lock.json, and only when that address is plain http on a loopback host (127.0.0.1, localhost or ::1). It asks that server for its health, its repo list, the health map, the blast radius of a file Claude edited (the file's path) and the savings ledger. Nothing it reads from the conversation goes anywhere else.turn.start; and at turn.complete the answer's text (to place the review card under it), the turn's duration and how it ended. It keeps these in memory for the session's Flow tab and map, and writes none of it to disk.$.prompt.submit), only when you press the button: Run tests sends Run the tests Repowise names for this change, ...: <test ids>. Brief Claude (offered after a compaction) sends The context was compacted. This brief is built from the Repowise index and this session's edits: followed by up to three lines, Files edited:, Decisions in play: and Open review items:, at most 1,200 characters. Why submits nothing: it fills the Ask field with why <decision title> and waits for Enter.tool.check hook, so it never approves or denies a tool call. Its tool.call hook passes every call and result on unchanged. turn.complete adds the review card as a line under Claude's answer, which Claude does not read. command.run answers only /lens (opening the pane on the tab you name, and asking with /lens ask). classic.PostCompact only offers the brief, and classic.PostToolUse reads the note the Repowise hook added for an edit to show it in the margin; both pass their event and result on unchanged.Proxy, getters or then methods of its own.Lens's own lookups ask Claude Code's permission like any MCP tool call. To let them run without a prompt, allow the four read-only tools once, for example with /permissions or in .claude/settings.json:
{
"permissions": {
"allow": [
"mcp__plugin_repowise_repowise__get_context",
"mcp__plugin_repowise_repowise__get_change_risk",
"mcp__plugin_repowise_repowise__get_why",
"mcp__plugin_repowise_repowise__get_answer"
]
}
}
Without the rules, a lookup that is refused (in auto mode, dontAsk, or claude -p) leaves that part of Lens empty: no file card in the spinner, no review card, or an error line in the Ask tab. The rest of Lens works.
AGPL-3.0, same as repowise. See the repository.
hooks/lens/lens.js 4958 lines1// Generated by packages/claude-mod (npm run build:mod). Do not edit.
2// ../api-client/src/client.ts
3var config = { baseUrl: "" };
4function configureApiClient(next) {
5 config = next;
6 BASE_URL = next.baseUrl;
7}
8var BASE_URL = "";
9function resolveToken() {
10 const { token } = config;
11 if (typeof token === "function") return token();
12 return token ?? null;
13}
14function requestHeaders(extra) {
15 const headers = {
16 "Content-Type": "application/json",
17 ...extra
18 };
19 const token = resolveToken();
20 if (token) {
21 headers["Authorization"] = `Bearer ${token}`;
22 }
23 return headers;
24}
25var ApiClientError = class extends Error {
26 constructor(status, detail) {
27 super(`API error ${status}: ${detail}`);
28 this.status = status;
29 this.detail = detail;
30 this.name = "ApiClientError";
31 }
32 status;
33 detail;
34};
35function stringifyDetail(detail) {
36 if (typeof detail === "string") return detail;
37 if (Array.isArray(detail)) {
38 const msgs = detail.map(
39 (d) => d && typeof d === "object" && "msg" in d ? String(d.msg) : null
40 ).filter((m) => !!m).map((m) => m.replace(/^Value error, /, ""));
41 if (msgs.length > 0) return msgs.join("; ");
42 }
43 return null;
44}
45async function handleResponse(res) {
46 if (!res.ok) {
47 let detail = res.statusText;
48 try {
49 const json = await res.json();
50 detail = stringifyDetail(json.detail) ?? detail;
51 } catch {
52 }
53 throw new ApiClientError(res.status, detail);
54 }
55 if (res.status === 204) return void 0;
56 return res.json();
57}
58function doFetch(url, init) {
59 return (config.fetch ?? fetch)(url, init);
60}
61function headersToRecord(headers) {
62 const out = {};
63 if (!headers) return out;
64 if (Array.isArray(headers)) {
65 for (const [name, value] of headers) out[name] = value;
66 } else if (typeof headers.forEach === "function") {
67 headers.forEach((value, name) => {
68 out[name] = value;
69 });
70 } else {
71 Object.assign(out, headers);
72 }
73 return out;
74}
75function createAdapterFetch(minimal) {
76 const adapted = async (input2, init) => {
77 const url = typeof input2 === "string" ? input2 : "href" in input2 ? input2.href : input2.url;
78 const body = init?.body;
79 if (body != null && typeof body !== "string") {
80 throw new TypeError("createAdapterFetch only sends string bodies");
81 }
82 const request = { headers: headersToRecord(init?.headers) };
83 if (init?.method) request.method = init.method;
84 if (body != null) request.body = body;
85 if (init?.signal) request.signal = init.signal;
86 const res = await minimal(url, request);
87 return {
88 ok: res.ok,
89 status: res.status,
90 statusText: `HTTP ${res.status}`,
91 json: async () => JSON.parse(res.text),
92 text: async () => res.text
93 };
94 };
95 return adapted;
96}
97async function apiGet(path, params, fetchOptions) {
98 const url = new URL(`${BASE_URL}${path}`, typeof window !== "undefined" ? window.location.href : "http://localhost");
99 if (params) {
100 for (const [k, v] of Object.entries(params)) {
101 if (v !== void 0 && v !== null) {
102 url.searchParams.set(k, String(v));
103 }
104 }
105 }
106 const res = await doFetch(url.toString(), {
107 method: "GET",
108 headers: requestHeaders(),
109 ...fetchOptions
110 });
111 return handleResponse(res);
112}
113async function apiPost(path, body, fetchOptions, params) {
114 const url = new URL(`${BASE_URL}${path}`, typeof window !== "undefined" ? window.location.href : "http://localhost");
115 if (params) {
116 for (const [k, v] of Object.entries(params)) {
117 if (v !== void 0 && v !== null) {
118 url.searchParams.set(k, String(v));
119 }
120 }
121 }
122 const res = await doFetch(url.toString(), {
123 method: "POST",
124 headers: requestHeaders(),
125 ...body !== void 0 ? { body: JSON.stringify(body) } : {},
126 ...fetchOptions
127 });
128 return handleResponse(res);
129}
130
131// ../api-client/src/costs.ts
132async function getSavings(repoId, opts = {}) {
133 return apiGet(`/api/repos/${repoId}/savings`, {
134 days: opts.days
135 });
136}
137
138// ../api-client/src/health.ts
139async function getHealth() {
140 return apiGet("/health");
141}
142
143// ../api-client/src/repos.ts
144async function listRepos() {
145 return apiGet("/api/repos");
146}
147
148// ../types/src/repos.ts
149function normalizeRepoPath(p, isWindows) {
150 const slashed = isWindows ? p.replace(/\\/g, "/") : p;
151 const trimmed = slashed.replace(/\/+$/, "");
152 return isWindows ? trimmed.toLowerCase() : trimmed;
153}
154
155// ../types/src/serve-lock.ts
156function isServeLock(value) {
157 if (typeof value !== "object" || value === null) return false;
158 const v = value;
159 return typeof v.pid === "number" && typeof v.host === "string" && typeof v.port === "number" && typeof v.url === "string" && (typeof v.ui_port === "number" || v.ui_port === null) && typeof v.server_version === "string" && typeof v.started_at === "string";
160}
161var LOOPBACK_V4 = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
162function isLoopbackUrl(url) {
163 try {
164 const u = new URL(url);
165 if (u.protocol !== "http:" && u.protocol !== "https:") return false;
166 return u.hostname === "localhost" || u.hostname === "[::1]" || LOOPBACK_V4.test(u.hostname);
167 } catch {
168 return false;
169 }
170}
171
172// src/data/transport.ts
173function connectApiClient(host, baseUrl) {
174 configureApiClient({ baseUrl, fetch: createAdapterFetch(host.http) });
175}
176var TimeoutError = class extends Error {
177 constructor(label, ms) {
178 super(`${label} timed out after ${ms} ms`);
179 this.name = "TimeoutError";
180 }
181};
182function withTimeout(work, ms, label) {
183 let timer;
184 const timeout = new Promise((_, reject) => {
185 timer = setTimeout(() => reject(new TimeoutError(label, ms)), ms);
186 });
187 return Promise.race([work, timeout]).finally(() => clearTimeout(timer));
188}
189
190// src/data/discovery.ts
191var HEALTH_TIMEOUT_MS = 800;
192var LIST_TIMEOUT_MS = 3e3;
193var CONNECT_TIMEOUT_MS = 35e3;
194var GIT_TIMEOUT_MS = 5e3;
195var MAX_WALK = 20;
196var SHA = /^[0-9a-f]{7,64}$/i;
197function isWindowsPath(p) {
198 return /^[A-Za-z]:[\\/]/.test(p) || p.startsWith("\\\\");
199}
200function join(dir, rel2) {
201 return `${dir.replace(/[\\/]+$/, "")}/${rel2}`;
202}
203function parentDir(p) {
204 const trimmed = p.replace(/[\\/]+$/, "");
205 const cut = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
206 if (cut < 0) return null;
207 if (cut === 0) return trimmed === "" ? null : "/";
208 return trimmed.slice(0, cut);
209}
210async function findIndexedRoot(host, cwd2) {
211 let dir = cwd2;
212 for (let i = 0; dir !== null && i < MAX_WALK; i++) {
213 if (await host.fs.exists(join(dir, ".repowise/state.json"))) return dir;
214 dir = parentDir(dir);
215 }
216 return null;
217}
218async function readJson(host, path) {
219 try {
220 return JSON.parse(await host.fs.read(path));
221 } catch {
222 return null;
223 }
224}
225async function readServeLock(host, repoRoot) {
226 const parsed = await readJson(host, join(repoRoot, ".repowise/serve.lock.json"));
227 return isServeLock(parsed) ? parsed : null;
228}
229async function isPidAlive(host, pid, cwd2) {
230 try {
231 if (isWindowsPath(cwd2)) {
232 const out2 = await host.process.run(["tasklist", "/FI", `PID eq ${pid}`, "/NH", "/FO", "CSV"], cwd2);
233 return out2.exitCode === 0 && out2.stdout.includes(`"${pid}"`);
234 }
235 const out = await host.process.run(["ps", "-p", String(pid), "-o", "pid="], cwd2);
236 return out.exitCode === 0 && out.stdout.trim() === String(pid);
237 } catch {
238 return null;
239 }
240}
241async function probeServer(host, lock, repoRoot) {
242 connectApiClient(host, lock.url);
243 try {
244 await withTimeout(getHealth(), HEALTH_TIMEOUT_MS, "health");
245 } catch {
246 return { kind: "down" };
247 }
248 try {
249 const repos = await withTimeout(listRepos(), LIST_TIMEOUT_MS, "listRepos");
250 const win = isWindowsPath(repoRoot);
251 const target = normalizeRepoPath(repoRoot, win);
252 const match = repos.find((r) => r.local_path && normalizeRepoPath(r.local_path, win) === target);
253 return match ? { kind: "ok", repoId: match.id, updatedAt: match.updated_at ?? null } : { kind: "unlisted" };
254 } catch (err) {
255 if (err instanceof ApiClientError && (err.status === 401 || err.status === 403)) return { kind: "auth" };
256 return { kind: "down" };
257 }
258}
259async function mcpReachable(host) {
260 try {
261 return await withTimeout(host.mcp.connect(), CONNECT_TIMEOUT_MS, "mcp connect");
262 } catch {
263 return false;
264 }
265}
266async function unindexedMode(host, cwd2) {
267 const inWorkTree = (await git(host, cwd2, ["rev-parse", "--is-inside-work-tree"]))?.trim() === "true";
268 if (!inWorkTree) return "no-repo";
269 return await mcpReachable(host) ? "no-index" : "no-cli";
270}
271async function serverState(host, repoRoot) {
272 const lock = await readServeLock(host, repoRoot);
273 if (!lock || !isLoopbackUrl(lock.url)) return { kind: "down" };
274 if (await isPidAlive(host, lock.pid, repoRoot) === false) return { kind: "down" };
275 return probeServer(host, lock, repoRoot);
276}
277async function discover(host) {
278 const cwd2 = await host.session.cwd();
279 const repoRoot = await findIndexedRoot(host, cwd2);
280 if (repoRoot === null) return { mode: await unindexedMode(host, cwd2), repoRoot };
281 const server = await serverState(host, repoRoot);
282 if (server.kind === "ok") return { mode: "full", repoRoot, repo: { id: server.repoId, updatedAt: server.updatedAt } };
283 if (!await mcpReachable(host)) return { mode: "no-cli", repoRoot };
284 const liteReason = server.kind === "down" ? "no-server" : server.kind;
285 return { mode: "lite", liteReason, repoRoot };
286}
287async function git(host, cwd2, args) {
288 try {
289 const out = await withTimeout(host.process.run(["git", ...args], cwd2), GIT_TIMEOUT_MS, "git");
290 return out.exitCode === 0 ? out.stdout : null;
291 } catch {
292 return null;
293 }
294}
295async function updateRunning(host, repoRoot) {
296 const lock = await readJson(host, join(repoRoot, ".repowise/.update.lock"));
297 const pid = lock?.pid;
298 return typeof pid === "number" && await isPidAlive(host, pid, repoRoot) === true;
299}
300async function readFreshness(host, repoRoot) {
301 const state2 = await readJson(host, join(repoRoot, ".repowise/state.json"));
302 const indexed = state2?.last_sync_commit;
303 if (typeof indexed !== "string" || !SHA.test(indexed)) return null;
304 const head = (await git(host, repoRoot, ["rev-parse", "HEAD"]))?.trim();
305 if (!head || !SHA.test(head) || head === indexed) return null;
306 if (await updateRunning(host, repoRoot)) return null;
307 const diff = await git(host, repoRoot, ["diff", "--name-only", indexed, head, "--"]);
308 const changedFiles = diff === null ? null : diff.split("\n").filter((l) => l.trim() !== "").length;
309 return { changedFiles };
310}
311
312// src/data/mcp.ts
313var PLUGIN_SERVER_FORMS = ["plugin:repowise:repowise", "plugin_repowise_repowise"];
314var DEFAULT_TIMEOUT_MS = 1e4;
315var resolvedServer = null;
316var resolving = null;
317function resetMcp() {
318 resolvedServer = null;
319 resolving = null;
320}
321function warmMcp(host) {
322 if (resolvedServer !== null || resolving !== null) return;
323 const asked = host.mcp.server().then((name) => {
324 if (name !== null && PLUGIN_SERVER_FORMS.includes(name)) resolvedServer = name;
325 }).catch(() => void 0).finally(() => {
326 if (resolving === asked) resolving = null;
327 });
328 resolving = asked;
329}
330async function mcpReady(host, timeoutMs = DEFAULT_TIMEOUT_MS) {
331 warmMcp(host);
332 const pending = resolving;
333 if (resolvedServer === null && pending !== null) await withTimeout(pending, timeoutMs, "MCP connect").catch(() => void 0);
334 return resolvedServer !== null;
335}
336function parse(result, tool) {
337 const text2 = result.content[0]?.text;
338 if (result.isError) throw new Error(`${tool} failed: ${String(text2).slice(0, 200)}`);
339 if (typeof text2 !== "string") throw new Error(`${tool} returned no text`);
340 const parsed = JSON.parse(text2);
341 return parsed !== null && typeof parsed === "object" && "result" in parsed ? parsed.result : parsed;
342}
343function callTool(host, tool, args, opts = {}) {
344 if (resolvedServer === null) {
345 warmMcp(host);
346 return Promise.reject(new Error("repowise MCP server name not resolved yet"));
347 }
348 const call = host.mcp.call(resolvedServer, tool, args).then((r) => parse(r, tool));
349 return withTimeout(call, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, tool);
350}
351function recentOwner(o) {
352 const name = o?.recent_owner;
353 const share = o?.recent_owner_pct;
354 return typeof name === "string" && name !== "" && typeof share === "number" ? { name, share } : null;
355}
356async function fetchFileContext(host, path) {
357 const result = await callTool(host, "get_context", {
358 targets: [path],
359 include: ["callers", "ownership"]
360 });
361 const target = result.targets?.[path];
362 if (target === void 0 || target.error !== void 0) return null;
363 const callers = target.callers_total ?? target.callers?.length;
364 const contributors = target.ownership?.contributor_count;
365 return {
366 callerFiles: typeof callers === "number" ? callers : null,
367 contributors: typeof contributors === "number" ? contributors : null,
368 hotspot: typeof target.hotspot === "boolean" ? target.hotspot : null,
369 recentOwner: recentOwner(target.ownership)
370 };
371}
372
373// ../ui/src/lib/format.ts
374function parseDate(date) {
375 if (date instanceof Date) return date;
376 if (!/[zZ]|[+-]\d\d?:\d\d$/.test(date.trim())) {
377 return /* @__PURE__ */ new Date(`${date.trim()}Z`);
378 }
379 return new Date(date);
380}
381var NUMBER_FORMAT = new Intl.NumberFormat("en-US");
382function formatNumber(n) {
383 return NUMBER_FORMAT.format(n);
384}
385function formatRelativeTime(date) {
386 const d = parseDate(date);
387 const now = Date.now();
388 const diff = now - d.getTime();
389 const seconds = Math.floor(diff / 1e3);
390 const minutes = Math.floor(seconds / 60);
391 const hours = Math.floor(minutes / 60);
392 const days = Math.floor(hours / 24);
393 const weeks = Math.floor(days / 7);
394 const months = Math.floor(days / 30);
395 if (seconds < 10) return "just now";
396 if (seconds < 60) return `${seconds}s ago`;
397 if (minutes < 60) return `${minutes}m ago`;
398 if (hours < 24) return `${hours}h ago`;
399 if (days < 7) return `${days}d ago`;
400 if (weeks < 5) return `${weeks}w ago`;
401 if (months < 12) return `${months}mo ago`;
402 return `${Math.floor(months / 12)}y ago`;
403}
404function formatRelativeTimeOrNull(iso, fallback = "—") {
405 if (!iso) return fallback;
406 const d = parseDate(iso);
407 if (Number.isNaN(d.getTime()) || d.getTime() > Date.now()) return fallback;
408 return formatRelativeTime(d);
409}
410
411// src/format.ts
412function countOf(n, singular, plural) {
413 return `${formatNumber(n)} ${n === 1 ? singular : plural}`;
414}
415function fit(text2, columns) {
416 if (columns <= 0) return "";
417 if (text2.length <= columns) return text2;
418 return `${text2.slice(0, columns - 1).trimEnd()}…`;
419}
420var NOT_ONE_CELL = /[\u0000-\u001f\u007f-\u009f\ud800-\udfffᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/;
421function cellSafe(text2) {
422 return Array.from(text2, (ch) => ch.length === 1 && !NOT_ONE_CELL.test(ch) ? ch : "?").join("");
423}
424
425// ../../node_modules/d3-hierarchy/src/hierarchy/count.js
426function count(node) {
427 var sum = 0, children = node.children, i = children && children.length;
428 if (!i) sum = 1;
429 else while (--i >= 0) sum += children[i].value;
430 node.value = sum;
431}
432function count_default() {
433 return this.eachAfter(count);
434}
435
436// ../../node_modules/d3-hierarchy/src/hierarchy/each.js
437function each_default(callback, that) {
438 let index = -1;
439 for (const node of this) {
440 callback.call(that, node, ++index, this);
441 }
442 return this;
443}
444
445// ../../node_modules/d3-hierarchy/src/hierarchy/eachBefore.js
446function eachBefore_default(callback, that) {
447 var node = this, nodes = [node], children, i, index = -1;
448 while (node = nodes.pop()) {
449 callback.call(that, node, ++index, this);
450 if (children = node.children) {
451 for (i = children.length - 1; i >= 0; --i) {
452 nodes.push(children[i]);
453 }
454 }
455 }
456 return this;
457}
458
459// ../../node_modules/d3-hierarchy/src/hierarchy/eachAfter.js
460function eachAfter_default(callback, that) {
461 var node = this, nodes = [node], next = [], children, i, n, index = -1;
462 while (node = nodes.pop()) {
463 next.push(node);
464 if (children = node.children) {
465 for (i = 0, n = children.length; i < n; ++i) {
466 nodes.push(children[i]);
467 }
468 }
469 }
470 while (node = next.pop()) {
471 callback.call(that, node, ++index, this);
472 }
473 return this;
474}
475
476// ../../node_modules/d3-hierarchy/src/hierarchy/find.js
477function find_default(callback, that) {
478 let index = -1;
479 for (const node of this) {
480 if (callback.call(that, node, ++index, this)) {
481 return node;
482 }
483 }
484}
485
486// ../../node_modules/d3-hierarchy/src/hierarchy/sum.js
487function sum_default(value) {
488 return this.eachAfter(function(node) {
489 var sum = +value(node.data) || 0, children = node.children, i = children && children.length;
490 while (--i >= 0) sum += children[i].value;
491 node.value = sum;
492 });
493}
494
495// ../../node_modules/d3-hierarchy/src/hierarchy/sort.js
496function sort_default(compare) {
497 return this.eachBefore(function(node) {
498 if (node.children) {
499 node.children.sort(compare);
500 }
501 });
502}
503
504// ../../node_modules/d3-hierarchy/src/hierarchy/path.js
505function path_default(end) {
506 var start = this, ancestor = leastCommonAncestor(start, end), nodes = [start];
507 while (start !== ancestor) {
508 start = start.parent;
509 nodes.push(start);
510 }
511 var k = nodes.length;
512 while (end !== ancestor) {
513 nodes.splice(k, 0, end);
514 end = end.parent;
515 }
516 return nodes;
517}
518function leastCommonAncestor(a, b) {
519 if (a === b) return a;
520 var aNodes = a.ancestors(), bNodes = b.ancestors(), c = null;
521 a = aNodes.pop();
522 b = bNodes.pop();
523 while (a === b) {
524 c = a;
525 a = aNodes.pop();
526 b = bNodes.pop();
527 }
528 return c;
529}
530
531// ../../node_modules/d3-hierarchy/src/hierarchy/ancestors.js
532function ancestors_default() {
533 var node = this, nodes = [node];
534 while (node = node.parent) {
535 nodes.push(node);
536 }
537 return nodes;
538}
539
540// ../../node_modules/d3-hierarchy/src/hierarchy/descendants.js
541function descendants_default() {
542 return Array.from(this);
543}
544
545// ../../node_modules/d3-hierarchy/src/hierarchy/leaves.js
546function leaves_default() {
547 var leaves = [];
548 this.eachBefore(function(node) {
549 if (!node.children) {
550 leaves.push(node);
551 }
552 });
553 return leaves;
554}
555
556// ../../node_modules/d3-hierarchy/src/hierarchy/links.js
557function links_default() {
558 var root = this, links = [];
559 root.each(function(node) {
560 if (node !== root) {
561 links.push({ source: node.parent, target: node });
562 }
563 });
564 return links;
565}
566
567// ../../node_modules/d3-hierarchy/src/hierarchy/iterator.js
568function* iterator_default() {
569 var node = this, current, next = [node], children, i, n;
570 do {
571 current = next.reverse(), next = [];
572 while (node = current.pop()) {
573 yield node;
574 if (children = node.children) {
575 for (i = 0, n = children.length; i < n; ++i) {
576 next.push(children[i]);
577 }
578 }
579 }
580 } while (next.length);
581}
582
583// ../../node_modules/d3-hierarchy/src/hierarchy/index.js
584function hierarchy(data, children) {
585 if (data instanceof Map) {
586 data = [void 0, data];
587 if (children === void 0) children = mapChildren;
588 } else if (children === void 0) {
589 children = objectChildren;
590 }
591 var root = new Node(data), node, nodes = [root], child, childs, i, n;
592 while (node = nodes.pop()) {
593 if ((childs = children(node.data)) && (n = (childs = Array.from(childs)).length)) {
594 node.children = childs;
595 for (i = n - 1; i >= 0; --i) {
596 nodes.push(child = childs[i] = new Node(childs[i]));
597 child.parent = node;
598 child.depth = node.depth + 1;
599 }
600 }
601 }
602 return root.eachBefore(computeHeight);
603}
604function node_copy() {
605 return hierarchy(this).eachBefore(copyData);
606}
607function objectChildren(d) {
608 return d.children;
609}
610function mapChildren(d) {
611 return Array.isArray(d) ? d[1] : null;
612}
613function copyData(node) {
614 if (node.data.value !== void 0) node.value = node.data.value;
615 node.data = node.data.data;
616}
617function computeHeight(node) {
618 var height = 0;
619 do
620 node.height = height;
621 while ((node = node.parent) && node.height < ++height);
622}
623function Node(data) {
624 this.data = data;
625 this.depth = this.height = 0;
626 this.parent = null;
627}
628Node.prototype = hierarchy.prototype = {
629 constructor: Node,
630 count: count_default,
631 each: each_default,
632 eachAfter: eachAfter_default,
633 eachBefore: eachBefore_default,
634 find: find_default,
635 sum: sum_default,
636 sort: sort_default,
637 path: path_default,
638 ancestors: ancestors_default,
639 descendants: descendants_default,
640 leaves: leaves_default,
641 links: links_default,
642 copy: node_copy,
643 [Symbol.iterator]: iterator_default
644};
645
646// ../../node_modules/d3-hierarchy/src/accessors.js
647function required(f) {
648 if (typeof f !== "function") throw new Error();
649 return f;
650}
651
652// ../../node_modules/d3-hierarchy/src/constant.js
653function constantZero() {
654 return 0;
655}
656function constant_default(x) {
657 return function() {
658 return x;
659 };
660}
661
662// ../../node_modules/d3-hierarchy/src/treemap/round.js
663function round_default(node) {
664 node.x0 = Math.round(node.x0);
665 node.y0 = Math.round(node.y0);
666 node.x1 = Math.round(node.x1);
667 node.y1 = Math.round(node.y1);
668}
669
670// ../../node_modules/d3-hierarchy/src/treemap/dice.js
671function dice_default(parent, x0, y0, x1, y1) {
672 var nodes = parent.children, node, i = -1, n = nodes.length, k = parent.value && (x1 - x0) / parent.value;
673 while (++i < n) {
674 node = nodes[i], node.y0 = y0, node.y1 = y1;
675 node.x0 = x0, node.x1 = x0 += node.value * k;
676 }
677}
678
679// ../../node_modules/d3-hierarchy/src/treemap/slice.js
680function slice_default(parent, x0, y0, x1, y1) {
681 var nodes = parent.children, node, i = -1, n = nodes.length, k = parent.value && (y1 - y0) / parent.value;
682 while (++i < n) {
683 node = nodes[i], node.x0 = x0, node.x1 = x1;
684 node.y0 = y0, node.y1 = y0 += node.value * k;
685 }
686}
687
688// ../../node_modules/d3-hierarchy/src/treemap/squarify.js
689var phi = (1 + Math.sqrt(5)) / 2;
690function squarifyRatio(ratio, parent, x0, y0, x1, y1) {
691 var rows = [], nodes = parent.children, row2, nodeValue, i0 = 0, i1 = 0, n = nodes.length, dx, dy, value = parent.value, sumValue, minValue, maxValue, newRatio, minRatio, alpha, beta;
692 while (i0 < n) {
693 dx = x1 - x0, dy = y1 - y0;
694 do
695 sumValue = nodes[i1++].value;
696 while (!sumValue && i1 < n);
697 minValue = maxValue = sumValue;
698 alpha = Math.max(dy / dx, dx / dy) / (value * ratio);
699 beta = sumValue * sumValue * alpha;
700 minRatio = Math.max(maxValue / beta, beta / minValue);
701 for (; i1 < n; ++i1) {
702 sumValue += nodeValue = nodes[i1].value;
703 if (nodeValue < minValue) minValue = nodeValue;
704 if (nodeValue > maxValue) maxValue = nodeValue;
705 beta = sumValue * sumValue * alpha;
706 newRatio = Math.max(maxValue / beta, beta / minValue);
707 if (newRatio > minRatio) {
708 sumValue -= nodeValue;
709 break;
710 }
711 minRatio = newRatio;
712 }
713 rows.push(row2 = { value: sumValue, dice: dx < dy, children: nodes.slice(i0, i1) });
714 if (row2.dice) dice_default(row2, x0, y0, x1, value ? y0 += dy * sumValue / value : y1);
715 else slice_default(row2, x0, y0, value ? x0 += dx * sumValue / value : x1, y1);
716 value -= sumValue, i0 = i1;
717 }
718 return rows;
719}
720var squarify_default = (function custom(ratio) {
721 function squarify(parent, x0, y0, x1, y1) {
722 squarifyRatio(ratio, parent, x0, y0, x1, y1);
723 }
724 squarify.ratio = function(x) {
725 return custom((x = +x) > 1 ? x : 1);
726 };
727 return squarify;
728})(phi);
729
730// ../../node_modules/d3-hierarchy/src/treemap/index.js
731function treemap_default() {
732 var tile = squarify_default, round = false, dx = 1, dy = 1, paddingStack = [0], paddingInner = constantZero, paddingTop = constantZero, paddingRight = constantZero, paddingBottom = constantZero, paddingLeft = constantZero;
733 function treemap(root) {
734 root.x0 = root.y0 = 0;
735 root.x1 = dx;
736 root.y1 = dy;
737 root.eachBefore(positionNode);
738 paddingStack = [0];
739 if (round) root.eachBefore(round_default);
740 return root;
741 }
742 function positionNode(node) {
743 var p = paddingStack[node.depth], x0 = node.x0 + p, y0 = node.y0 + p, x1 = node.x1 - p, y1 = node.y1 - p;
744 if (x1 < x0) x0 = x1 = (x0 + x1) / 2;
745 if (y1 < y0) y0 = y1 = (y0 + y1) / 2;
746 node.x0 = x0;
747 node.y0 = y0;
748 node.x1 = x1;
749 node.y1 = y1;
750 if (node.children) {
751 p = paddingStack[node.depth + 1] = paddingInner(node) / 2;
752 x0 += paddingLeft(node) - p;
753 y0 += paddingTop(node) - p;
754 x1 -= paddingRight(node) - p;
755 y1 -= paddingBottom(node) - p;
756 if (x1 < x0) x0 = x1 = (x0 + x1) / 2;
757 if (y1 < y0) y0 = y1 = (y0 + y1) / 2;
758 tile(node, x0, y0, x1, y1);
759 }
760 }
761 treemap.round = function(x) {
762 return arguments.length ? (round = !!x, treemap) : round;
763 };
764 treemap.size = function(x) {
765 return arguments.length ? (dx = +x[0], dy = +x[1], treemap) : [dx, dy];
766 };
767 treemap.tile = function(x) {
768 return arguments.length ? (tile = required(x), treemap) : tile;
769 };
770 treemap.padding = function(x) {
771 return arguments.length ? treemap.paddingInner(x).paddingOuter(x) : treemap.paddingInner();
772 };
773 treemap.paddingInner = function(x) {
774 return arguments.length ? (paddingInner = typeof x === "function" ? x : constant_default(+x), treemap) : paddingInner;
775 };
776 treemap.paddingOuter = function(x) {
777 return arguments.length ? treemap.paddingTop(x).paddingRight(x).paddingBottom(x).paddingLeft(x) : treemap.paddingTop();
778 };
779 treemap.paddingTop = function(x) {
780 return arguments.length ? (paddingTop = typeof x === "function" ? x : constant_default(+x), treemap) : paddingTop;
781 };
782 treemap.paddingRight = function(x) {
783 return arguments.length ? (paddingRight = typeof x === "function" ? x : constant_default(+x), treemap) : paddingRight;
784 };
785 treemap.paddingBottom = function(x) {
786 return arguments.length ? (paddingBottom = typeof x === "function" ? x : constant_default(+x), treemap) : paddingBottom;
787 };
788 treemap.paddingLeft = function(x) {
789 return arguments.length ? (paddingLeft = typeof x === "function" ? x : constant_default(+x), treemap) : paddingLeft;
790 };
791 return treemap;
792}
793
794// ../types/src/health.ts
795var EXCELLENT_MIN = 8.5;
796var GOOD_MIN = 7;
797var FAIR_MIN = 5.5;
798var NEEDS_WORK_MIN = 4;
799var HEALTH_BAND_ORDER = [
800 "at_risk",
801 "needs_work",
802 "fair",
803 "good",
804 "excellent"
805];
806var HEALTH_BAND_LABEL = {
807 excellent: "Excellent",
808 good: "Good",
809 fair: "Fair",
810 needs_work: "Needs work",
811 at_risk: "At risk"
812};
813function bandForScore(score) {
814 if (score >= EXCELLENT_MIN) return "excellent";
815 if (score >= GOOD_MIN) return "good";
816 if (score >= FAIR_MIN) return "fair";
817 if (score >= NEEDS_WORK_MIN) return "needs_work";
818 return "at_risk";
819}
820
821// ../ui/src/brand.ts
822var BRAND = {
823 /** Accent orange — the brand fill (CTAs, owl eyes, highlights). */
824 accent: "#f59520",
825 /** Accent text on light surfaces (darkened for 4.5:1 contrast). */
826 accentTextLight: "#a16215",
827 /** Brand plum — logo strokes on light surfaces, secondary accent. */
828 plum: "#473659",
829 /** Plum near-black — primary ink on warm paper. */
830 ink: "#241b2c",
831 /** Warm paper — the light-mode page background. */
832 paper: "#fbf6f1",
833 /** Cream — the logo stroke color on dark surfaces. */
834 cream: "#fce9dd"
835};
836var LIGHT = {
837 bgRoot: "#fbf6f1",
838 bgSurface: "#ffffff",
839 bgElevated: "#fbf4ee",
840 bgInset: "#f4eae1",
841 textPrimary: "#241b2c",
842 textSecondary: "#5e5360",
843 textTertiary: "#8c7f88",
844 accentPrimary: "#a16215",
845 accentFill: "#f59520",
846 accentSecondary: "#58436c",
847 success: "#1d8155",
848 warning: "#9a6614",
849 error: "#b23a2e"
850};
851var DARK = {
852 bgRoot: "#0e0e0f",
853 bgSurface: "#141416",
854 bgElevated: "#191a1c",
855 bgInset: "#0a0a0b",
856 textPrimary: "#f2f2f3",
857 textSecondary: "#b4b4b9",
858 textTertiary: "#7c7c82",
859 accentPrimary: "#f59520",
860 accentFill: "#f59520",
861 accentSecondary: "#a98fc4",
862 success: "#34d399",
863 warning: "#f2a03d",
864 error: "#e06a5a"
865};
866var DARK_CANVAS = {
867 nodeAtRisk: "#b0544b",
868 nodeNeedsWork: "#bd7c42",
869 nodeFair: "#a89453",
870 nodeGood: "#42906f",
871 nodeExcellent: "#5cb389",
872 nodeNeutral: "#333336",
873 caution: "#d9b04a"
874};
875var DARK_CANVAS_BAND = {
876 excellent: DARK_CANVAS.nodeExcellent,
877 good: DARK_CANVAS.nodeGood,
878 fair: DARK_CANVAS.nodeFair,
879 needs_work: DARK_CANVAS.nodeNeedsWork,
880 at_risk: DARK_CANVAS.nodeAtRisk
881};
882
883// src/views/map.ts
884var UPPER_HALF = 9600;
885var LOWER_HALF = 9604;
886var SPACE = 32;
887var hex = (s) => parseInt(s.slice(1), 16);
888var GROUND = hex(DARK.bgRoot);
889var TERMINAL_DEFAULT = 16777216;
890var LABEL_FG = hex(DARK.textSecondary);
891var NEUTRAL = hex(DARK_CANVAS.nodeNeutral);
892var BLACK = 0;
893var MAP_PALETTES = {
894 dark: {
895 tileA: mix(hex(DARK.bgElevated), hex(DARK.textTertiary), 0.12),
896 tileB: mix(hex(DARK.bgElevated), hex(DARK.textTertiary), 0.22),
897 folder: hex(DARK.textTertiary),
898 hit: mix(hex(DARK.bgElevated), hex(DARK.textSecondary), 0.32),
899 read: hex(DARK.textSecondary),
900 named: mix(hex(DARK.accentSecondary), hex(DARK.bgRoot), 0.45),
901 importer: hex(DARK.accentSecondary),
902 edit: hex(BRAND.accent),
903 ring: hex(BRAND.cream)
904 },
905 light: {
906 tileA: mix(hex(LIGHT.bgInset), hex(LIGHT.textTertiary), 0.08),
907 tileB: mix(hex(LIGHT.bgInset), hex(LIGHT.textTertiary), 0.16),
908 folder: hex(LIGHT.textTertiary),
909 hit: mix(hex(LIGHT.bgInset), hex(LIGHT.textSecondary), 0.3),
910 read: hex(LIGHT.textSecondary),
911 named: mix(hex(LIGHT.accentSecondary), hex(LIGHT.bgRoot), 0.55),
912 importer: hex(LIGHT.accentSecondary),
913 edit: hex(BRAND.accentTextLight),
914 ring: hex(LIGHT.textPrimary)
915 }
916};
917var DEFAULT_STYLE = { theme: "dark", health: false };
918var DENSE_SHARE = 0.6;
919var SPLIT_SHARE = 0.25;
920var MAX_SPLITS = 8;
921var GUTTER_MIN = 6;
922var EDGE_MIN = 3;
923function colorForScore(score) {
924 return score === null ? NEUTRAL : hex(DARK_CANVAS_BAND[bandForScore(score)]);
925}
926function mix(a, b, amount) {
927 const ch = (shift) => {
928 const x = a >> shift & 255;
929 const y = b >> shift & 255;
930 return Math.round(x + (y - x) * amount) << shift;
931 };
932 return (ch(16) | ch(8) | ch(0)) >>> 0;
933}
934var byName = (a, b) => a < b ? -1 : a > b ? 1 : 0;
935function dirsOf(path) {
936 return path.split("/").slice(0, -1);
937}
938function keyOf(path, split) {
939 const dirs = dirsOf(path);
940 let depth = Math.min(1, dirs.length);
941 while (depth < dirs.length && split.has(dirs.slice(0, depth).join("/"))) depth++;
942 return dirs.slice(0, depth).join("/");
943}
944function nextSplit(files, split, limit) {
945 const weight = /* @__PURE__ */ new Map();
946 for (const f of files) {
947 const key2 = keyOf(f.file_path, split);
948 weight.set(key2, (weight.get(key2) ?? 0) + f.nloc);
949 }
950 const heavy = [...weight].filter(([key2, w]) => key2 !== "" && w > limit).sort((a, b) => b[1] - a[1] || byName(a[0], b[0]));
951 for (const [key2] of heavy) {
952 const deeper = /* @__PURE__ */ new Set([...split, key2]);
953 const parts = new Set(files.filter((f) => keyOf(f.file_path, split) === key2).map((f) => keyOf(f.file_path, deeper)));
954 if (parts.size > 1) return key2;
955 }
956 return null;
957}
958function groupKeys(files) {
959 const split = /* @__PURE__ */ new Set();
960 const limit = files.reduce((sum, f) => sum + f.nloc, 0) * SPLIT_SHARE;
961 for (let i = 0; i < MAX_SPLITS; i++) {
962 const key2 = nextSplit(files, split, limit);
963 if (key2 === null) break;
964 split.add(key2);
965 }
966 return files.map((f) => keyOf(f.file_path, split));
967}
968var folderDatum = (name) => ({ name, children: [], kids: /* @__PURE__ */ new Map() });
969function insertFile(group, key2, file) {
970 const rel2 = key2 === "" ? file.file_path : file.file_path.slice(key2.length + 1);
971 let node = group;
972 for (const dir of rel2.split("/").slice(0, -1)) {
973 let next = node.kids?.get(dir);
974 if (next === void 0) {
975 next = folderDatum(dir);
976 node.kids?.set(dir, next);
977 node.children?.push(next);
978 }
979 node = next;
980 }
981 node.children?.push({ name: file.file_path, file });
982}
983function treemapOf(files, width2, height) {
984 const keys = groupKeys(files);
985 const groups = /* @__PURE__ */ new Map();
986 files.forEach((file, i) => {
987 const key2 = keys[i] ?? "";
988 const group = groups.get(key2) ?? folderDatum(key2);
989 groups.set(key2, group);
990 insertFile(group, key2, file);
991 });
992 const data = { name: "", children: [...groups.values()] };
993 const root = hierarchy(data).sum((d) => d.file?.nloc ?? 0).sort((a, b) => (b.value ?? 0) - (a.value ?? 0) || byName(a.data.name, b.data.name));
994 return treemap_default().tile(squarify_default).size([width2, height])(root);
995}
996var rectOf = (n) => ({ x: n.x0, y: n.y0, w: n.x1 - n.x0, h: n.y1 - n.y0 });
997function pixelSpan(from, to) {
998 return [Math.ceil(from - 0.5), Math.ceil(to - 0.5)];
999}
1000function groupInterior(rect, width2, height) {
1001 const [x0, x1] = pixelSpan(rect.x, rect.x + rect.w);
1002 const [y0, y1] = pixelSpan(rect.y, rect.y + rect.h);
1003 const gutter = x1 - x0 >= GUTTER_MIN && y1 - y0 >= GUTTER_MIN;
1004 return { x0, y0, x1: gutter && x1 < width2 ? x1 - 1 : x1, y1: gutter && y1 < height ? y1 - 1 : y1 };
1005}
1006function placeFile(leaf, group, inner) {
1007 const file = leaf.data.file;
1008 const rect = rectOf(leaf);
1009 const [x0, x1] = pixelSpan(rect.x, rect.x + rect.w);
1010 const [y0, y1] = pixelSpan(rect.y, rect.y + rect.h);
1011 return {
1012 path: file.file_path,
1013 score: file.score,
1014 nloc: file.nloc,
1015 group,
1016 rect,
1017 px0: Math.max(x0, inner.x0),
1018 py0: Math.max(y0, inner.y0),
1019 px1: Math.min(x1, inner.x1),
1020 py1: Math.min(y1, inner.y1)
1021 };
1022}
1023function tileFor(f, i, style) {
1024 const pal = MAP_PALETTES[style.theme];
1025 if (style.health) return colorForScore(f.score);
1026 return i % 2 === 0 ? pal.tileA : pal.tileB;
1027}
1028function paintBox(base, width2, box2, fill) {
1029 for (let y = box2.y0; y < box2.y1; y++) base.fill(fill, y * width2 + box2.x0, y * width2 + box2.x1);
1030}
1031function folderPath(group, node) {
1032 const below2 = node.ancestors().reverse().slice(2).map((n) => n.data.name);
1033 return [group.data.name, ...below2].filter((part) => part !== "").join("/");
1034}
1035function folderTiles(group, inner) {
1036 const out = [];
1037 for (const node of group.descendants()) {
1038 if (node === group || node.children === void 0) continue;
1039 const tone = (node.parent?.children ?? []).indexOf(node) % 2;
1040 const [x0, x1] = pixelSpan(node.x0, node.x1);
1041 const [y0, y1] = pixelSpan(node.y0, node.y1);
1042 const box2 = { x0: Math.max(x0, inner.x0), y0: Math.max(y0, inner.y0), x1: Math.min(x1, inner.x1), y1: Math.min(y1, inner.y1) };
1043 out.push({ path: folderPath(group, node), ...box2, tone });
1044 }
1045 return out;
1046}
1047var within = (f, x, y) => x >= f.x0 && x < f.x1 && y >= f.y0 && y < f.y1;
1048function folderAt(layout, x, y) {
1049 const tile = layout.folders.filter((f) => within(f, x, y)).at(-1);
1050 if (tile !== void 0) return tile.path;
1051 const group = layout.groups.find((g) => within({ x0: g.rect.x, y0: g.rect.y, x1: g.rect.x + g.rect.w, y1: g.rect.y + g.rect.h }, x, y));
1052 return group === void 0 || group.key === "" ? null : group.key;
1053}
1054function paintFile(base, width2, f, fill) {
1055 const edge = mix(fill, BLACK, 0.3);
1056 const edgeX = f.px1 - f.px0 >= EDGE_MIN ? f.px1 - 1 : -1;
1057 const edgeY = f.py1 - f.py0 >= EDGE_MIN ? f.py1 - 1 : -1;
1058 for (let y = f.py0; y < f.py1; y++) {
1059 base.fill(y === edgeY ? edge : fill, y * width2 + f.px0, y * width2 + f.px1);
1060 if (edgeX >= 0) base[y * width2 + edgeX] = edge;
1061 }
1062}
1063function labelText(key2, width2) {
1064 const parts = key2.split("/");
1065 const rungs = [key2, parts.slice(-2).join("/"), parts[parts.length - 1] ?? ""];
1066 const text2 = rungs.find((r) => r.length > 0 && r.length <= width2);
1067 return text2 !== void 0 && /^[\x20-\x7e]+$/.test(text2) ? text2 : null;
1068}
1069var LABEL_MIN_COLS = 12;
1070var LABEL_MIN_ROWS = 3;
1071var MAX_LABELS = 8;
1072function labelFor(g, columns, rows, root) {
1073 const col = Math.ceil(g.rect.x);
1074 const row2 = Math.ceil(g.rect.y / 2);
1075 const width2 = Math.min(columns, Math.floor(g.rect.x + g.rect.w)) - col - 1;
1076 const height = Math.floor((g.rect.y + g.rect.h) / 2) - row2;
1077 const small = height < LABEL_MIN_ROWS || width2 < LABEL_MIN_COLS;
1078 const key2 = root === null ? g.key : g.key.slice(root.length + 1);
1079 if (key2 === "" || small || row2 >= rows) return null;
1080 const text2 = labelText(key2, width2);
1081 return text2 === null ? null : { row: row2, col, text: text2 };
1082}
1083function labelsFor(groups, columns, rows, root = null) {
1084 return [...groups].sort((a, b) => b.nloc - a.nloc).map((g) => labelFor(g, columns, rows, root)).filter((l) => l !== null).slice(0, MAX_LABELS);
1085}
1086function fileLabel(f, rows) {
1087 const text2 = cellSafe(f.path.slice(f.path.lastIndexOf("/") + 1));
1088 const row2 = Math.ceil(f.py0 / 2);
1089 const roomy = f.px1 - f.px0 >= text2.length + 2 && Math.floor(f.py1 / 2) > row2;
1090 return roomy && row2 < rows ? { row: row2, col: f.px0 + 1, text: text2 } : null;
1091}
1092function fileLabels(files, rows, taken) {
1093 const used = new Set(taken.flatMap((l) => [...l.text].map((_, k) => `${l.row}:${l.col + k}`)));
1094 const out = [];
1095 for (const label of files.map((f) => fileLabel(f, rows))) {
1096 if (label === null) continue;
1097 const cells = [...label.text].map((_, k) => `${label.row}:${label.col + k}`);
1098 if (cells.some((c) => used.has(c))) continue;
1099 cells.forEach((c) => used.add(c));
1100 out.push(label);
1101 }
1102 return out;
1103}
1104function paintBase(map2, style, dense) {
1105 const { width: width2, height } = map2;
1106 const base = new Uint32Array(width2 * height).fill(GROUND);
1107 const pal = MAP_PALETTES[style.theme];
1108 const tone = (t) => t === 0 ? pal.tileB : pal.tileA;
1109 if (!style.health) map2.groups.forEach((g, i) => paintBox(base, width2, groupInterior(g.rect, width2, height), tone(i % 2)));
1110 if (!style.health) for (const f of map2.folders) paintBox(base, width2, f, tone(1 - f.tone));
1111 if (dense && !style.health) return base;
1112 for (const i of map2.drawn) paintFile(base, width2, map2.files[i], tileFor(map2.files[i], i, style));
1113 return base;
1114}
1115function filesUnder(feed, root) {
1116 const lines = feed.filter((f) => f.nloc > 0);
1117 return root === null ? lines : lines.filter((f) => f.file_path.startsWith(`${root}/`));
1118}
1119function layoutMap(feed, canvas) {
1120 const { columns, rows, caseInsensitive } = canvas;
1121 const style = canvas.style ?? DEFAULT_STYLE;
1122 const width2 = columns;
1123 const height = rows * 2;
1124 const zoom = canvas.root ?? null;
1125 const root = treemapOf(filesUnder(feed, zoom), width2, height);
1126 const groups = [];
1127 const files = [];
1128 const folders = [];
1129 for (const node of root.children ?? []) {
1130 const group = groups.push({ key: node.data.name, rect: rectOf(node), nloc: node.value ?? 0 }) - 1;
1131 const inner = groupInterior(rectOf(node), width2, height);
1132 for (const leaf of node.leaves()) files.push(placeFile(leaf, group, inner));
1133 folders.push(...folderTiles(node, inner));
1134 }
1135 const drawn = [];
1136 const index = /* @__PURE__ */ new Map();
1137 const lower = /* @__PURE__ */ new Map();
1138 files.forEach((f, i) => {
1139 index.set(caseInsensitive ? f.path.toLowerCase() : f.path, i);
1140 lower.set(f.path.toLowerCase(), i);
1141 if (f.px1 > f.px0 && f.py1 > f.py0) drawn.push(i);
1142 });
1143 const dense = drawn.length < files.length * DENSE_SHARE;
1144 const base = paintBase({ width: width2, height, groups, folders, files, drawn }, style, dense);
1145 const folderNames = labelsFor(groups, columns, rows, zoom);
1146 const labels = zoom === null || dense ? folderNames : [...folderNames, ...fileLabels(files, rows, folderNames)];
1147 return { columns, rows, width: width2, height, groups, files, drawn, index, caseInsensitive, labels, base, style, dense, lower, folders, root: zoom };
1148}
1149function halfCell(top, bottom) {
1150 if (top === GROUND && bottom === GROUND) return [SPACE, TERMINAL_DEFAULT, TERMINAL_DEFAULT];
1151 if (top === GROUND) return [LOWER_HALF, bottom, TERMINAL_DEFAULT];
1152 return [UPPER_HALF, top, bottom === GROUND ? TERMINAL_DEFAULT : bottom];
1153}
1154function labelCovered(label, marked, width2) {
1155 if (marked === void 0) return false;
1156 for (let c = label.col; c < label.col + label.text.length; c++) {
1157 if (marked[2 * label.row * width2 + c] || marked[(2 * label.row + 1) * width2 + c]) return true;
1158 }
1159 return false;
1160}
1161var cellGround = (pixel) => pixel === void 0 || pixel === GROUND ? TERMINAL_DEFAULT : pixel;
1162function writeText(words, layout, label, colors) {
1163 if (label.row < 0 || label.row >= layout.rows) return;
1164 for (let k = Math.max(0, -label.col); k < label.text.length && label.col + k < layout.columns; k++) {
1165 const col = label.col + k;
1166 words.set([label.text.charCodeAt(k), ...colors(col)], (label.row * layout.columns + col) * 3);
1167 }
1168}
1169function writeFolderLabels(words, layout, pixels, marked) {
1170 const fg = layout.style.health ? LABEL_FG : MAP_PALETTES[layout.style.theme].folder;
1171 for (const label of layout.labels) {
1172 if (labelCovered(label, marked, layout.width)) continue;
1173 writeText(words, layout, label, (col) => [fg, cellGround(pixels[2 * label.row * layout.width + col])]);
1174 }
1175}
1176function encodeCells(layout, pixels, marked, names2 = []) {
1177 const { columns, rows, width: width2 } = layout;
1178 const words = new Uint32Array(columns * rows * 3);
1179 for (let i = 0; i < columns * rows; i++) {
1180 const r = Math.floor(i / columns);
1181 const c = i % columns;
1182 words.set(halfCell(pixels[2 * r * width2 + c] ?? GROUND, pixels[(2 * r + 1) * width2 + c] ?? GROUND), i * 3);
1183 }
1184 writeFolderLabels(words, layout, pixels, marked);
1185 for (const name of names2) writeText(words, layout, name, () => [name.color, TERMINAL_DEFAULT]);
1186 return new Uint8Array(words.buffer).toBase64();
1187}
1188
1189// src/views/overlay.ts
1190var NO_LIT = { hits: [], reads: [], named: [], importers: [], edits: [], edit: null, current: null };
1191var FRAME_MS = 40;
1192var FLASH_MS = 600;
1193var RIPPLE_MS = 1200;
1194var NAME_MAX = 18;
1195var FLASH_LIFT = 0.6;
1196var keyOf2 = (layout, path) => layout.caseInsensitive ? path.toLowerCase() : path;
1197function indices(layout, paths, lowered = false) {
1198 const out = [];
1199 for (const p of paths) {
1200 const i = lowered ? layout.lower.get(p) : layout.index.get(keyOf2(layout, p));