SLOPSHOPPER

minimap

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

newpaneguardcommandstatustimer
★ 5v0.3.0MITupdated 2026-10-20beaurm/minimap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · minimap
│ ┃ Minimap ✕ › fix the failing auth test and add an audit log call │ ┃ No map yet. │ ┃ Run /minimap init to have Claude map this ⏺ Read(src/auth.ts) │ ┃ repo (uses tokens). ⎿ Read 6 lines │ ┃ Or write minimap.json by hand, then ⏺ Update(src/auth.ts) │ ┃ /minimap reload. ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /minimap │ ⎿ minimap: Minimap opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Minimap
No map yet. Run /minimap init to have Claude map this repo (uses tokens). Or write minimap.json by hand, then /minimap reload.
README

minimap

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 demo

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.

Install

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.

Usage

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.

ActionWhat it doesUses tokens
Live trackingLights up the sections Claude reads, edits or runs commands on, as it worksNo
Drift detectionNotices folders and files the map doesn't cover, and sections whose folders are goneNo
/minimapOpens the paneNo
/minimap initAsks Claude to explore the repo and write the mapYes
/minimap updateAsks Claude to bring the map up to date, given what driftedYes
/minimap reloadRe-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.

The pane

  • Opening: the pane opens at the start of each session when the terminal is wide enough (Claude Code needs about 144 columns to open a pane by itself). In a narrower terminal, a line under the prompt says /minimap to open the map.
  • Following Claude: on a map taller than the pane, the pane scrolls to the section Claude moves to. It only moves when that section is off screen.
  • Closing: the 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.

Live tracking

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:

  • Working now: a double border (═), 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.
  • Touched this turn: an orange border. When the turn ends, the pane switches to ○ Idle · last … and keeps the trail until the next turn starts.
  • Subagents: each one at work gets its own status line, and its section lights up too, until its own turn ends.
  • The header counts which sections were touched most this session.

Tracking adds no model calls and never blocks a tool call. If it fails, the call goes through untouched.

The map file

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".
  • No positions: the pane lays the graph out itself. To keep the picture readable it leaves out an edge that a longer path already implies (with 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).

Keeping it up to date

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:

  • a top-level folder no section covers,
  • a file Claude writes that no section owns,
  • a section whose folders no longer exist.

When something drifted, the pane says Map out of date and lists it. Run /minimap update when you want it fixed.

What minimap can access

minimap runs as a hook on Claude's tool calls, so here is exactly what it does:

  • Reads, never writes. It reads 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.
  • Watches, never interferes. For each Read, Edit, Write, LSP or shell call, it looks at the file path or command to find the section. It never changes or blocks a call, and if tracking fails, the call goes through untouched.
  • No network, no programs. It makes no network requests and runs no commands of its own.
  • Tokens only when you ask. It never calls the model by itself. Only /minimap init and /minimap update send Claude a prompt.
  • Keeps almost nothing. Tracking lives in the session and is gone when it ends. The one thing kept across sessions is whether you closed the pane.

You can check this yourself: claude plugin validate . in this repo lists every call the plugin makes.

Limitations

  • Start Claude Code at the repo root. minimap looks for 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.
  • Plugin API changes. minimap is built on Claude Code's plugin API, which is still in early access and may change between releases. A Claude Code update can break it. It is tested with Claude Code 2.1.299 through 2.1.304; older versions may not draw the pane.
  • PowerShell on Windows. PowerShell doesn't set 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.
  • Wide characters in labels. Box widths count characters, so labels with wide characters (Chinese, Japanese, Korean, emoji) can misalign their boxes.

Uninstall

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).

Develop

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.

Layout

PathWhat it is
.claude-plugin/plugin.jsonMod manifest
.claude-plugin/marketplace.jsonMakes this repo installable with /plugin install
hooks/hooks.jsonPoints to the hooks module
hooks/register.tsxThe hooks: loading the map, commands, tracking calls, the pane's open/close
hooks/tracking.tsLive tracking as pure steps: calls and turns in, what to highlight out
hooks/activity.tsWhich files and sections a tool call touches
hooks/graph.tsReading and checking the map file, path matching, drift checks
hooks/prompts.tsWhat init and update ask Claude
hooks/layout.tsPlacing any graph in layers
hooks/connectors.tsRouting the lines between layers
hooks/terminal.tsxThe pane's drawing: header, footer, and the terminal's map
hooks/boxes.tsWhat each box shows: its file or folder, and the highlight
types/index.d.tsThe map's shape and the state the mod keeps
tests/Unit tests for the helpers, and pane tests against a fake repo
Source 10 files
hooks/register.tsx 375 lines
1import { 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}
375
hooks/activity.ts 155 lines
1import 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
155
hooks/graph.ts 179 lines
1import 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}
179
hooks/layout.ts 302 lines
1import 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}
302
hooks/prompts.ts 60 lines
1/** 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}
60
hooks/terminal.tsx 215 lines
1/**
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}
215
hooks/tracking.ts 90 lines
1/**
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}
90
hooks/boxes.ts 21 lines
1/** 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}
21
hooks/connectors.ts 266 lines
1/** 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}
266
types/index.d.ts 78 lines
1/** 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