A live map of your project in a side pane: its sections, how they depend on each other, and where Claude is working right now

A Claude Code mod that keeps a high-level map of your project in a side pane: its main sections, how they depend on each other, and, live, which section Claude is working in.

minimap runs in the terminal (claude CLI), including the built-in terminal of any editor. The VS Code extension and the Claude desktop app are not supported.
At the prompt of a terminal Claude Code session:
/plugin install minimap --marketplace beaurm/minimap
Or from a shell:
claude plugin install minimap --marketplace beaurm/minimap
Answer y to add the marketplace, then press Enter to pick the user scope.
The first time, in your project:
/minimap init
Claude explores the repo and writes minimap.json. You can commit it so teammates who use minimap share the same map.
| Action | What it does | Uses tokens |
|---|---|---|
| Live tracking | Lights up the sections Claude reads, edits or runs commands on, as it works | No |
| Drift detection | Notices folders and files the map doesn't cover, and sections whose folders are gone | No |
/minimap | Opens the pane | No |
/minimap init | Asks Claude to explore the repo and write the map | Yes |
/minimap update | Asks Claude to bring the map up to date, given what drifted | Yes |
/minimap reload | Re-reads the map right away (it also reloads by itself at the start and end of each turn when the file changed) | No |
Only init and update call Claude, and only when you run them. Everything else, tracking included, runs locally without any model calls.
/minimap to open the map.x closes the pane, and it stays closed, even in new sessions, until you run /minimap. While it's closed, a reminder under the prompt shows that command.Every time Claude reads, edits or writes a file, or runs a command that names paths, the mod finds the sections that own those files. A command reading files across several sections lights all of them, and so do calls Claude runs in parallel:
═), and the box shows the file instead of its folder. The lines into and out of it turn orange. The pane's top line says what Claude is doing (● Editing src/api/routes.ts · API) and lists the section's direct links.○ Idle · last … and keeps the trail until the next turn starts.Tracking adds no model calls and never blocks a tool call. If it fails, the call goes through untouched.
minimap.json at the repo root:
{
"version": 1,
"sections": [
{ "id": "api", "label": "API", "description": "HTTP routes", "paths": ["src/api/**"] },
{ "id": "db", "label": "Database", "paths": ["src/db/**", "migrations"] }
],
"edges": [["api", "db"]]
}
paths are glob patterns from the repo root: * within a folder, ** at any depth, and a plain folder name means everything in it. When several sections match a file, the most specific pattern wins.edges are [from, to] pairs meaning "from depends on to".a → b → c, it doesn't draw a → c); the active section's links line lists them all.It is a plain, visible file on purpose. Claude Code treats files under .claude/ and hidden files at the repo root as sensitive, and would ask for approval on every init or update (or refuse outright in headless runs).
Because sections are path patterns, new files in existing folders belong to their section with no update needed. The mod also watches for drift, without calling Claude:
When something drifted, the pane says Map out of date and lists it. Run /minimap update when you want it fixed.
minimap runs as a hook on Claude's tool calls, so here is exactly what it does:
minimap.json, lists your repo's top-level folders, and checks that your sections' folders still exist. It never writes or changes your files; only Claude writes minimap.json, when you run init or update./minimap init and /minimap update send Claude a prompt.You can check this yourself: claude plugin validate . in this repo lists every call the plugin makes.
minimap.json in the folder where the session starts, and reads paths from there. Started in a subfolder (or in one package of a monorepo), it finds no map.TERM, and without it Claude Code doesn't draw the pane properly or let you click it. Set it before starting Claude Code: ``powershell $env:TERM = "xterm-256color" claude ` To make it stick, add the first line to your PowerShell profile (notepad $PROFILE`). Bash terminals on Windows (Git Bash, WSL) work as is.claude plugin uninstall minimap
That's all it takes: minimap stops running and its stored settings are removed.
Installing also registered this repo as a marketplace (where Claude Code finds the plugin). That entry does nothing on its own, but if you want no trace left:
claude plugin marketplace remove minimap
The minimap.json files in your repos are ordinary files: delete them if you no longer want them. To switch minimap off without uninstalling, use claude plugin disable minimap (and enable to bring it back).
Run Claude Code with the mod loaded straight from this folder:
claude --plugin-dir /path/to/minimap
Check and test it:
claude plugin validate .
claude plugin test .
One catch when trying init or update on this repo itself: Claude Code protects a loaded plugin's own files, so with --plugin-dir . Claude can't write minimap.json here. Load a copy of the plugin from another folder instead.
| Path | What it is |
|---|---|
.claude-plugin/plugin.json | Mod manifest |
.claude-plugin/marketplace.json | Makes this repo installable with /plugin install |
hooks/hooks.json | Points to the hooks module |
hooks/register.tsx | The hooks: loading the map, commands, tracking calls, the pane's open/close |
hooks/tracking.ts | Live tracking as pure steps: calls and turns in, what to highlight out |
hooks/activity.ts | Which files and sections a tool call touches |
hooks/graph.ts | Reading and checking the map file, path matching, drift checks |
hooks/prompts.ts | What init and update ask Claude |
hooks/layout.ts | Placing any graph in layers |
hooks/connectors.ts | Routing the lines between layers |
hooks/terminal.tsx | The pane's drawing: header, footer, and the terminal's map |
hooks/boxes.ts | What each box shows: its file or folder, and the highlight |
types/index.d.ts | The map's shape and the state the mod keeps |
tests/ | Unit tests for the helpers, and pane tests against a fake repo |
hooks/register.tsx 375 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { MapActivity, ProjectMap } from '../types'
5import { actionOf, fileArg, locate } from './activity'
6import type { TrackedTool } from './activity'
7import {
8 LEGACY_MAP_PATHS, MAP_PATH, isMapFile, isTracked, parseMap, relativeTo, sectionDirs, sectionOf, unmappedTopDirs,
9} from './graph'
10import { layoutMap } from './layout'
11import { initPrompt, updatePrompt } from './prompts'
12import { brokenPane, emptyPane, invalidPane, legend, mapStatus, paneHeader, terminalMap } from './terminal'
13import type { PaneView } from './terminal'
14import {
15 EMPTY_TRACKING, MAIN, endCall, endTurn, highlightOf, sanitize, startCall, startTurn,
16} from './tracking'
17
18const PANE = 'minimap'
19const TITLE = 'Minimap'
20
21const graph = atom({ plugin: 'minimap', key: 'graph' } as const, null)
22const loadError = atom({ plugin: 'minimap', key: 'error' } as const, null)
23const unmappedDirs = atom({ plugin: 'minimap', key: 'unmappedDirs' } as const, [])
24const unmappedFiles = atom({ plugin: 'minimap', key: 'unmappedFiles' } as const, [])
25const missing = atom({ plugin: 'minimap', key: 'missing' } as const, [])
26const tracking = atom({ plugin: 'minimap', key: 'tracking' } as const, EMPTY_TRACKING)
27const isPaneOpen = atom({ plugin: 'minimap', key: 'isPaneOpen' } as const, false)
28const paneWait = atom({ plugin: 'minimap', key: 'paneWait' } as const, null)
29const followed = atom({ plugin: 'minimap', key: 'followed' } as const, null)
30const mapStamp = atom({ plugin: 'minimap', key: 'mapStamp' } as const, null)
31const mapFile = atom({ plugin: 'minimap', key: 'mapFile' } as const, MAP_PATH)
32
33/** The store key remembering, across sessions, that the person closed the pane. */
34const CLOSED_KEY = 'paneClosed'
35const MAX_UNMAPPED = 50
36
37/** The tracking value, safe to read whatever an older version of the module stored. */
38const readTracking = async ($: EngineInterface) => sanitize(await read($, tracking))
39
40// ── Loading the map and finding drift ─────────────────────────────────────────
41
42/** Reads the map file into state, then checks it against the repo. */
43async function loadMap($: EngineInterface) {
44 let map: ProjectMap | null = null
45 let error: string | null = null
46 let stamp: number | null = null
47 const file = await findMapFile($)
48 try {
49 stamp = await stampOf($, file)
50 if (stamp !== null) {
51 const parsed = parseMap(await $.fs.read(file))
52 if ('map' in parsed) map = parsed.map
53 else error = parsed.error
54 }
55 } catch (err) {
56 error = `could not read it (${err instanceof Error ? err.message : String(err)})`
57 }
58 await update($, graph, () => map)
59 await update($, loadError, () => error)
60 await update($, mapStamp, () => stamp)
61 await update($, mapFile, () => file)
62 await scanDrift($, map)
63}
64
65/** The map file to read: minimap.json, else an earlier name the repo still has, else minimap.json. */
66async function findMapFile($: EngineInterface): Promise<string> {
67 for (const file of [MAP_PATH, ...LEGACY_MAP_PATHS]) if (await $.fs.exists(file)) return file
68 return MAP_PATH
69}
70
71/**
72 * The map file's modification time, or null when there is none. NaN when the
73 * time can't be read: NaN equals nothing, so the map then reloads every turn.
74 */
75async function stampOf($: EngineInterface, file: string): Promise<number | null> {
76 if (!(await $.fs.exists(file))) return null
77 return $.fs.stat(file).then(
78 stat => stat.mtimeMs,
79 () => NaN,
80 )
81}
82
83/**
84 * Reloads the map when its file changed since it was loaded, however it
85 * changed: Claude's Edit or Write (caught at once), a script it ran, the
86 * person's editor, a git pull.
87 */
88async function reloadIfChanged($: EngineInterface) {
89 const file = await findMapFile($)
90 if (file !== (await read($, mapFile)) || (await stampOf($, file)) !== (await read($, mapStamp))) await loadMap($)
91}
92
93/**
94 * Cheap checks against a freshly loaded map, no model calls: top-level folders
95 * no section reaches, and sections whose folders are gone. Files Claude
96 * touched stay listed only while the new map still leaves them out.
97 */
98async function scanDrift($: EngineInterface, map: ProjectMap | null) {
99 if (!map) {
100 await update($, unmappedDirs, () => [])
101 await update($, unmappedFiles, () => [])
102 await update($, missing, () => [])
103 return
104 }
105 const dirs = (await $.fs.list('.')).filter(entry => entry.kind === 'dir').map(entry => entry.name)
106 const gone: string[] = []
107 for (const section of map.sections) {
108 const roots = sectionDirs(section)
109 const exists = await Promise.all(roots.map(dir => (dir === '' ? true : $.fs.exists(dir))))
110 if (!exists.some(Boolean)) gone.push(section.id)
111 }
112
113 await update($, unmappedDirs, () => unmappedTopDirs(map, dirs).slice(0, MAX_UNMAPPED))
114 await update($, unmappedFiles, files => files.filter(p => !sectionOf(map, p)))
115 await update($, missing, () => gone)
116}
117
118/** What drifted, as the pane and the update prompt show it: everything uncovered, then what is gone. */
119async function readDrift($: EngineInterface) {
120 return {
121 unmapped: [...(await read($, unmappedDirs)), ...(await read($, unmappedFiles))],
122 missing: await read($, missing),
123 }
124}
125
126/** After Claude writes a file: reload when it is the map, else note it if no section owns it. */
127async function trackWrite($: EngineInterface, path: string) {
128 const rel = relativeTo(await $.session.cwd(), path)
129 if (rel === undefined) return
130 if (isMapFile(rel)) return loadMap($)
131 await noteUnmapped($, rel)
132}
133
134/** Notes a repo file (read or written) that no section owns: the map is missing it. */
135async function noteUnmapped($: EngineInterface, rel: string) {
136 const map = await read($, graph)
137 if (!map || isMapFile(rel) || !isTracked(rel) || sectionOf(map, rel)) return
138 await update($, unmappedFiles, list => (list.includes(rel) ? list : [...list, rel].slice(-MAX_UNMAPPED)))
139}
140
141// ── Live tracking: where Claude is working ──────────────────────────────────
142
143/** Records a tool call (one state write); resolves what was recorded, if the call is in the repo. */
144async function trackStart($: EngineInterface, loop: string, callId: string, tool: TrackedTool, args: Record<string, unknown>) {
145 const map = await read($, graph)
146 if (!map) return undefined
147 const where = locate(map, await $.session.cwd(), tool, args)
148 if (!where) return undefined
149 const activity: MapActivity = { ...where, action: actionOf(tool), callId }
150 await update($, tracking, t => startCall(sanitize(t), loop, activity))
151 if (loop === MAIN && where.sections[0]) await follow($, where.sections[0])
152 return activity
153}
154
155/**
156 * Scrolls the pane to the section Claude moved to. `nearest`: the pane moves
157 * only when that section is off screen, so it doesn't fight the person scrolling.
158 */
159async function follow($: EngineInterface, section: string) {
160 if ((await read($, followed)) === section || !(await read($, isPaneOpen))) return
161 await update($, followed, () => section)
162 await $.ui.scroll({ in: PANE, to: { key: `box-${section}` }, block: 'nearest' }).catch(() => {})
163}
164
165// ── The pane ─────────────────────────────────────────────────────────────────
166
167/**
168 * Whether the session runs in a terminal, the one surface minimap supports.
169 * The VS Code panel draws the pane broken and brings it back each session,
170 * so there (and in a -p run, with no surface) the pane is never opened.
171 */
172async function hasTerminal($: EngineInterface) {
173 return (await $.session.surfaces()).includes('terminal')
174}
175
176/** Opens the pane; `asked` when the person asked for it, which also undoes a remembered close. */
177async function openPane($: EngineInterface, asked: boolean) {
178 const opened = await $.ui.open({ id: PANE, title: TITLE })
179 // Undrawn, Claude Code says why: under the width floor ("144 columns ...", a
180 // terminal to widen), or a surface that places no panes, where /minimap can't help.
181 const wait = opened.isPlaced ? null : /column/i.test(opened.reason) ? 'width' : 'surface'
182 await update($, paneWait, () => wait)
183 if (asked) await $.store.set(CLOSED_KEY, false)
184 await syncPane($)
185 return opened
186}
187
188/**
189 * Asks Claude Code where the pane really stands, and says so under the prompt
190 * while it isn't showing. Opened unasked, a pane waits undrawn until the
191 * terminal is wide enough (144 columns, 110 once opened by hand), so "not
192 * showing" is either the person's close or that wait, and the two read
193 * differently. Run again at each turn, it catches a pane placed meanwhile.
194 */
195async function syncPane($: EngineInterface) {
196 const isPlaced = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
197 await update($, isPaneOpen, () => isPlaced)
198 if (isPlaced) return $.ui.status(undefined)
199 // Where nothing draws panes, any hint would point at a command that can't help.
200 if ((await read($, paneWait)) === 'surface') return $.ui.status(undefined)
201 const isClosed = (await $.store.get(CLOSED_KEY)) === true
202 $.ui.status(isClosed ? 'pane closed · /minimap to reopen' : '/minimap to open the map')
203}
204
205/** After a /clear: the map, and whether the pane is still up, into the new session's state. */
206async function restoreAfterClear($: EngineInterface) {
207 await loadMap($)
208 await syncPane($)
209}
210
211/**
212 * Queues a prompt for Claude once the command has finished: submitting from
213 * inside the command.run hook would wait on the very turn the hook holds.
214 */
215function queuePrompt($: EngineInterface, text: string) {
216 $.clock.after(0, () => void $.prompt.submit({ text }).catch(() => {}))
217}
218
219// ── Hooks ────────────────────────────────────────────────────────────────────
220
221const HELP = `/minimap open the pane
222/minimap init ask Claude to map this repo (uses tokens)
223/minimap update ask Claude to bring the map up to date (uses tokens)
224/minimap reload re-read ${MAP_PATH} after editing it by hand`
225
226export const register: Register = on => {
227 on('session.start', async ($, e, next) => {
228 await $.command.register({
229 name: 'minimap',
230 description: 'Minimap: open, or init | update | reload',
231 })
232 await loadMap($)
233 // Drop what an older version of the module may have left in the session's state.
234 await update($, tracking, t => sanitize(t))
235 await update($, followed, () => null)
236 // The pane opens with each session, unless the person closed it last time.
237 // Opening resolves at once, placed or waiting for a wider terminal, so the session waits for it.
238 if (!(await hasTerminal($))) await update($, paneWait, () => 'surface')
239 else if ((await $.store.get(CLOSED_KEY)) === true) await syncPane($).catch(() => {})
240 else await openPane($, false).catch(() => {})
241
242 return next(e)
243 })
244
245 // A /clear starts a fresh session (and fresh state) with no session.start:
246 // once it is through, load the map again and pick up the pane as it stands.
247 on('session.end', async ($, e, next) => {
248 const ended = await next(e)
249 if (e.reason === 'clear') $.clock.after(0, () => void restoreAfterClear($).catch(() => {}))
250 return ended
251 })
252
253 on('command.run', { command: 'minimap' }, async ($, e) => {
254 const sub = e.args.trim()
255 const map = await read($, graph)
256
257 if (sub === '') {
258 const unsupported = 'This Claude Code surface can\'t show plugin panes. minimap needs Claude Code in a terminal (`claude`).'
259 if (!(await hasTerminal($))) return { text: unsupported }
260 const opened = await openPane($, true)
261 return { text: opened.isPlaced ? 'Minimap opened.' : unsupported }
262 }
263 if (sub === 'reload') {
264 await loadMap($)
265 const error = await read($, loadError)
266 if (error) return { text: `${await read($, mapFile)} is invalid: ${error}` }
267 return { text: (await read($, graph)) ? 'Minimap reloaded.' : `No ${MAP_PATH} found.` }
268 }
269 if (sub === 'init') {
270 if (map) return { text: `This repo already has ${await read($, mapFile)}. Use /minimap update, or delete the file to start over.` }
271 queuePrompt($, initPrompt())
272 return { text: 'Asked Claude to map this repo.' }
273 }
274 if (sub === 'update') {
275 if (!map) return { text: `No map yet: run /minimap init first.` }
276 const file = await read($, mapFile)
277 const text = await $.fs.read(file).catch(() => undefined)
278 if (text === undefined) return { text: `Could not read ${file}: run /minimap reload, then try again.` }
279 queuePrompt($, updatePrompt(text, await readDrift($), file))
280 return { text: 'Asked Claude to update the project map.' }
281 }
282
283 return { text: `Unknown option "${sub}".\n${HELP}` }
284 })
285
286 // The validator reads the matcher's list literally, so it is written out here; it is
287 // TRACKED_TOOLS from activity.ts, and passing e.tool on as a TrackedTool keeps the two in step.
288 on('tool.call', { tool: ['Read', 'Edit', 'Write', 'NotebookEdit', 'LSP', 'Bash'] }, async ($, e, next) => {
289 const tool: TrackedTool = e.tool
290 const args = e as unknown as Record<string, unknown>
291 const loop = e.agentId ?? MAIN
292 // Tracking is best effort: it must never get in the way of the call itself.
293 let started: MapActivity | undefined
294 try {
295 started = await trackStart($, loop, e.tool_use_id, tool, args)
296 // A file Claude opens that no section owns is drift too (a command's words are too loose to count).
297 if (started && started.sections.length === 0 && tool !== 'Bash') await noteUnmapped($, started.file)
298 } catch {}
299
300 const ran = await next(e)
301
302 try {
303 if (started) await update($, tracking, t => endCall(sanitize(t), e.tool_use_id))
304 const path = fileArg(args)
305 if (actionOf(tool) === 'Editing' && path && ran.deny === undefined && !ran.isError) await trackWrite($, path)
306 } catch {}
307
308 return ran
309 }).catch(($, e, next) => next(e)) // never in the way: the call goes through (or replays) untouched
310
311 // A main turn opens the trail afresh; a subagent's activity ends with its own turn.
312 on('turn.start', async ($, e, next) => {
313 if (!e.agentId) {
314 // The map file changed since it was loaded, or never loaded into this session (a /clear).
315 await reloadIfChanged($).catch(() => {})
316 // A pane that waited for a wider terminal may be showing by now: follow it from here on.
317 await syncPane($).catch(() => {})
318 await update($, tracking, t => startTurn(sanitize(t)))
319 await update($, followed, () => null)
320 }
321 return next(e)
322 })
323
324 on('turn.complete', async ($, e, next) => {
325 await update($, tracking, t => endTurn(sanitize(t), e.agentId ?? MAIN))
326 // A turn may have changed the map file without Claude's file tools (a script, a command).
327 if (!e.agentId) await reloadIfChanged($).catch(() => {})
328 return next(e)
329 })
330
331 // The x closes the pane for good: later sessions keep it closed until /minimap.
332 on('ui.close', { id: PANE }, async ($, e, next) => {
333 const closed = await next(e)
334 if (e.origin.kind === 'person') await $.store.set(CLOSED_KEY, true)
335 await syncPane($)
336 return closed
337 })
338
339 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
340 const els = $.ui.resolve(e)
341 // A drawing that throws makes the engine drop the pane: show what went wrong instead.
342 try {
343 const map = await read($, graph)
344 const error = await read($, loadError)
345 if (error) return invalidPane(els, error, await read($, mapFile))
346 if (!map) return emptyPane(els)
347
348 const t = await readTracking($)
349 const columns = Math.max(20, (e.props.bodyColumns ?? e.viewport?.columns ?? 60) - 2)
350 const view: PaneView = {
351 map,
352 layout: layoutMap(map, columns),
353 lit: highlightOf(t),
354 tracking: t,
355 drift: await readDrift($),
356 }
357 const { Box } = els
358
359 return (
360 <Box flexDirection="column" paddingX={1}>
361 {paneHeader(els, view)}
362 {/* The map centered in the pane, its status under it; redrawn at each new width. */}
363 <Box flexDirection="column" alignItems="center" marginTop={1}>
364 {terminalMap(els, view)}
365 <Box marginTop={1}>{mapStatus(els, view)}</Box>
366 </Box>
367 <Box marginTop={1}>{legend(els)}</Box>
368 </Box>
369 )
370 } catch (err) {
371 return brokenPane(els, err)
372 }
373 })
374}
375hooks/activity.ts 155 lines1import type { MapActivity, ProjectMap } from '../types'
2import { isAbsolute, relativeTo, sectionOf, toSlashes } from './graph'
3
4/** The tools whose calls say where Claude is working, and what each one counts as. */
5export const TRACKED_TOOLS = ['Read', 'Edit', 'Write', 'NotebookEdit', 'LSP', 'Bash'] as const
6export type TrackedTool = (typeof TRACKED_TOOLS)[number]
7
8const ACTION: Record<TrackedTool, MapActivity['action']> = {
9 Read: 'Reading',
10 LSP: 'Reading',
11 Edit: 'Editing',
12 Write: 'Editing',
13 NotebookEdit: 'Editing',
14 Bash: 'Running',
15}
16
17export const actionOf = (tool: TrackedTool) => ACTION[tool]
18
19/** The file a file tool's call names, if any. */
20export function fileArg(args: Record<string, unknown>): string | undefined {
21 for (const key of ['file_path', 'notebook_path', 'filePath']) {
22 const value = args[key]
23 if (typeof value === 'string' && value !== '') return value
24 }
25 return undefined
26}
27
28/**
29 * The words of a shell command that look like paths, quotes and trailing
30 * punctuation stripped: `grep -rn foo src/api "lib/x.ts";` → src/api, lib/x.ts.
31 * Flags, operators, URLs and words with no slash or extension are left out.
32 */
33export function pathsInCommand(command: string): string[] {
34 const words = command.split(/[\s|;&<>()]+/)
35 const found: string[] = []
36 for (const raw of words) {
37 let word = raw.replace(/^['"`]+|['"`,:]+$/g, '').replace(/^[^=\s/\\]+=/, '')
38 // A Windows path (`C:\repo\a.ts`, `src\api\a.ts`) to the `/` form; other backslashes
39 // (a regex, an escape) are left alone, so they never pass for paths.
40 if (/^[A-Za-z]:\\/.test(word) || /^[\w.-]+(\\[\w.-]+)+$/.test(word)) word = toSlashes(word)
41 if (!word || word.startsWith('-') || word.includes('://') || /[${}]/.test(word)) continue
42 // A glob counts for the folder it starts in: src/api/*.ts → src/api.
43 const glob = word.search(/[*?[]/)
44 if (glob !== -1) word = word.slice(0, word.lastIndexOf('/', glob) + 1)
45 if (!word.includes('/') && !/\.[A-Za-z0-9]{1,6}$/.test(word)) continue
46 found.push(word.replace(/\/+$/, ''))
47 }
48 return found
49}
50
51/** `path` with `.`, `..` and repeated slashes resolved: `/repo/src/../lib` → `/repo/lib`. */
52function tidy(path: string): string {
53 const drive = /^[A-Za-z]:/.exec(path)?.[0] ?? ''
54 const parts: string[] = []
55 for (const part of path.slice(drive.length).split('/')) {
56 if (part === '' || part === '.') continue
57 if (part === '..') parts.pop()
58 else parts.push(part)
59 }
60 return `${drive}/${parts.join('/')}`
61}
62
63/**
64 * The paths a command names, made absolute against the folder each step runs
65 * in: a plain `cd <dir>` step moves it for the steps after (`&&`, `||`, `;`, a
66 * new line), and the folder it moves to counts as touched too. After a `cd` to
67 * somewhere unknowable (`~`, `-`, a variable), relative paths count for nothing
68 * rather than for the wrong place. Subshells, `pushd` and the like are left as
69 * plain words.
70 */
71export function pathsFrom(cwd: string, command: string): string[] {
72 let dir: string | undefined = tidy(toSlashes(cwd))
73 const found: string[] = []
74 const resolve = (path: string) => (isAbsolute(path) ? tidy(path) : dir === undefined ? undefined : tidy(`${dir}/${path}`))
75 for (const step of command.split(/&&|\|\||;|\n/)) {
76 const cd = /^\s*cd(?:\s+(\S+))?\s*$/.exec(step)
77 if (cd) {
78 const target = (cd[1] ?? '').replace(/^['"]|['"]$/g, '')
79 dir = target === '' || /^[~-]|[$`]/.test(target) ? undefined : resolve(toSlashes(target))
80 if (dir !== undefined) found.push(dir)
81 continue
82 }
83 for (const word of pathsInCommand(step)) {
84 const path = resolve(word)
85 if (path !== undefined) found.push(path)
86 }
87 }
88 return found
89}
90
91/** Where a tool call happens: its main file, and every section it touches with the first file in each. */
92export type Location = Pick<MapActivity, 'file' | 'sections' | 'files'>
93
94/**
95 * Where a tool call happens, or undefined when it names nothing inside the
96 * repo. A file tool touches its file's section; a command touches every
97 * section its paths fall in, the one it names most first.
98 */
99export function locate(
100 map: ProjectMap,
101 cwd: string,
102 tool: TrackedTool,
103 args: Record<string, unknown>,
104): Location | undefined {
105 const inRepo = (path: string) => {
106 const rel = relativeTo(cwd, path)
107 return rel === undefined || rel === '' || rel.startsWith('../') ? undefined : rel
108 }
109
110 if (tool !== 'Bash') {
111 const path = fileArg(args)
112 const rel = path === undefined ? undefined : inRepo(path)
113 if (rel === undefined) return undefined
114 const id = sectionOf(map, rel)?.id
115 return { file: rel, sections: id ? [id] : [], files: id ? { [id]: rel } : {} }
116 }
117
118 const command = typeof args.command === 'string' ? args.command : ''
119 const counts = new Map<string, { n: number; file: string }>()
120 for (const path of pathsFrom(cwd, command)) {
121 const rel = inRepo(path)
122 const id = rel === undefined ? undefined : sectionOf(map, rel)?.id
123 if (rel === undefined || !id) continue
124 const seen = counts.get(id)
125 counts.set(id, { n: (seen?.n ?? 0) + 1, file: seen?.file ?? rel })
126 }
127 // Most named first; a tie keeps the order the command names them in.
128 const sections = [...counts.keys()].sort((a, b) => counts.get(b)!.n - counts.get(a)!.n)
129 if (sections.length) {
130 const files = Object.fromEntries(sections.map(id => [id, counts.get(id)!.file]))
131 return { file: files[sections[0]!]!, sections, files }
132 }
133 const head = command.trim().split('\n')[0] ?? ''
134 return head ? { file: head.length > 48 ? `${head.slice(0, 47)}…` : head, sections: [], files: {} } : undefined
135}
136
137/**
138 * Whether a stored activity has the shape this version records. State outlives
139 * a reload of the module, so entries an older version wrote can still be there.
140 */
141export function isActivity(value: unknown): value is MapActivity {
142 const a = value as Partial<MapActivity> | null
143 return (
144 !!a &&
145 Array.isArray(a.sections) &&
146 typeof a.files === 'object' &&
147 a.files !== null &&
148 typeof a.file === 'string' &&
149 typeof a.callId === 'string'
150 )
151}
152
153/** The last segment of a path, for a box too narrow for the whole of it. */
154export const baseName = (path: string) => path.split('/').filter(Boolean).pop() ?? path
155hooks/graph.ts 179 lines1import type { MapSection, ProjectMap } from '../types'
2
3export const MAP_PATH = 'minimap.json'
4/** The map file's names under the plugin's earlier names: read when a repo has no minimap.json yet. */
5export const LEGACY_MAP_PATHS = ['claude-nodes.json']
6/** Whether a repo-relative path is the map file, under its current name or an earlier one. */
7export const isMapFile = (path: string) => path === MAP_PATH || LEGACY_MAP_PATHS.includes(path)
8
9// Top-level folders that are never project sections, so never drift.
10const IGNORED_DIRS = new Set([
11 'node_modules', 'dist', 'build', 'out', 'target', 'coverage', 'vendor', '__pycache__', 'venv',
12])
13
14/** Parses and checks the map file's text; the error names the first problem found. */
15export function parseMap(text: string): { map: ProjectMap } | { error: string } {
16 let raw: unknown
17 try {
18 raw = JSON.parse(text)
19 } catch (err) {
20 return { error: `not valid JSON (${err instanceof Error ? err.message : String(err)})` }
21 }
22 if (typeof raw !== 'object' || raw === null) return { error: 'expected an object' }
23 const { sections, edges = [] } = raw as { sections?: unknown; edges?: unknown }
24 if (!Array.isArray(sections) || sections.length === 0) {
25 return { error: '"sections" must be a non-empty array' }
26 }
27
28 const ids = new Set<string>()
29 const parsed: MapSection[] = []
30 for (const [i, s] of sections.entries()) {
31 const at = `sections[${i}]`
32 if (typeof s !== 'object' || s === null) return { error: `${at} must be an object` }
33 const { id, label, description, paths } = s as Record<string, unknown>
34 if (typeof id !== 'string' || id === '') return { error: `${at}.id must be a string` }
35 if (ids.has(id)) return { error: `${at}.id "${id}" is used twice` }
36 if (typeof label !== 'string') return { error: `${at}.label must be a string` }
37 if (!Array.isArray(paths) || paths.length === 0 || !paths.every(p => typeof p === 'string')) {
38 return { error: `${at}.paths must be a non-empty array of strings` }
39 }
40 ids.add(id)
41 parsed.push({
42 id,
43 label,
44 ...(typeof description === 'string' ? { description } : {}),
45 paths: paths as string[],
46 })
47 }
48
49 if (!Array.isArray(edges)) return { error: '"edges" must be an array' }
50 const parsedEdges: [string, string][] = []
51 for (const [i, edge] of edges.entries()) {
52 if (!Array.isArray(edge) || edge.length !== 2 || !edge.every(id => typeof id === 'string')) {
53 return { error: `edges[${i}] must be a pair of section ids` }
54 }
55 const [from, to] = edge as [string, string]
56 for (const id of [from, to]) if (!ids.has(id)) return { error: `edges[${i}] names unknown section "${id}"` }
57 // A self-edge says nothing, and a repeated one would draw twice.
58 if (from !== to && !parsedEdges.some(([a, b]) => a === from && b === to)) parsedEdges.push([from, to])
59 }
60
61 return { map: { version: 1, sections: parsed, edges: parsedEdges } }
62}
63
64const hasGlob = (segment: string) => /[*?]/.test(segment)
65
66// Compiled patterns, by pattern: sectionOf runs per tool call and per path in a command.
67const compiled = new Map<string, RegExp>()
68
69/** A pattern as a RegExp over repo-relative paths; a plain folder also matches what's inside. */
70export function globToRegExp(pattern: string): RegExp {
71 let re = compiled.get(pattern)
72 if (!re) compiled.set(pattern, (re = compileGlob(pattern)))
73 return re
74}
75
76function compileGlob(pattern: string): RegExp {
77 const clean = pattern.replace(/^\.\//, '').replace(/\/+$/, '')
78 const full = hasGlob(clean) ? clean : `${clean}/**`
79 let source = ''
80 for (let i = 0; i < full.length; i++) {
81 const ch = full[i]!
82 if (ch === '*' && full[i + 1] === '*') {
83 const slash = full[i + 2] === '/'
84 source += slash ? '(?:.*/)?' : '.*'
85 i += slash ? 2 : 1
86 } else if (ch === '*') source += '[^/]*'
87 else if (ch === '?') source += '[^/]'
88 else source += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')
89 }
90 // `src/api/**` also owns the folder `src/api` itself.
91 if (source.endsWith('/.*')) source = `${source.slice(0, -3)}(?:/.*)?`
92 return new RegExp(`^${source}$`)
93}
94
95/** The leading folders of a pattern before any glob: `src/api/**` → `src/api`. */
96export function baseDir(pattern: string): string {
97 const parts = pattern.replace(/^\.\//, '').split('/')
98 const plain: string[] = []
99 for (const part of parts) {
100 if (hasGlob(part)) break
101 plain.push(part)
102 }
103 // A pattern without globs names a folder; one with a file glob stops before it.
104 return plain.filter(Boolean).join('/')
105}
106
107/**
108 * How specific a pattern is, compared in order: the longer its folder before
109 * any wildcard, then the more fixed (non-wildcard) characters, then the fewer
110 * `**`. So a pattern for test files under `src` beats `src/**`, whatever order the sections come in.
111 */
112function specificity(pattern: string): [number, number, number] {
113 return [baseDir(pattern).length, pattern.replace(/[*?]/g, '').length, -(pattern.match(/\*\*/g)?.length ?? 0)]
114}
115
116const moreSpecific = (a: [number, number, number], b: [number, number, number]) =>
117 a[0] !== b[0] ? a[0] > b[0] : a[1] !== b[1] ? a[1] > b[1] : a[2] > b[2]
118
119/** The section owning a repo-relative path: the one whose matching pattern is most specific. */
120export function sectionOf(map: ProjectMap, path: string): MapSection | undefined {
121 let best: MapSection | undefined
122 let bestScore: [number, number, number] | undefined
123 for (const section of map.sections) {
124 for (const pattern of section.paths) {
125 if (!globToRegExp(pattern).test(path)) continue
126 const score = specificity(pattern)
127 if (!bestScore || moreSpecific(score, bestScore)) {
128 best = section
129 bestScore = score
130 }
131 }
132 }
133 return best
134}
135
136/** A Windows path (`C:\repo\src`) in the `/` form the patterns use (`C:/repo/src`); others as they are. */
137export const toSlashes = (path: string) => path.replace(/\\/g, '/')
138
139/** Whether a path is absolute: `/…`, or a Windows drive (`C:/…`). */
140export const isAbsolute = (path: string) => path.startsWith('/') || /^[A-Za-z]:\//.test(path)
141
142/**
143 * `path` relative to `root`, or undefined when it lies outside it; both may be
144 * Windows paths. A drive path compares without case, as Windows does.
145 */
146export function relativeTo(root: string, path: string): string | undefined {
147 const target = toSlashes(path)
148 if (!isAbsolute(target)) return target.replace(/^\.\//, '')
149 const base = toSlashes(root).replace(/\/+$/, '') + '/'
150 const isDrive = /^[A-Za-z]:\//.test(base)
151 const starts = isDrive ? target.toLowerCase().startsWith(base.toLowerCase()) : target.startsWith(base)
152 return starts ? target.slice(base.length) : undefined
153}
154
155/** Whether drift tracking cares about a path: hidden and build folders don't count. */
156export function isTracked(path: string): boolean {
157 const first = path.split('/')[0] ?? ''
158 return first !== '' && !first.startsWith('.') && !IGNORED_DIRS.has(first)
159}
160
161/** Top-level folders no section's patterns reach into, as `dir/`. */
162export function unmappedTopDirs(map: ProjectMap, dirs: string[]): string[] {
163 const reaches = (pattern: string, dir: string) => {
164 const segments = pattern.replace(/^\.\//, '').split('/')
165 const first = segments[0] ?? ''
166 if (first === '**') return true
167 // A lone glob like `*.md` matches files at the root, not folders.
168 if (segments.length === 1 && hasGlob(first)) return false
169 return globToRegExp(first).test(dir) || first === dir
170 }
171 const reached = (dir: string) => map.sections.some(s => s.paths.some(p => reaches(p, dir)))
172 return dirs.filter(d => isTracked(d) && !reached(d)).map(d => `${d}/`)
173}
174
175/** Every folder a section's patterns start from (empty string: the repo root). */
176export function sectionDirs(section: MapSection): string[] {
177 return section.paths.map(baseDir)
178}
179hooks/layout.ts 302 lines1import type { ProjectMap } from '../types'
2
3/** A box (a section) or a lane (one cell wide) carrying a long edge through a layer. */
4export type Slot = {
5 id: string
6 kind: 'box' | 'lane'
7 /** The row it sits on: a layer too wide for the pane takes several rows. */
8 layer: number
9 /** First column, and width in columns. */
10 left: number
11 width: number
12 center: number
13}
14
15/** One drawn segment between adjacent layers, and the map edge it belongs to. */
16export type Segment = { from: string; to: string; edge: [string, string] }
17
18export type Layout = {
19 boxW: number
20 gap: number
21 width: number
22 /** The rows, top to bottom: each layer of the graph, wrapped where it is too wide. */
23 layers: Slot[][]
24 slots: Map<string, Slot>
25 segments: Segment[]
26 /** The edges drawn: the map's, less those a longer path already implies. */
27 edges: [string, string][]
28}
29
30const MAX_BOX_W = 18
31const MIN_BOX_W = 10
32/** A box's border and padding: a label needs this many columns more than its own length. */
33const BOX_CHROME = 4
34const GAP = 2
35
36/**
37 * Longest-path layers: a section sits one layer below everything it is used by,
38 * and no higher than it has to above what it uses.
39 * Edges that would close a cycle are ignored for layering (they still draw if
40 * they point downward).
41 */
42function assignLayers(map: ProjectMap): Map<string, number> {
43 const layer = new Map(map.sections.map(s => [s.id, 0]))
44 const limit = map.sections.length
45 for (let pass = 0; pass < limit; pass++) {
46 let changed = false
47 for (const [from, to] of map.edges) {
48 const next = layer.get(from)! + 1
49 if (next > layer.get(to)! && next < limit) {
50 layer.set(to, next)
51 changed = true
52 }
53 }
54 if (!changed) break
55 }
56
57 // Then pull each section down to just above the highest section it uses: one
58 // that nothing uses (a tool that only reads the logs) sits beside its target
59 // instead of on the top row. Lowest first, so a section sees its targets settled.
60 const downward = map.edges.filter(([from, to]) => layer.get(to)! > layer.get(from)!)
61 const order = [...map.sections].sort((a, b) => layer.get(b.id)! - layer.get(a.id)!)
62 for (const { id } of order) {
63 const targets = downward.filter(([from]) => from === id).map(([, to]) => layer.get(to)!)
64 if (targets.length) layer.set(id, Math.min(...targets) - 1)
65 }
66 return layer
67}
68
69/**
70 * The map's edges less the ones a longer path already says: with a → b → c,
71 * a → c adds nothing to the picture and only crosses other lines. The pane
72 * draws these; the map file keeps every edge.
73 */
74export function displayEdges(edges: [string, string][]): [string, string][] {
75 const out = new Map<string, string[]>()
76 for (const [a, b] of edges) out.set(a, [...(out.get(a) ?? []), b])
77 const reachesWithout = (from: string, to: string, skip: [string, string]) => {
78 const seen = new Set<string>([from])
79 const queue = [from]
80 while (queue.length) {
81 const node = queue.shift()!
82 for (const next of out.get(node) ?? []) {
83 if (node === skip[0] && next === skip[1]) continue
84 if (next === to) return true
85 if (!seen.has(next)) {
86 seen.add(next)
87 queue.push(next)
88 }
89 }
90 }
91 return false
92 }
93 return edges.filter(edge => !reachesWithout(edge[0], edge[1], edge))
94}
95
96// The last few layouts, by map and width: a pane redraws on every tool call, the map rarely changes.
97const layouts = new Map<string, Layout>()
98const KEPT_LAYOUTS = 4
99
100/** Lays the map out in rows that fit `columns` where they can, its labels whole where they can. */
101export function layoutMap(map: ProjectMap, columns: number): Layout {
102 const key = `${columns} ${JSON.stringify(map)}`
103 const kept = layouts.get(key)
104 if (kept) return kept
105 const layout = computeLayout(map, columns)
106 layouts.set(key, layout)
107 if (layouts.size > KEPT_LAYOUTS) layouts.delete(layouts.keys().next().value!)
108 return layout
109}
110
111function computeLayout(map: ProjectMap, columns: number): Layout {
112 const edges = displayEdges(map.edges)
113 const layerOf = assignLayers({ ...map, edges })
114 // Boxes never get narrower than the longest label needs: a wrapped row reads
115 // better than a cut-off name. Only a pane too narrow for even one such box
116 // shrinks the boxes (and cuts the labels).
117 const longest = Math.max(0, ...map.sections.map(s => s.label.length))
118 const readable = Math.max(MIN_BOX_W, longest + BOX_CHROME)
119
120 // Each layer on one row first; while that is wider than the pane, wrap the
121 // layers into rows of fewer boxes, in the order the one-row layout settled on.
122 const first = place(map, edges, layerOf, columns, Infinity, readable)
123 if (first.width <= columns) return first
124 const rank = new Map<string, number>()
125 for (const row of first.layers) row.forEach((slot, i) => rank.set(slot.id, i))
126 for (let perRow = Math.max(...first.layers.map(row => row.length)) - 1; perRow >= 1; perRow--) {
127 const layout = place(map, edges, layerOf, columns, perRow, readable, rank)
128 if (layout.width <= columns) return layout
129 }
130 // One box per row still doesn't fit: let the boxes shrink after all.
131 return place(map, edges, layerOf, columns, 1, MIN_BOX_W, rank)
132}
133
134type Item = { id: string; kind: Slot['kind'] }
135
136/** Down-and-up sweeps tried after the first pass: maps this small settle in a few. */
137const SWEEPS = 8
138
139/**
140 * Orders each row to cross as few lines as it can. A first pass sorts each row
141 * by the mean position of what feeds it; then sweeps go down and up, sorting
142 * each row by its neighbors in the row just placed, and swap neighbors where
143 * that crosses fewer lines. The order with the fewest crossings wins, the first
144 * pass on a tie, so a map that already draws clean keeps its order.
145 */
146function orderRows(start: Item[][], segments: Segment[]): Item[][] {
147 // Lines that share an end merge instead of crossing, so each stretch counts once.
148 const links = [...new Map(segments.map(s => [`${s.from}>${s.to}`, [s.from, s.to] as const])).values()]
149 const parents = new Map<string, string[]>()
150 const children = new Map<string, string[]>()
151 for (const [a, b] of links) {
152 children.set(a, [...(children.get(a) ?? []), b])
153 parents.set(b, [...(parents.get(b) ?? []), a])
154 }
155 const rowOf = new Map<string, number>()
156 start.forEach((row, r) => row.forEach(item => rowOf.set(item.id, r)))
157 const bands = start.map((_, r) => links.filter(([a]) => rowOf.get(a) === r))
158
159 const positions = (rows: Item[][]) => {
160 const pos = new Map<string, number>()
161 for (const row of rows) row.forEach((item, i) => pos.set(item.id, i))
162 return pos
163 }
164 const crossings = (rows: Item[][], band: number) => {
165 const pos = positions(rows)
166 const ls = bands[band] ?? []
167 let n = 0
168 for (let i = 0; i < ls.length; i++) {
169 for (let j = i + 1; j < ls.length; j++) {
170 const [a, b] = ls[i]!
171 const [c, d] = ls[j]!
172 if (a !== c && b !== d && (pos.get(a)! - pos.get(c)!) * (pos.get(b)! - pos.get(d)!) < 0) n++
173 }
174 }
175 return n
176 }
177 const total = (rows: Item[][]) => rows.reduce((sum, _, r) => sum + crossings(rows, r), 0)
178
179 // Sorts row r by the mean position of its neighbors; one with none keeps its place.
180 const sortRow = (rows: Item[][], r: number, neighbors: Map<string, string[]>, lonely: (i: number) => number) => {
181 const pos = positions(rows)
182 const keyed = rows[r]!.map((item, i) => {
183 const at = (neighbors.get(item.id) ?? []).map(id => pos.get(id)!)
184 return { item, i, w: at.length ? at.reduce((x, y) => x + y, 0) / at.length : lonely(i) }
185 })
186 keyed.sort((x, y) => x.w - y.w || x.i - y.i)
187 rows[r] = keyed.map(k => k.item)
188 }
189
190 // Swaps neighbors in a row while that crosses fewer lines with the rows around it.
191 const transpose = (rows: Item[][]) => {
192 for (let pass = 0, improved = true; improved && pass < 10; pass++) {
193 improved = false
194 for (let r = 0; r < rows.length; r++) {
195 const row = rows[r]!
196 const around = () => (r > 0 ? crossings(rows, r - 1) : 0) + crossings(rows, r)
197 for (let i = 0; i + 1 < row.length; i++) {
198 const before = around()
199 ;[row[i], row[i + 1]] = [row[i + 1]!, row[i]!]
200 if (around() < before) improved = true
201 else [row[i], row[i + 1]] = [row[i + 1]!, row[i]!]
202 }
203 }
204 }
205 }
206
207 const rows = start.map(row => [...row])
208 // First pass: what nothing feeds goes last.
209 for (let r = 1; r < rows.length; r++) sortRow(rows, r, parents, () => Infinity)
210 let best = rows.map(row => [...row])
211 let fewest = total(best)
212 for (let sweep = 0; sweep < SWEEPS && fewest > 0; sweep++) {
213 for (let r = rows.length - 2; r >= 0; r--) sortRow(rows, r, children, i => i)
214 for (let r = 1; r < rows.length; r++) sortRow(rows, r, parents, i => i)
215 transpose(rows)
216 const n = total(rows)
217 if (n < fewest) {
218 fewest = n
219 best = rows.map(row => [...row])
220 }
221 }
222 return best
223}
224
225/**
226 * Lays the map out with at most `perRow` boxes on a row: a layer with more
227 * takes several rows, and an edge into a later row passes the rows between
228 * through lanes, as an edge skipping layers does. Boxes are at least `minW`
229 * wide, and at most MAX_BOX_W unless a label needs more.
230 */
231function place(
232 map: ProjectMap,
233 edges: [string, string][],
234 layerOf: Map<string, number>,
235 columns: number,
236 perRow: number,
237 minW: number,
238 rank?: Map<string, number>,
239): Layout {
240 const depth = Math.max(0, ...layerOf.values()) + 1
241
242 // Which row each box lands on: its layer's rows follow the rows of the layers above.
243 const rowOf = new Map<string, number>()
244 let next = 0
245 for (let l = 0; l < depth; l++) {
246 const boxes = map.sections.filter(s => layerOf.get(s.id) === l).map(s => s.id)
247 if (rank) boxes.sort((x, y) => (rank.get(x) ?? 0) - (rank.get(y) ?? 0))
248 // As many rows as the layer needs, its boxes spread evenly over them (6 in rows of 5: 3 + 3).
249 const count = Math.max(1, Math.ceil(boxes.length / perRow))
250 const each = Math.ceil(boxes.length / count)
251 boxes.forEach((id, i) => rowOf.set(id, next + Math.floor(i / each)))
252 next += count
253 }
254
255 // Rows of ids, with a lane id for each row an edge passes through. Edges from
256 // one box share its lane on a row, and split only past their last shared row.
257 const rows: Item[][] = Array.from({ length: next }, () => [])
258 for (const s of map.sections) rows[rowOf.get(s.id)!]!.push({ id: s.id, kind: 'box' })
259 const segments: Segment[] = []
260 for (const edge of edges) {
261 const [from, to] = edge
262 const a = rowOf.get(from)!
263 const b = rowOf.get(to)!
264 if (b <= a) continue // an upward edge (a cycle): not drawn
265 let prev = from
266 for (let r = a + 1; r < b; r++) {
267 const lane = `${from}@${r}`
268 if (!rows[r]!.some(item => item.id === lane)) rows[r]!.push({ id: lane, kind: 'lane' })
269 segments.push({ from: prev, to: lane, edge })
270 prev = lane
271 }
272 segments.push({ from: prev, to, edge })
273 }
274
275 rows.splice(0, rows.length, ...orderRows(rows, segments))
276
277 const rowWidth = (row: Item[], boxW: number) =>
278 row.reduce((sum, item) => sum + (item.kind === 'box' ? boxW : 1), 0) + GAP * Math.max(0, row.length - 1)
279 const fitW = (row: Item[]) => {
280 const boxes = row.filter(i => i.kind === 'box').length
281 if (boxes === 0) return Infinity
282 const fixed = row.length - boxes + GAP * Math.max(0, row.length - 1)
283 return Math.floor((columns - fixed) / boxes)
284 }
285 const boxW = Math.max(minW, Math.min(Math.max(MAX_BOX_W, minW), ...rows.map(fitW)))
286 const width = Math.max(...rows.map(r => rowWidth(r, boxW)))
287
288 const slots = new Map<string, Slot>()
289 const layers = rows.map((row, r) => {
290 let left = Math.floor((width - rowWidth(row, boxW)) / 2)
291 return row.map(item => {
292 const w = item.kind === 'box' ? boxW : 1
293 const slot: Slot = { id: item.id, kind: item.kind, layer: r, left, width: w, center: left + Math.floor(w / 2) }
294 slots.set(item.id, slot)
295 left += w + GAP
296 return slot
297 })
298 })
299
300 return { boxW, gap: GAP, width, layers, slots, segments, edges }
301}
302hooks/prompts.ts 60 lines1/** What `/minimap init` and `update` ask Claude: the only token spend. */
2import { MAP_PATH } from './graph'
3
4const SCHEMA = `{
5 "version": 1,
6 "sections": [
7 { "id": "api", "label": "API", "description": "HTTP routes and handlers", "paths": ["src/api/**"] }
8 ],
9 "edges": [["api", "auth"]]
10}`
11
12const RULES = `Rules for the file:
13- Sections are the main parts of the codebase a newcomer would want on a diagram: usually 4 to 12.
14- "paths" are glob patterns relative to the repo root ("*" within a folder, "**" any depth; a plain folder means everything in it). Together they should cover every source folder; skip build output, dependencies and hidden folders.
15- "id" is short kebab-case; "label" is 1-2 words (it is drawn in a small box); "description" is one short line.
16- "edges" are [from, to] pairs meaning "from depends on / uses to". Keep only the main dependencies.
17- No positions or layout: the plugin computes those.`
18
19export function initPrompt(): string {
20 return `Create the project map for the minimap plugin.
21
22Explore this repository's structure (folders, entry points, and imports where needed to tell what depends on what; don't read every file), then write \`${MAP_PATH}\` in exactly this JSON shape:
23
24\`\`\`json
25${SCHEMA}
26\`\`\`
27
28${RULES}
29
30Write only that file, then reply with a one-line summary of the sections.`
31}
32
33/**
34 * `text` is the map file as it is on disk, not as the plugin parsed it, so
35 * Claude keeps whatever else the file holds. `file` is the map file in use:
36 * a repo may still have it under an earlier name.
37 */
38export function updatePrompt(text: string, drift: { unmapped: string[]; missing: string[] }, file: string): string {
39 const lines = [
40 drift.unmapped.length ? `- Not covered by any section: ${drift.unmapped.join(', ')}` : '',
41 drift.missing.length ? `- Sections whose folders no longer exist: ${drift.missing.join(', ')}` : '',
42 ].filter(Boolean)
43 return `Update the project map at \`${file}\`; the project has changed since it was written.
44
45Current file:
46
47\`\`\`json
48${text.trimEnd()}
49\`\`\`
50
51What the plugin detected:
52${lines.length ? lines.join('\n') : '- Nothing specific; check whether the sections and edges still match the code.'}
53
54Make the smallest change that brings it up to date: keep existing ids and labels, cover new folders by extending a section's paths or adding a section, remove or repoint sections that are gone, and adjust edges only where the change requires it.
55
56${RULES}
57
58Write only that file, then reply with a one-line summary of what changed.`
59}
60hooks/terminal.tsx 215 lines1/**
2 * What the pane draws: its messages, the header and footer every surface
3 * shares, and the terminal's map of boxes and lines.
4 */
5import type { BoxProps, ElementConstructor, RenderElement, TextProps } from 'claude-code'
6
7import type { MapActivity, MapTracking, ProjectMap } from '../types'
8import { baseName } from './activity'
9import { pathHint } from './boxes'
10import type { Highlight } from './boxes'
11import { connectorRows, isHotEdge } from './connectors'
12import { MAP_PATH } from './graph'
13import type { Layout } from './layout'
14import { MAIN } from './tracking'
15
16/** The two elements every surface draws with. */
17export type Els = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
18
19/** Everything a drawing of the map reads. */
20export type PaneView = {
21 map: ProjectMap
22 layout: Layout
23 lit: Highlight
24 tracking: MapTracking
25 drift: { unmapped: string[]; missing: string[] }
26}
27
28const BOX_ROWS = 4
29
30export function invalidPane({ Box, Text }: Els, error: string, file: string): RenderElement {
31 return (
32 <Box flexDirection="column" paddingX={1}>
33 <Text color="error">{file} is invalid</Text>
34 <Text dimColor>{error}</Text>
35 <Text dimColor>Fix it, then run /minimap reload.</Text>
36 </Box>
37 )
38}
39
40export function emptyPane({ Box, Text }: Els): RenderElement {
41 return (
42 <Box flexDirection="column" paddingX={1}>
43 <Text bold>No map yet.</Text>
44 <Text dimColor>Run /minimap init to have Claude map this repo (uses tokens).</Text>
45 <Text dimColor>Or write {MAP_PATH} by hand, then /minimap reload.</Text>
46 </Box>
47 )
48}
49
50export function brokenPane({ Box, Text }: Els, err: unknown): RenderElement {
51 return (
52 <Box flexDirection="column" paddingX={1}>
53 <Text color="error">The project map hit an error while drawing.</Text>
54 <Text dimColor>{err instanceof Error ? err.message : String(err)}</Text>
55 <Text dimColor>Run /minimap reload; if it persists, please report it.</Text>
56 </Box>
57 )
58}
59
60/** One status line per loop at work, the focused section's links, and the session's counts. */
61export function paneHeader({ Box, Text }: Els, { map, lit, tracking }: PaneView): RenderElement {
62 const labelOf = (id: string) => map.sections.find(s => s.id === id)?.label ?? id
63 // "graph.ts +3 more · Graph, Activity, Boxes" for a call that touches several sections.
64 const whereOf = (act: MapActivity) => {
65 const more = Object.keys(act.files).length - 1
66 const where = act.sections.length ? act.sections.map(labelOf).join(', ') : 'unmapped'
67 return `${act.file}${more > 0 ? ` +${more} more` : ''} · ${where}`
68 }
69
70 // The main conversation first, then subagents.
71 const statusLines = Object.entries(tracking.live)
72 .sort(([a], [b]) => (a === MAIN ? -1 : b === MAIN ? 1 : a.localeCompare(b)))
73 .map(([loop, act]) => {
74 const isIdle = loop === MAIN && !tracking.busy
75 return (
76 <Text wrap="truncate-end">
77 {isIdle ? <Text dimColor>○ Idle · last </Text> : <Text color="claude">● </Text>}
78 {loop !== MAIN && <Text dimColor>subagent </Text>}
79 <Text bold={!isIdle} dimColor={isIdle}>
80 {isIdle ? act.action.toLowerCase() : act.action}
81 </Text>
82 <Text dimColor>
83 {' '}
84 {whereOf(act)}
85 </Text>
86 </Text>
87 )
88 })
89
90 const busiest = Object.entries(tracking.touches)
91 .map(([id, n]) => ({ id, n: n.reads + n.edits + n.runs }))
92 .sort((a, b) => b.n - a.n)
93 .slice(0, 3)
94
95 // The drawing leaves out edges a longer path implies, so spell out the focus's own links.
96 const focus = tracking.live[MAIN]?.sections[0] ?? [...lit.active][0]
97 const uses = focus ? map.edges.filter(([a]) => a === focus).map(([, b]) => labelOf(b)) : []
98 const usedBy = focus ? map.edges.filter(([, b]) => b === focus).map(([a]) => labelOf(a)) : []
99
100 return (
101 <Box flexDirection="column">
102 {statusLines.length ? statusLines : <Text dimColor>○ Waiting for Claude to touch a file</Text>}
103 {focus && (uses.length > 0 || usedBy.length > 0) && (
104 <Text dimColor wrap="truncate-end">
105 {' '}
106 {labelOf(focus)} uses {uses.join(', ') || 'nothing'} · used by {usedBy.join(', ') || 'nothing'}
107 </Text>
108 )}
109 <Text dimColor wrap="truncate-end">
110 {map.sections.length} sections · {map.edges.length} links
111 {busiest.length ? ` · most touched: ${busiest.map(b => `${labelOf(b.id)} ${b.n}`).join(', ')}` : ''}
112 </Text>
113 </Box>
114 )
115}
116
117/** Whether the map is up to date, or what drifted and how to fix it: drawn centered under the map. */
118export function mapStatus({ Box, Text }: Els, { drift }: PaneView): RenderElement {
119 const { unmapped, missing } = drift
120 if (!unmapped.length && !missing.length) return <Text dimColor>Map up to date</Text>
121 const counts = [
122 unmapped.length ? `${unmapped.length} unmapped` : '',
123 missing.length ? `${missing.length} missing section${missing.length > 1 ? 's' : ''}` : '',
124 ].filter(Boolean)
125 return (
126 <Box flexDirection="column" alignItems="center">
127 <Text color="warning">Map out of date: {counts.join(', ')}</Text>
128 <Text dimColor wrap="truncate-end">
129 {[...unmapped, ...missing.map(id => `${id} (gone)`)].slice(0, 4).join(', ')}
130 {unmapped.length + missing.length > 4 ? ', …' : ''}
131 </Text>
132 <Text dimColor>Run /minimap update to fix it (uses tokens).</Text>
133 </Box>
134 )
135}
136
137/** What the marks mean, one per line. */
138export function legend({ Box, Text }: Els): RenderElement {
139 return (
140 <Box flexDirection="column">
141 <Text dimColor>▼ depends on</Text>
142 <Text>
143 <Text color="claude">═</Text>
144 <Text dimColor> Claude is working here</Text>
145 </Text>
146 <Text>
147 <Text color="claude">orange</Text>
148 <Text dimColor> touched this turn</Text>
149 </Text>
150 </Box>
151 )
152}
153
154/** The terminal's map: rows of boxes, joined by box-drawing lines; the active ones doubled. */
155export function terminalMap({ Box, Text }: Els, { map, layout, lit }: PaneView): RenderElement {
156 const byId = new Map(map.sections.map(s => [s.id, s]))
157 return (
158 <Box flexDirection="column" width={layout.width}>
159 {layout.layers.map((row, i) => (
160 <Box flexDirection="column">
161 {i > 0 &&
162 connectorRows(layout, i - 1, lit.active).map(runs => (
163 <Text wrap="truncate-end">
164 {runs.map(run => (run.isHot ? <Text color="claude">{run.text}</Text> : <Text dimColor>{run.text}</Text>))}
165 </Text>
166 ))}
167 <Box flexDirection="row">
168 {row.map((slot, k) => {
169 const margin = k === 0 ? slot.left : layout.gap
170 if (slot.kind === 'lane') {
171 // A lane several edges share is hot when any of them is.
172 const hot = layout.segments.some(s => s.to === slot.id && isHotEdge(s.edge, lit.active))
173 return (
174 <Box flexDirection="column" width={1} marginLeft={margin}>
175 {Array.from({ length: BOX_ROWS }, () => (hot ? <Text color="claude">│</Text> : <Text dimColor>│</Text>))}
176 </Box>
177 )
178 }
179 const section = byId.get(slot.id)!
180 const isHere = lit.active.has(section.id)
181 const wasHere = lit.trail.has(section.id)
182 return (
183 <Box
184 key={`box-${section.id}`}
185 flexDirection="column"
186 alignItems="center"
187 borderStyle={isHere ? 'double' : 'round'}
188 borderColor={isHere || wasHere ? 'claude' : 'subtle'}
189 borderDimColor={wasHere}
190 paddingX={1}
191 width={slot.width}
192 marginLeft={margin}
193 >
194 <Text color={isHere ? 'claude' : 'text'} bold={isHere} wrap="truncate-end">
195 {section.label}
196 </Text>
197 {isHere ? (
198 <Text color="claude" wrap="truncate-middle">
199 {baseName(lit.files.get(section.id) ?? '')}
200 </Text>
201 ) : (
202 <Text dimColor wrap="truncate-middle">
203 {pathHint(section.paths)}
204 </Text>
205 )}
206 </Box>
207 )
208 })}
209 </Box>
210 </Box>
211 ))}
212 </Box>
213 )
214}
215hooks/tracking.ts 90 lines1/**
2 * Live tracking as pure steps over one MapTracking value: each event computes
3 * the next value, and the hooks write it once.
4 */
5import type { MapActivity, MapCall, MapTouches, MapTracking } from '../types'
6import { isActivity } from './activity'
7import type { Highlight } from './boxes'
8
9/** The main conversation's loop; a subagent's loop is its agent id. */
10export const MAIN = 'main'
11const TRAIL_LENGTH = 6
12
13export const EMPTY_TRACKING: MapTracking = { busy: false, live: {}, running: {}, trail: [], touches: {} }
14
15/** A call starts: it runs, it is its loop's latest, and its sections join the trail and the counts. */
16export function startCall(t: MapTracking, loop: string, activity: MapActivity): MapTracking {
17 const { sections } = activity
18 const field: keyof MapTouches = activity.action === 'Editing' ? 'edits' : activity.action === 'Running' ? 'runs' : 'reads'
19 const touches = { ...t.touches }
20 for (const id of sections) {
21 const seen = touches[id] ?? { reads: 0, edits: 0, runs: 0 }
22 touches[id] = { ...seen, [field]: seen[field] + 1 }
23 }
24 return {
25 ...t,
26 running: { ...t.running, [activity.callId]: { ...activity, loop } },
27 live: { ...t.live, [loop]: activity },
28 trail: [...sections, ...t.trail.filter(id => !sections.includes(id))].slice(0, TRAIL_LENGTH),
29 touches,
30 }
31}
32
33/** A call ends: off the running list. It stays its loop's latest until another call starts. */
34export function endCall(t: MapTracking, callId: string): MapTracking {
35 const { [callId]: _, ...running } = t.running
36 return { ...t, running }
37}
38
39/** A main turn starts: busy, with a fresh trail. */
40export function startTurn(t: MapTracking): MapTracking {
41 return { ...t, busy: true, trail: [] }
42}
43
44/**
45 * A loop's turn ends. Calls an interrupted turn left running never report
46 * their end, so they go; a subagent leaves the picture, the main loop idles.
47 */
48export function endTurn(t: MapTracking, loop: string): MapTracking {
49 const running = Object.fromEntries(Object.entries(t.running).filter(([, call]) => call.loop !== loop))
50 if (loop === MAIN) return { ...t, running, busy: false }
51 const { [loop]: _, ...live } = t.live
52 return { ...t, running, live }
53}
54
55/**
56 * The stored value made safe to read: state outlives a reload of the module,
57 * so it may hold what an older version wrote (other fields, or none).
58 */
59export function sanitize(value: unknown): MapTracking {
60 const t = (value ?? {}) as Partial<MapTracking>
61 const keep = <T>(all: unknown) =>
62 Object.fromEntries(Object.entries((all ?? {}) as Record<string, T>).filter(([, a]) => isActivity(a))) as Record<string, T>
63 return {
64 busy: t.busy === true,
65 live: keep<MapActivity>(t.live),
66 running: keep<MapCall>(t.running),
67 trail: Array.isArray(t.trail) ? t.trail.filter(id => typeof id === 'string') : [],
68 touches: t.touches && typeof t.touches === 'object' ? t.touches : {},
69 }
70}
71
72/**
73 * What to light up: every section a running call touches (parallel calls
74 * included), plus each busy loop's latest call; the rest of the turn's trail
75 * stays outlined.
76 */
77export function highlightOf(t: MapTracking): Highlight {
78 const active = new Set<string>()
79 const files = new Map<string, string>()
80 const light = (activity: MapActivity) => {
81 for (const id of activity.sections) {
82 active.add(id)
83 if (!files.has(id)) files.set(id, activity.files[id] ?? activity.file)
84 }
85 }
86 for (const call of Object.values(t.running)) light(call)
87 for (const [loop, activity] of Object.entries(t.live)) if (loop !== MAIN || t.busy) light(activity)
88 return { active, trail: new Set(t.trail.filter(id => !active.has(id))), files }
89}
90hooks/boxes.ts 21 lines1/** What the drawings put in and around each box. */
2
3/** What a drawing highlights: sections being worked in now, and the ones touched before. */
4export type Highlight = {
5 active: ReadonlySet<string>
6 trail: ReadonlySet<string>
7 /** The file being worked on, by active section. */
8 files: ReadonlyMap<string, string>
9}
10
11/**
12 * What a box shows under its label: its first pattern's folder, or the file's
13 * name when it names one file, and how many more patterns it has.
14 */
15export function pathHint(paths: string[]): string {
16 const first = (paths[0] ?? '').replace(/\/\*\*$/, '')
17 const last = first.split('/').pop() ?? ''
18 const hint = /[*?]/.test(first) || !/\.[A-Za-z0-9]+$/.test(last) ? first || '.' : last
19 return paths.length > 1 ? `${hint} +${paths.length - 1}` : hint
20}
21hooks/connectors.ts 266 lines1/** The lines between layers, drawn with box-drawing characters for a monospace grid. */
2import type { Layout, Segment, Slot } from './layout'
3
4const U = 1
5const D = 2
6const L = 4
7const R = 8
8const GLYPH: Record<number, string> = {
9 [U | D]: '│', [L | R]: '─', [U | R]: '└', [U | L]: '┘', [D | R]: '┌', [D | L]: '┐',
10 [U | D | R]: '├', [U | D | L]: '┤', [U | L | R]: '┴', [D | L | R]: '┬', [U | D | L | R]: '┼',
11}
12
13export type Run = { text: string; isHot: boolean }
14
15export const isHotEdge = ([a, b]: [string, string], active: ReadonlySet<string>) => active.has(a) || active.has(b)
16
17/** The columns a line may leave or enter a slot through: inside a box's border, a lane's one cell. */
18const inner = (slot: Slot) => (slot.kind === 'box' ? [slot.left + 1, slot.left + slot.width - 2] : [slot.left, slot.left])
19
20// Gives each segment leaving (or entering) a slot its own column: one line
21// uses the slot's center; several spread evenly around it, two apart while
22// they fit inside the border (else one apart), ordered by where each line's
23// other end sits so they don't cross. A lane has one column.
24function ports(layout: Layout, segs: Segment[], own: (s: Segment) => string, other: (s: Segment) => string) {
25 const col = new Map<Segment, number>()
26 for (const id of new Set(segs.map(own))) {
27 const slot = layout.slots.get(id)!
28 const mine = segs.filter(s => own(s) === id)
29 if (slot.kind === 'lane') {
30 for (const s of mine) col.set(s, slot.center)
31 continue
32 }
33 const centerOf = (s: Segment) => layout.slots.get(other(s))!.center
34 mine.sort((x, y) => centerOf(x) - centerOf(y))
35 const [lo, hi] = inner(slot)
36 const room = hi! - lo!
37 const step = (mine.length - 1) * 2 <= room ? 2 : 1
38 mine.forEach((s, k) => {
39 const at = slot.center + Math.round((k - (mine.length - 1) / 2) * step)
40 col.set(s, Math.min(hi!, Math.max(lo!, at)))
41 })
42 }
43 return col
44}
45
46/** How one segment runs through its band: down at `fa`, across on elbow row `elbow`, down at `tb`. */
47export type Route = { fa: number; tb: number; elbow: number }
48
49/**
50 * Routes every segment between layer `layer` and layer `layer + 1`. Each line
51 * keeps its own columns, and bends whose stretches would overlap get their
52 * own elbow rows, so two lines meet only where one really crosses the other.
53 * `height` counts the band's rows: a drop row, the elbow rows, an arrow row.
54 */
55export function routeBand(layout: Layout, layer: number): Band {
56 // Layouts are cached and never change, so each band is routed once per layout.
57 let bands = routed.get(layout)
58 if (!bands) routed.set(layout, (bands = new Map()))
59 let band = bands.get(layer)
60 if (!band) bands.set(layer, (band = cleanerBand(layout, layer)))
61 return band
62}
63
64/** Ways to route a band, the readers' favorite first: each helps most bands and tangles some. */
65const STYLES: RouteStyle[] = [
66 { branches: true, laneDrops: true },
67 { branches: true, laneDrops: false },
68 { branches: false, laneDrops: true },
69 { branches: false, laneDrops: false },
70]
71
72/**
73 * How a band may be routed. `branches`: a lane's lines leaving it the same way
74 * share one turn row, like a tree. `laneDrops`: a lane over a box drops straight
75 * into it, its other lines branching off that drop.
76 */
77type RouteStyle = { branches: boolean; laneDrops: boolean }
78
79/**
80 * Routes the band in every style and keeps the cleanest: the fewest tangles
81 * (crossings and one-cell sideways jogs), then the fewest turns, then the
82 * fewest rows; on a full tie, the first style. A ┼ is a join, not a tangle:
83 * crossings bridge. Which style draws best depends on the map and the width,
84 * so each band is measured.
85 */
86function cleanerBand(layout: Layout, layer: number): Band {
87 const score = (band: Band) => {
88 const cells = drawBand(layout, band, new Set()).flat()
89 const routes = [...band.routes.values()]
90 const tangles =
91 cells.filter(cell => !cell.arrow && isCrossing(cell)).length +
92 new Set(routes.filter(r => Math.abs(r.tb - r.fa) === 1)).size
93 const turns = new Set(routes.filter(r => r.fa !== r.tb)).size
94 return [tangles, turns, band.height]
95 }
96 let best: { band: Band; score: number[] } | undefined
97 for (const style of STYLES) {
98 const band = computeBand(layout, layer, style)
99 const s = score(band)
100 const i = best ? s.findIndex((v, k) => v !== best!.score[k]) : -1
101 if (!best || (i !== -1 && s[i]! < best.score[i]!)) best = { band, score: s }
102 }
103 return best!.band
104}
105
106/** A band's routes and its height in rows: a drop row, the elbow rows, an arrow row. */
107export type Band = { routes: Map<Segment, Route>; height: number }
108
109const routed = new WeakMap<Layout, Map<number, Band>>()
110
111function computeBand(layout: Layout, layer: number, { branches, laneDrops }: RouteStyle): Band {
112 // Edges sharing a lane repeat its segments: route each stretch once, and give its copies the same route.
113 const keyOf = (s: Segment) => `${s.from}>${s.to}`
114 const first = new Map<string, Segment>()
115 for (const s of layout.segments) {
116 if (layout.slots.get(s.from)!.layer === layer && !first.has(keyOf(s))) first.set(keyOf(s), s)
117 }
118 const segs = [...first.values()]
119 const outPort = ports(layout, segs, s => s.from, s => s.to)
120 const within = (col: number, slot: Slot) => {
121 const [lo, hi] = inner(slot)
122 return col >= lo! && col <= hi!
123 }
124 // Lines into one box all land on one column, so they merge into one arrow: where a line
125 // can drop straight in (the one nearest the center), else the center.
126 const entry = new Map<string, number>()
127 for (const id of new Set(segs.map(s => s.to))) {
128 const slot = layout.slots.get(id)!
129 const straight = segs
130 .filter(s => s.to === id && within(outPort.get(s)!, slot))
131 .map(s => outPort.get(s)!)
132 .sort((x, y) => Math.abs(x - slot.center) - Math.abs(y - slot.center))
133 entry.set(id, straight[0] ?? slot.center)
134 }
135 const inPort = new Map(segs.map(s => [s, entry.get(s.to)!]))
136 // Go straight down when one end's port also lies over the other end, no other line leaving
137 // that box is in that column or the next, and no other line enters the target (they merge
138 // at its center instead): a drop must not touch another line. With `laneDrops`, a lane's
139 // lines, which all leave its one column anyway, branch off its straight drop.
140 const cols = new Map<Segment, [number, number]>()
141 const endAt = (s: Segment, end: 'from' | 'to') => {
142 const decided = cols.get(s)
143 if (decided) return end === 'from' ? decided[0] : decided[1]
144 return end === 'from' ? outPort.get(s)! : inPort.get(s)!
145 }
146 const isClear = (self: Segment, col: number) =>
147 segs.every(
148 s =>
149 s === self ||
150 ((s.from !== self.from || (laneDrops && layout.slots.get(s.from)!.kind === 'lane') || Math.abs(endAt(s, 'from') - col) > 1) &&
151 s.to !== self.to),
152 )
153 for (const seg of segs) {
154 const o = outPort.get(seg)!
155 const i = inPort.get(seg)!
156 const from = layout.slots.get(seg.from)!
157 const to = layout.slots.get(seg.to)!
158 if (o === i) cols.set(seg, [o, o])
159 else if (within(o, to) && isClear(seg, o)) cols.set(seg, [o, o])
160 else if (within(i, from) && isClear(seg, i)) cols.set(seg, [i, i])
161 else cols.set(seg, [o, i])
162 }
163
164 // Bends share an elbow row only when their stretches don't overlap; lines going the same
165 // way also keep a cell between them, so they never read as one. Lines going opposite ways
166 // can't merge, so a fan out of one box turns both its sides on the same row. Lines into
167 // the same box always share a row: they are meant to read as one. With `branches`, so do
168 // lines leaving one column the same way (a lane splitting): they branch off one line.
169 // A fan of lines going right turns its rightmost line highest, each next one lower, so
170 // no line's turn crosses another's drop; lines going left mirror it.
171 type Stretch = { lo: number; hi: number; right: boolean; to: string; from: string; fa: number }
172 const tracks: Stretch[][] = []
173 const trackOf = new Map<Segment, number>()
174 const bends = segs.filter(s => cols.get(s)![0] !== cols.get(s)![1])
175 const goesRight = (s: Segment) => cols.get(s)![1] > cols.get(s)![0]
176 bends.sort((x, y) => {
177 if (goesRight(x) !== goesRight(y)) return goesRight(x) ? -1 : 1
178 const fx = cols.get(x)![0]
179 const fy = cols.get(y)![0]
180 return goesRight(x) ? fy - fx : fx - fy
181 })
182 const clash = (a: Stretch, b: Stretch) => {
183 if (a.to === b.to) return false
184 if (branches && a.from === b.from && a.fa === b.fa && a.right === b.right) return false
185 const gap = a.right === b.right ? 1 : 0
186 return a.lo - gap <= b.hi && b.lo - gap <= a.hi
187 }
188 for (const seg of bends) {
189 const [fa, tb] = cols.get(seg)!
190 const stretch: Stretch = { lo: Math.min(fa, tb), hi: Math.max(fa, tb), right: tb > fa, to: seg.to, from: seg.from, fa }
191 // Below every line before it in the fan that it overlaps going the same way, else it would
192 // cross that line's drop; then the first row free of clashes.
193 const below = tracks.findLastIndex(track => track.some(other => other.right === stretch.right && clash(stretch, other)))
194 let t = tracks.findIndex((track, k) => k > below && track.every(other => !clash(stretch, other)))
195 if (t === -1) t = tracks.push([]) - 1
196 tracks[t]!.push(stretch)
197 trackOf.set(seg, t)
198 }
199
200 const routes = new Map<Segment, Route>()
201 for (const seg of layout.segments) {
202 const routed = first.get(keyOf(seg))
203 if (!routed || layout.slots.get(seg.from)!.layer !== layer) continue
204 const [fa, tb] = cols.get(routed)!
205 routes.set(seg, { fa, tb, elbow: 1 + (trackOf.get(routed) ?? 0) })
206 }
207 return { routes, height: 2 + Math.max(1, tracks.length) }
208}
209
210/** One character of a band: the ways its lines leave it, and which lines pass straight through. */
211type Cell = { dirs: number; isHot: boolean; arrow: boolean; across: Segment[]; down: Segment[]; isTurn: boolean }
212
213// Lines that only pass through a cell, one down and one across, cross: lines join only
214// where one turns. The line down bridges over, so every ┬ ├ ┴ left is a real join.
215const isCrossing = (cell: Cell) => !cell.isTurn && cell.down.length > 0 && cell.across.length > 0
216
217/** The band's cells, lines marked on them as routed. */
218function drawBand(layout: Layout, { routes, height }: Band, active: ReadonlySet<string>): Cell[][] {
219 const grid: Cell[][] = Array.from({ length: height }, () =>
220 Array.from({ length: layout.width }, () => ({ dirs: 0, isHot: false, arrow: false, across: [], down: [], isTurn: false })),
221 )
222 for (const [seg, { fa, tb, elbow }] of routes) {
223 const isHot = isHotEdge(seg.edge, active)
224 const mark = (row: number, col: number, dirs: number) => {
225 const cell = grid[row]?.[col]
226 if (!cell) return
227 cell.dirs |= dirs
228 cell.isHot ||= isHot
229 if (dirs === (U | D)) cell.down.push(seg)
230 else if (dirs === (L | R)) cell.across.push(seg)
231 else cell.isTurn = true
232 }
233 for (let row = 0; row < height; row++) {
234 if (fa === tb) mark(row, fa, U | D)
235 else if (row < elbow) mark(row, fa, U | D)
236 else if (row > elbow) mark(row, tb, U | D)
237 }
238 if (fa !== tb) {
239 const toward = tb > fa ? R : L
240 const back = tb > fa ? L : R
241 mark(elbow, fa, U | toward)
242 mark(elbow, tb, D | back)
243 for (let c = Math.min(fa, tb) + 1; c < Math.max(fa, tb); c++) mark(elbow, c, L | R)
244 }
245 const cell = grid[height - 1]?.[tb]
246 if (cell && layout.slots.get(seg.to)!.kind === 'box') cell.arrow = true
247 }
248 return grid
249}
250
251/** The rows of text wiring layer `layer` to layer `layer + 1`, as routed by routeBand. */
252export function connectorRows(layout: Layout, layer: number, active: ReadonlySet<string>): Run[][] {
253 return drawBand(layout, routeBand(layout, layer), active).map(row => {
254 const runs: Run[] = []
255 for (const cell of row) {
256 const crossing = isCrossing(cell)
257 const ch = cell.arrow ? '▼' : crossing ? GLYPH[U | D]! : (GLYPH[cell.dirs] ?? ' ')
258 const isHot = cell.dirs !== 0 && (crossing ? cell.down.some(s => isHotEdge(s.edge, active)) : cell.isHot)
259 const last = runs[runs.length - 1]
260 if (last && last.isHot === isHot) last.text += ch
261 else runs.push({ text: ch, isHot })
262 }
263 return runs
264 })
265}
266types/index.d.ts 78 lines1/** One part of the project, owning the files its path patterns match. */
2export type MapSection = {
3 id: string
4 label: string
5 description?: string
6 /** Glob patterns relative to the repo root: `*` within a folder, `**` any depth. */
7 paths: string[]
8}
9
10/** The contents of `minimap.json`. Edges read "from depends on to". */
11export type ProjectMap = {
12 version: 1
13 sections: MapSection[]
14 edges: [string, string][]
15}
16
17/** What one model loop (the main conversation, or a subagent) is doing now. */
18export type MapActivity = {
19 /** Every section the call touches, most touched first; empty when no section owns its files. */
20 sections: string[]
21 /** The first file the call touches in each of its sections, by section id. */
22 files: Record<string, string>
23 action: 'Reading' | 'Editing' | 'Running'
24 /** The call's main file (repo-relative), or the start of a command that names no file. */
25 file: string
26 /** The tool call (its tool_use_id) this activity records. */
27 callId: string
28}
29
30/** How often a section was touched this session. */
31export type MapTouches = { reads: number; edits: number; runs: number }
32
33/** A tool call still running, and the loop (`main`, or a subagent's id) it runs in. */
34export type MapCall = MapActivity & { loop: string }
35
36/** Everything live tracking keeps, as one value: a tool call writes it once. */
37export type MapTracking = {
38 /** True while a turn of the main conversation runs. */
39 busy: boolean
40 /** The latest call of each loop, by loop: `main`, or a subagent's id. */
41 live: Record<string, MapActivity>
42 /** The calls running now, by tool_use_id: parallel calls are all here at once. */
43 running: Record<string, MapCall>
44 /** Sections touched during the current (or last) turn, most recent first. */
45 trail: string[]
46 /** Touch counts by section id, for the whole session. */
47 touches: Record<string, MapTouches>
48}
49
50declare module 'claude-code' {
51 interface PluginState {
52 'minimap': {
53 /** The loaded map; null when the repo has none (or it failed to load). */
54 graph: ProjectMap | null
55 /** Why the map file could not be loaded; null when it loaded or is absent. */
56 error: string | null
57 /** Top-level folders no section covers (as `dir/`), found when the map loads. */
58 unmappedDirs: string[]
59 /** Files Claude read or wrote that no section owns, collected as it works. */
60 unmappedFiles: string[]
61 /** Ids of sections whose folders no longer exist. */
62 missing: string[]
63 /** Where Claude is working: see MapTracking. */
64 tracking: MapTracking
65 /** Whether the map pane is open. */
66 isPaneOpen: boolean
67 /** Why the last open left the pane undrawn: `width` (the terminal is too narrow), `surface` (nothing here draws panes), or null. */
68 paneWait: 'width' | 'surface' | null
69 /** The section the pane last scrolled to, so it only follows Claude when the section changes. */
70 followed: string | null
71 /** The map file in use: minimap.json, or an earlier name a repo still has. */
72 mapFile: string
73 /** The map file's modification time when it was last loaded; null when there was no file. */
74 mapStamp: number | null
75 }
76 }
77}
78