Understand a codebase visually: a live architecture map that lights up as Claude reads and edits, a call graph explorer, the session's changes by layer with…

A Claude Code plugin marketplace, and the single home for Claude customizations that stay the same on every device: ten mods, the Claritymaxx skill, the sync-home skill, and the karpathy-guidelines and python-standards coding skills. To add something, ask Claude to put it "on all my devices". The sync-home skill and CLAUDE.md tell it how.
| Plugin | Command | What it does |
|---|---|---|
| usage-forecast | /forecast, /forecast hide, /forecast show | A band above the prompt. 5-hour and Weekly plan limits as coloured bars (green, then yellow from 70%, red from 90%) with the percent used, a reset countdown and the reset time (↻ resets in 1h 12m · 15:40), and ⚠ at this pace, out in 40m when you would run out before the reset. Below it, the context window as weather with a 12-turn sparkline and turns left. Replaces token-weather. |
| blast-radius | /blast-radius, /blast-radius <command> (dry run) | Catches rm -rf, git reset --hard, force pushes, git clean -f, git checkout ., git branch -D, git stash drop/clear, DROP/TRUNCATE, mkfs, dd of=/dev/.... It shows what the command would touch in a pane and forces a permission prompt. A deny from your settings is never loosened. |
| replay-theater | /replay, /replay 2 | Records each turn's Edit and Write calls. In the pane, n/p step through the diffs, o/w change turn, and Esc closes it. |
| changed-files | /changed-files | A live sidebar of every file touched this session, with +/- counts, edit counts and a new tag. It opens by itself on the first edit. |
| session-meter | /meter | The status line shows elapsed time, tool calls and files edited. A toast appears when a turn takes 60 s or more; change it with /plugin configure session-meter@harshit-mods. |
| claritymaxx | /claritymaxx:explain <topic>, or ask "explain..." | Third-party skill from v60samurai/claritymaxx. Builds a mental model first, then explains it as text, a diagram or a small HTML page. |
| fork-explorer | /fork <what if...>, or type in the pane | Runs a side question over the current session and shows 2-3 alternative approaches as cards side by side, with pros, cons and effort. "use this" puts the choice in your prompt box to edit and send. Nothing is added to your conversation. |
| mission-control | /mission | A live pane of what Claude is executing: each tool call with its input, status badge (✓ ✗ ⊘ ●), duration and output; subagents in their own lanes with tool and token counts; a network-tab style waterfall; time spent in the model between tools. v switches between the timeline and the outputs. Observe-only; it never changes a call. |
| diff-minimap | automatic | A thin strip beside each Edit and Write row in the transcript. Green, red and yellow ticks show where in the file the change sits, with the line range and the file length. |
| codebase-atlas | /atlas, /atlas changes, /atlas components <file>, /atlas calls <name>, /atlas decisions, /atlas rescan | One pane with six tabs. Map: folders as boxes in dependency layers (entry points on top, foundations at the bottom), lit yellow when Claude edits and cyan when it reads; select one for what it imports and what uses it. Changes: every uncommitted change mapped to the functions and classes it added, modified or removed, with call-site counts, the layers crossed and the files that import what changed. Components: a file's classes, functions and methods with size bars, what each calls, and which changed. Calls: callers and callees of any function; type a name or select one in the transcript, then walk the graph. Decisions: a timeline of decisions a small model pulls from each turn (turn off with extractDecisions). Explain: side-question explanations of a folder, a function or the session's changes that never enter your conversation. Works with or without git: in a git repo, changes are measured against the last commit; in a plain folder, against the files as Atlas first read them that session (dependencies, virtual environments and build output are skipped). Reads Python and JS/TS. |
| workbench | /wb, /wb changes [file], /wb preview <file>, /wb artifacts, /wb map, /wb usage, /wb calls <name>, /wb forecast, /wb workbench, /wb hud off | One workspace. A one-line band above the prompt with two slides, switched with ◀ ▶: Workbench (session time, tool calls, files changed with +/−, new files, plan limits and context on wide terminals, then the running tool or the last edit and its functions) and Forecast (5-hour and weekly limits with bars, reset times and pace, plus the context forecast; it shows the usage-forecast mod's band when that mod is on). A pane with six tabs on one row: Now, the turn's tool calls with status and timing, failures with their error, and subagents nested under the call that started them; Changes, every file with a change bar and the functions it touched, then a file's changed functions and highlighted diff since Claude first touched it, then each edit (replay with n/p); Preview, a live render of the file being edited (Markdown, CSV table, JSON tree, HTML screenshot, PNG, code) with recent files one press away; Artifacts, files created this session by Claude ✦ or its commands, with preview and copy path; Map, architecture layers with folder sizes and imports, a file's components, and the call graph; Usage, limits with reset times and pace, context, and cost per turn. Works with or without git. Claude Code's own diff panel sits above mod panes: run /diff to hide it if it covers the workbench. |
| crew | /crew, /crew auto off, /crew close | An Agents side pane, like a mission board for subagents. Three cards on top total the session's cost, tokens and time. Each subagent is a little pixel crew member whose hat shows its effort (heavy, careful, medium, light), with its model and effort level, context used with a bar, tokens, its share of the cost and how long it ran; it walks while it works and shows the tool it is running. Sections: Running, Completed (✓ done, ✗ failed, ⊘ stopped; folds) and Planned, the tasks from Claude's task or todo list with what each still waits for ("after 1, 3"). Click an agent for its task, tool count and answer. Opens by itself when the first subagent starts; /crew auto off stops that. |
| explainer-page | /explainer-page:explainer-page, or ask for a visual or interactive explainer page | A skill for textbook-style HTML pages that teach one concept: each idea in words, maths, a figure and code, with colour-coded notation (inputs blue, outputs orange, parameters green, functions violet, probabilities pink) kept identical everywhere; tables and figures (3D bars, node diagrams) drawn from one data source and highlighted together on hover; sliders where a parameter is the point; code cells with their real output; margin notes, callouts and check-yourself questions; light and dark themes and a contents sidebar. Publishes as a claude.ai artifact when that tool is available, otherwise writes an .html file. Triggers only on an explicit request for such a page. |
| savvy-flow | /savvy-flow:savvy-flow <task> | A copy of savvy-flow from johnnyvizz/claude-kit with one change: the top tier, savvy-fable, runs on Opus at max effort instead of the Fable model, so it works without Fable access. The session model plans the task, delegates pieces to five tiered worker agents (savvy-fable, savvy-heavy, savvy-careful, savvy-medium, savvy-light) and reviews what they return. tools/sync-savvy-flow.sh refreshes the copy from upstream and reapplies the change; the Sync savvy-flow GitHub Action runs it daily and commits when upstream changed. |
| savvy-progress | /agents-info | From the same repo, following its main branch the same way. A progress bar above the prompt driven by savvy-flow, and an agents panel with model, effort, step progress, context, estimated cost and time. Animated crabs draw in the desktop app; the terminal shows text rows (crew draws its sprites in both). |
| sync-home | (automatic) or ask "add X to all my devices" | A skill that tells Claude this repository is where plugins, mods, skills and other customizations go so they sync to every device. Claude adds them under plugins/, validates, pushes and tells you what to turn on. |
| karpathy-guidelines | /karpathy-guidelines:clean-code, or automatic on any coding task | Guidelines from Andrej Karpathy's notes on LLM coding mistakes: think before coding, simplicity first, surgical changes, goal-driven execution, with worked examples in EXAMPLES.md. |
| python-standards | /python-standards:python-standards, or automatic on Python work | Python standards: SOLID, DRY, KISS and YAGNI; FastAPI project layout; naming; OWASP-aligned secure coding; linting and testing setup. |
Add this marketplace to your claude.ai account once:
harshitmywork17/claude-mods. If the repository is private, your GitHub account must be connected to Claude.sync-home too, so every session knows to put new synced customizations here.Plugins on your account sync at session start, on every machine where you sign in to Claude Code with that account. Mods need Claude Code v2.1.287 or later (claude --version, then claude update). After you push a change here, press Check for updates on the marketplace in claude.ai, and turn on any new plugin. Then start a new session.
If a command such as /wb is missing on a machine:
claude --version must be 2.1.287 or later.claude plugin list should show workbench@synced. If it doesn't, the sync hasn't picked it up: check for updates in claude.ai, or install on that machine as below.claude plugin test run in an empty folder should say no hooks module to load. Any other message means mods are turned off there.Mods run in Claude Code only: the terminal, the desktop app's Code tab, IDE extensions and cloud sessions set up as below. Claude chat on the web, desktop and mobile shows no mod UI. Claritymaxx is a skill, so it also works in Claude chat and Cowork.
Cloud sessions at claude.ai/code don't load account plugins or the plugins a repository lists in .claude/settings.json. They do load plugin folders named in CLAUDE_CODE_PLUGIN_DIRS. Set it once on the cloud environment:
CLAUDE_CODE_PLUGIN_DIRS=/home/user/claude-mods/plugins ``harshitmywork17/claude-mods alongside the repository you're working on. It is cloned to /home/user/claude-mods, and every plugin in plugins/ loads, including new ones.A cloud session can only clone the repositories selected for it, because this repository is private. In a session without it, the variable points at a missing folder, which Claude Code skips.
claude plugin marketplace add harshitmywork17/claude-mods
claude plugin install workbench@harshit-mods # repeat for each plugin
Or run every plugin from a local clone, picking up changes with git pull: add "env": { "CLAUDE_CODE_PLUGIN_DIRS": "<path to clone>/plugins" } to ~/.claude/settings.json. Don't combine this with account sync on the same machine: when two copies share a name, only the first one loads.
Each mod has tests: claude plugin test plugins/<mod>. Check a mod with claude plugin validate plugins/<mod>. Increase version in a mod's plugin.json when you change it, because installed copies are cached by version.
hooks/register.tsx 1031 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, RenderNode, Register } from 'claude-code'
3
4import type {
5 Activity,
6 CallView,
7 ChangeMap,
8 Decision,
9 FileChange,
10 Graph,
11 Insight,
12 Outline,
13 Site,
14 SymbolKind,
15 Tab,
16} from '../types'
17import { bodyOf, calleesIn, enclosingSymbol, isDefinitionLine, symbolFromSelection } from './calls'
18import { countCallers, escapeRegex, lineDiff, markChanged, outlineOf, parseDiff, symbolChanges } from './changes'
19import type { DiffFile, Range } from './changes'
20import {
21 DECISION_MODEL,
22 DECISION_SYSTEM,
23 decisionPrompt,
24 explainChangesPrompt,
25 explainFunctionPrompt,
26 explainModulePrompt,
27 parseDecisions,
28} from './prompts'
29import {
30 IMPORT_PATTERN,
31 IMPORT_RE,
32 SKIPPED_DIRS,
33 SOURCE_GLOBS,
34 SYMBOL_PATTERN,
35 SYMBOL_RE,
36 buildScan,
37 countTexts,
38 dependentsOf,
39 grepLine,
40 grepMatches,
41 grepTexts,
42 groupOf,
43 isSource,
44 shortCount,
45} from './scan'
46import type { Scan } from './scan'
47
48const graphAtom = atom({ plugin: 'codebase-atlas', key: 'graph' } as const, null)
49const scanStatusAtom = atom({ plugin: 'codebase-atlas', key: 'scanStatus' } as const, 'idle')
50const scanErrorAtom = atom({ plugin: 'codebase-atlas', key: 'scanError' } as const, '')
51const activityAtom = atom({ plugin: 'codebase-atlas', key: 'activity' } as const, [])
52const tabAtom = atom({ plugin: 'codebase-atlas', key: 'tab' } as const, 'map')
53const focusAtom = atom({ plugin: 'codebase-atlas', key: 'focus' } as const, null)
54const changesAtom = atom({ plugin: 'codebase-atlas', key: 'changes' } as const, { status: 'idle', files: [], layers: [], dependents: [] })
55const outlineAtom = atom({ plugin: 'codebase-atlas', key: 'outline' } as const, null)
56const callAtom = atom({ plugin: 'codebase-atlas', key: 'call' } as const, null)
57const trailAtom = atom({ plugin: 'codebase-atlas', key: 'trail' } as const, [])
58const decisionsAtom = atom({ plugin: 'codebase-atlas', key: 'decisions' } as const, [])
59const isExtractingAtom = atom({ plugin: 'codebase-atlas', key: 'isExtracting' } as const, false)
60const insightAtom = atom({ plugin: 'codebase-atlas', key: 'insight' } as const, null)
61
62const MAX_CHANGED_FILES = 30
63const MAX_CALLER_NAMES = 25
64const MAX_CALLERS = 40
65const MAX_DECISIONS = 60
66const GIT_TIMEOUT_MS = 20_000
67const MAX_FOLDER_FILES = 1500
68const MAX_FOLDER_DIRS = 2000
69const MAX_FILE_BYTES = 512 * 1024
70
71type Source = { root: string; isGit: boolean }
72
73let cwd = ''
74let cache: Scan | null = null
75let isScanning = false
76let source: Source | null = null
77
78/** Folder mode: each source file's text and modification time as last read. */
79const current = new Map<string, { text: string; mtimeMs: number }>()
80/** Folder mode: each file as the first scan found it; null for a file that appeared after that scan. */
81const baseline = new Map<string, string | null>()
82let hasBaseline = false
83
84function setCwd(value: string): void {
85 cwd = value
86}
87
88type GitResult = { isOk: boolean; out: string; err: string }
89
90/** Runs git in the repo; `git grep` finding nothing (exit 1 with no error text) counts as success. */
91async function git($: EngineInterface, root: string, args: readonly string[]): Promise<GitResult> {
92 const run = await $.process.run(['git', '-C', root, ...args], { timeoutMs: GIT_TIMEOUT_MS })
93 const isEmptyGrep = args[0] === 'grep' && run.exitCode === 1 && run.stderr.trim() === ''
94 return { isOk: run.exitCode === 0 || isEmptyGrep, out: run.stdout, err: run.stderr.trim() }
95}
96
97/** The folder Atlas reads: the git repository around the session, or else the session's own folder. */
98async function findSource($: EngineInterface): Promise<Source | null> {
99 if (source !== null) return source
100 try {
101 const run = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: cwd || undefined, timeoutMs: 5000 })
102 if (run.exitCode === 0 && run.stdout.trim() !== '') {
103 source = { root: run.stdout.trim(), isGit: true }
104 return source
105 }
106 } catch {
107 // No git on this machine: read the folder directly.
108 }
109 if (cwd === '') return null
110 source = { root: cwd.replace(/\/+$/, ''), isGit: false }
111 return source
112}
113
114/** A path as the repo names it: relative to the root when inside it. */
115function relative(path: string, root: string): string {
116 return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
117}
118
119/** Lines in a text, not counting the empty piece after a final newline. */
120function lineTotal(text: string): number {
121 if (text === '') return 0
122 const lines = text.split('\n')
123 return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length
124}
125
126async function readText($: EngineInterface, path: string): Promise<string> {
127 try {
128 return await $.fs.read(path)
129 } catch {
130 return ''
131 }
132}
133
134/** Folder mode: the source files under the root, leaving out dependencies, environments, caches and build output. */
135async function walkFolder($: EngineInterface, root: string): Promise<{ path: string; mtimeMs: number }[]> {
136 const found: { path: string; mtimeMs: number }[] = []
137 const queue = ['']
138 let dirs = 0
139 while (queue.length > 0 && found.length < MAX_FOLDER_FILES && dirs < MAX_FOLDER_DIRS) {
140 const dir = queue.shift() ?? ''
141 dirs += 1
142 let entries: Awaited<ReturnType<EngineInterface['fs']['list']>>
143 try {
144 entries = await $.fs.list(dir === '' ? root : `${root}/${dir}`)
145 } catch {
146 continue
147 }
148 if (dir !== '' && entries.some(entry => entry.name === 'pyvenv.cfg')) continue
149 for (const entry of entries) {
150 const path = dir === '' ? entry.name : `${dir}/${entry.name}`
151 if (entry.kind === 'dir' && !entry.isLink && !entry.name.startsWith('.') && !SKIPPED_DIRS.has(entry.name)) queue.push(path)
152 else if (entry.kind === 'file' && isSource(entry.name) && entry.size <= MAX_FILE_BYTES) found.push({ path, mtimeMs: entry.mtimeMs })
153 }
154 }
155 return found.sort((a, b) => a.path.localeCompare(b.path)).slice(0, MAX_FOLDER_FILES)
156}
157
158/** Folder mode: re-reads only files whose time changed; the first scan's texts stay as the baseline. */
159async function syncFolder($: EngineInterface, root: string): Promise<void> {
160 const listed = await walkFolder($, root)
161 const seen = new Set<string>()
162 for (const file of listed) {
163 seen.add(file.path)
164 if (current.get(file.path)?.mtimeMs === file.mtimeMs) continue
165 const text = await readText($, `${root}/${file.path}`)
166 current.set(file.path, { text, mtimeMs: file.mtimeMs })
167 if (!baseline.has(file.path)) baseline.set(file.path, hasBaseline ? null : text)
168 }
169 for (const path of [...current.keys()]) if (!seen.has(path)) current.delete(path)
170 hasBaseline = true
171}
172
173function currentTexts(): Map<string, string> {
174 return new Map([...current].map(([path, file]) => [path, file.text]))
175}
176
177/** Folder mode: the same scan git mode builds, from the texts in memory. */
178function folderScan(root: string): Scan {
179 const texts = currentTexts()
180 return buildScan(root, [...texts.keys()].join('\n'), countTexts(texts), grepTexts(texts, IMPORT_RE), grepTexts(texts, SYMBOL_RE), false)
181}
182
183/** Lines matching a pattern across the sources, as `git grep -n` prints them (`-o` with `isOnlyMatch`). */
184async function searchSources($: EngineInterface, src: Source, gitPattern: string, jsPattern: RegExp, isOnlyMatch: boolean): Promise<string> {
185 if (src.isGit) {
186 const flags = isOnlyMatch ? ['-n', '-o', '-I', '-E'] : ['-n', '-I', '-E']
187 return (await git($, src.root, ['grep', ...flags, gitPattern, '--', ...SOURCE_GLOBS])).out
188 }
189 return isOnlyMatch ? grepMatches(currentTexts(), jsPattern) : grepTexts(currentTexts(), jsPattern)
190}
191
192async function sourceText($: EngineInterface, src: Source, path: string): Promise<string> {
193 return src.isGit ? readText($, `${src.root}/${path}`) : (current.get(path)?.text ?? readText($, `${src.root}/${path}`))
194}
195
196/** Scans the code for files, imports and definitions, and draws the map from them. */
197async function scan($: EngineInterface): Promise<void> {
198 if (isScanning) return
199 isScanning = true
200 await update($, scanStatusAtom, () => 'scanning')
201 try {
202 cache = null
203 const src = await findSource($)
204 if (src === null) {
205 await update($, scanStatusAtom, () => 'no-folder')
206 return
207 }
208 if (src.isGit) {
209 const files = await git($, src.root, ['ls-files', '--', ...SOURCE_GLOBS])
210 if (!files.isOk) throw new Error(files.err || 'git ls-files failed')
211 const counts = await git($, src.root, ['grep', '-c', '-I', '', '--', ...SOURCE_GLOBS])
212 const imports = await git($, src.root, ['grep', '-n', '-I', '-E', IMPORT_PATTERN, '--', ...SOURCE_GLOBS])
213 const symbols = await git($, src.root, ['grep', '-n', '-I', '-E', SYMBOL_PATTERN, '--', ...SOURCE_GLOBS])
214 cache = buildScan(src.root, files.out, counts.out, imports.out, symbols.out, true)
215 } else {
216 await syncFolder($, src.root)
217 cache = folderScan(src.root)
218 }
219 const graph = cache.graph
220 await update($, graphAtom, () => graph)
221 await update($, scanStatusAtom, () => 'ready')
222 } catch (error) {
223 await update($, scanErrorAtom, () => String(error))
224 await update($, scanStatusAtom, () => 'error')
225 } finally {
226 isScanning = false
227 }
228}
229
230async function ensureScan($: EngineInterface): Promise<Scan | null> {
231 if (cache === null) await scan($)
232 return cache
233}
234
235async function recordActivity($: EngineInterface, path: string, kind: 'read' | 'edit'): Promise<void> {
236 await update($, activityAtom, list => {
237 const found = list.find(one => one.path === path) ?? { path, reads: 0, edits: 0 }
238 const next = kind === 'read' ? { ...found, reads: found.reads + 1 } : { ...found, edits: found.edits + 1 }
239 return [next, ...list.filter(one => one.path !== path)].slice(0, 300)
240 })
241}
242
243function toFileChange(diff: DiffFile, after: string, before: string): FileChange {
244 const symbols = symbolChanges(outlineOf(after, diff.path), outlineOf(before, diff.path), diff.ranges)
245 return { path: diff.path, group: groupOf(diff.path), status: diff.status, added: diff.added, removed: diff.removed, symbols }
246}
247
248/** Git mode: uncommitted and untracked changes against HEAD. */
249async function gitChanges($: EngineInterface, root: string): Promise<FileChange[]> {
250 const diff = await git($, root, ['diff', '-U0', 'HEAD', '--', ...SOURCE_GLOBS])
251 const untracked = await git($, root, ['ls-files', '--others', '--exclude-standard', '--', ...SOURCE_GLOBS])
252 const diffs = parseDiff(diff.isOk ? diff.out : '')
253 for (const path of untracked.out.split('\n').filter(Boolean)) {
254 const lines = lineTotal(await readText($, `${root}/${path}`))
255 diffs.push({ path, status: 'added', added: lines, removed: 0, ranges: [[1, lines]] })
256 }
257 const files: FileChange[] = []
258 for (const one of diffs.slice(0, MAX_CHANGED_FILES)) {
259 const after = one.status === 'deleted' ? '' : await readText($, `${root}/${one.path}`)
260 const before = one.status === 'added' ? '' : (await git($, root, ['show', `HEAD:${one.path}`])).out
261 files.push(toFileChange(one, after, before))
262 }
263 return files
264}
265
266/** Folder mode: every file whose text differs from the baseline snapshot. */
267function folderDiffs(): (DiffFile & { text: string; before: string; after: string })[] {
268 const out: (DiffFile & { text: string; before: string; after: string })[] = []
269 for (const path of [...new Set([...baseline.keys(), ...current.keys()])].sort()) {
270 const before = baseline.get(path) ?? null
271 const after = current.get(path)?.text ?? null
272 if (before === after || (before === null && after === null)) continue
273 const diff = lineDiff(path, before, after)
274 if (diff.ranges.length === 0) continue
275 out.push({ ...diff, before: before ?? '', after: after ?? '' })
276 if (out.length >= MAX_CHANGED_FILES) break
277 }
278 return out
279}
280
281/** Maps every change to the functions and classes it touched, with call sites, layers and impact. */
282async function refreshChanges($: EngineInterface): Promise<void> {
283 await update($, changesAtom, (current): ChangeMap => ({ ...current, status: 'loading' }))
284 try {
285 const src = await findSource($)
286 if (src === null) throw new Error('Codebase Atlas could not tell which folder to read.')
287 let scanned = await ensureScan($)
288 let files: FileChange[]
289 if (src.isGit) {
290 files = await gitChanges($, src.root)
291 } else {
292 await syncFolder($, src.root)
293 scanned = folderScan(src.root)
294 cache = scanned
295 const graph = scanned.graph
296 await update($, graphAtom, () => graph)
297 files = folderDiffs().map(diff => toFileChange(diff, diff.after, diff.before))
298 }
299
300 const names = [...new Set(files.flatMap(file => file.symbols.filter(s => s.change !== 'removed').map(s => s.name)))]
301 .filter(name => /^[A-Za-z_$][\w$]*$/.test(name))
302 .slice(0, MAX_CALLER_NAMES)
303 if (names.length > 0) {
304 const alternatives = names.map(escapeRegex).join('|')
305 const hits = await searchSources($, src, `[A-Za-z0-9_$]*(${alternatives})[[:space:]]*\\(`, new RegExp(`[A-Za-z0-9_$]*(${alternatives})\\s*\\(`), true)
306 const definitions = new Set<string>()
307 for (const [name, sites] of scanned?.symbols ?? []) for (const site of sites) definitions.add(`${site.file}:${site.line}:${name}`)
308 for (const file of files) for (const s of file.symbols) definitions.add(`${file.path}:${s.line}:${s.name}`)
309 const counts = countCallers(hits, definitions, new Set(names))
310 for (const file of files) for (const s of file.symbols) s.callers = counts.get(s.name) ?? 0
311 }
312
313 const levelOf = new Map((await read($, graphAtom))?.groups.map(group => [group.id, group.level]) ?? [])
314 const layers = [...new Set(files.map(file => file.group))].sort((a, b) => (levelOf.get(b) ?? 0) - (levelOf.get(a) ?? 0))
315 const dependents = scanned === null ? [] : dependentsOf(new Set(files.map(file => file.path)), scanned.imports).slice(0, 30)
316 const map: ChangeMap = { status: 'ready', files, layers, dependents }
317 await update($, changesAtom, () => map)
318 } catch (error) {
319 await update($, changesAtom, (current): ChangeMap => ({ ...current, status: 'error', error: String(error) }))
320 }
321}
322
323/** Builds the component outline of one file: its classes, functions and methods, what each calls, and what changed. */
324async function loadOutline($: EngineInterface, path: string): Promise<void> {
325 await update($, outlineAtom, (): Outline => ({ file: path, status: 'loading', lines: 0, entries: [] }))
326 await update($, tabAtom, () => 'components')
327 try {
328 const src = await findSource($)
329 if (src === null) throw new Error('Codebase Atlas could not tell which folder to read.')
330 const scanned = await ensureScan($)
331 const text = await sourceText($, src, path)
332 if (text === '') throw new Error(`Could not read ${path}.`)
333 let ranges: Range[]
334 if (src.isGit) {
335 const diff = parseDiff((await git($, src.root, ['diff', '-U0', 'HEAD', '--', path])).out)[0]
336 const isUntracked = (await git($, src.root, ['ls-files', '--', path])).out.trim() === ''
337 ranges = isUntracked ? [[1, lineTotal(text)]] : (diff?.ranges ?? [])
338 } else {
339 const before = baseline.get(path)
340 ranges = before === undefined ? [] : lineDiff(path, before, text).ranges
341 }
342 const entries = markChanged(outlineOf(text, path), ranges).map(entry => ({
343 ...entry,
344 calls: entry.kind === 'class' ? [] : calleesIn(bodyOf(text, entry.line, path), entry.name, scanned?.symbols ?? new Map())
345 .filter(site => site.isKnown)
346 .map(site => site.symbol)
347 .slice(0, 8),
348 }))
349 await update($, outlineAtom, (): Outline => ({ file: path, status: 'ready', lines: lineTotal(text), entries }))
350 } catch (error) {
351 await update($, outlineAtom, (): Outline => ({ file: path, status: 'error', lines: 0, entries: [], error: String(error) }))
352 }
353}
354
355/** Finds a function's definition, who calls it and what it calls. */
356async function loadCalls($: EngineInterface, symbol: string, isFromTrail = false): Promise<void> {
357 const name = symbol.trim()
358 if (!/^[A-Za-z_$][\w$]*$/.test(name)) {
359 await update($, callAtom, (): CallView => ({ symbol: name, status: 'error', callers: [], callees: [], error: 'Type one function or class name.' }))
360 await update($, tabAtom, () => 'calls')
361 return
362 }
363 await update($, callAtom, (): CallView => ({ symbol: name, status: 'loading', callers: [], callees: [] }))
364 await update($, tabAtom, () => 'calls')
365 if (!isFromTrail) await update($, trailAtom, trail => [...trail.filter(one => one !== name), name].slice(-20))
366 try {
367 const src = await findSource($)
368 if (src === null) throw new Error('Codebase Atlas could not tell which folder to read.')
369 const scanned = await ensureScan($)
370 const index = scanned?.symbols ?? new Map<string, Site[]>()
371 const definition = index.get(name)?.[0]
372
373 const texts = new Map<string, string>()
374 const textOf = async (file: string): Promise<string> => {
375 if (!texts.has(file)) texts.set(file, await sourceText($, src, file))
376 return texts.get(file) ?? ''
377 }
378
379 const escaped = escapeRegex(name)
380 const hits = await searchSources($, src, `(^|[^A-Za-z0-9_$])${escaped}[[:space:]]*\\(`, new RegExp(`(^|[^A-Za-z0-9_$])${escaped}\\s*\\(`), false)
381 const callers: Site[] = []
382 const seen = new Set<string>()
383 for (const row of hits.split('\n')) {
384 const hit = grepLine(row)
385 if (hit === null || isDefinitionLine(hit.text, name)) continue
386 const caller = enclosingSymbol(await textOf(hit.file), hit.line, hit.file)
387 const key = `${caller}@${hit.file}`
388 if (seen.has(key)) continue
389 seen.add(key)
390 callers.push({ symbol: caller, file: hit.file, line: hit.line, isKnown: index.has(caller) })
391 if (callers.length >= MAX_CALLERS) break
392 }
393
394 const callees = definition === undefined ? [] : calleesIn(bodyOf(await textOf(definition.file), definition.line, definition.file), name, index)
395 const view: CallView = {
396 symbol: name,
397 status: definition === undefined && callers.length === 0 ? 'missing' : 'ready',
398 definition,
399 callers,
400 callees,
401 }
402 await update($, callAtom, () => view)
403 } catch (error) {
404 await update($, callAtom, (): CallView => ({ symbol: name, status: 'error', callers: [], callees: [], error: String(error) }))
405 }
406}
407
408async function showInsight($: EngineInterface, title: string, prompt: () => Promise<string>): Promise<void> {
409 const loading: Insight = { title, status: 'loading', text: '' }
410 await update($, insightAtom, () => loading)
411 await update($, tabAtom, (): Tab => 'explain')
412 try {
413 const reply = await $.model.fork({ prompt: await prompt() })
414 const text = reply.isAnswered ? reply.text : `No answer (${reply.reason}). Ask Claude something first, then try again.`
415 await update($, insightAtom, () => ({ title, status: reply.isAnswered ? 'ready' : 'error', text }))
416 } catch (error) {
417 await update($, insightAtom, () => ({ title, status: 'error', text: String(error) }))
418 }
419}
420
421async function explainModule($: EngineInterface, id: string): Promise<void> {
422 await showInsight($, `Folder ${id}`, async () => {
423 const scanned = await ensureScan($)
424 const files = [...new Set([...(scanned?.symbols.values() ?? [])].flat().map(site => site.file))].filter(file => groupOf(file) === id)
425 const symbols = [...(scanned?.symbols ?? new Map<string, Site[]>())]
426 .filter(([, sites]) => sites.some(site => groupOf(site.file) === id))
427 .map(([name]) => name)
428 const edges = (await read($, graphAtom))?.edges ?? []
429 return explainModulePrompt(
430 id,
431 files,
432 symbols,
433 edges.filter(edge => edge.from === id).map(edge => edge.to),
434 edges.filter(edge => edge.to === id).map(edge => edge.from),
435 )
436 })
437}
438
439async function explainFunction($: EngineInterface, view: CallView): Promise<void> {
440 await showInsight($, `Function ${view.symbol}`, async () => {
441 const src = await findSource($)
442 const definition = view.definition
443 const body = definition === undefined || src === null ? '' : bodyOf(await sourceText($, src, definition.file), definition.line, definition.file).join('\n')
444 return explainFunctionPrompt(
445 view.symbol,
446 definition?.file ?? 'an unknown file',
447 body,
448 view.callers.map(site => site.symbol),
449 view.callees.filter(site => site.isKnown).map(site => site.symbol),
450 )
451 })
452}
453
454async function explainChanges($: EngineInterface): Promise<void> {
455 await showInsight($, 'This session’s changes', async () => {
456 const changes = await read($, changesAtom)
457 const summary = changes.files
458 .map(file => `${file.path} (+${file.added} −${file.removed}): ${file.symbols.map(s => `${s.change} ${s.name}`).join(', ') || 'no function-level change'}`)
459 .join('\n')
460 const src = await findSource($)
461 const diff = src === null ? '' : src.isGit ? (await git($, src.root, ['diff', 'HEAD', '--', ...SOURCE_GLOBS])).out : folderDiffs().map(one => one.text).join('\n')
462 return explainChangesPrompt(summary || 'No changes found.', diff)
463 })
464}
465
466/** Pulls the decisions out of one finished turn with a small model, in the background. */
467async function extractDecisions($: EngineInterface, request: string, answer: string, edited: readonly string[]): Promise<void> {
468 await update($, isExtractingAtom, () => true)
469 try {
470 const reply = await $.model.complete({
471 model: DECISION_MODEL,
472 system: DECISION_SYSTEM,
473 prompt: decisionPrompt(request, answer, edited),
474 maxTokens: 700,
475 })
476 if (reply.isAnswered) {
477 const found = parseDecisions(reply.text, request.slice(0, 120), `d${await $.clock.now()}`)
478 if (found.length > 0) await update($, decisionsAtom, list => [...list, ...found].slice(-MAX_DECISIONS))
479 }
480 } finally {
481 await update($, isExtractingAtom, () => false)
482 }
483}
484
485const PANE = 'codebase-atlas'
486const TITLE = 'Codebase Atlas'
487const READ_TOOLS = new Set(['Read'])
488const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
489const LONG_ANSWER = 400
490
491type T = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Markdown'>
492
493const TABS: { tab: Tab; label: string; key: string }[] = [
494 { tab: 'map', label: 'Map', key: '1' },
495 { tab: 'changes', label: 'Changes', key: '2' },
496 { tab: 'components', label: 'Components', key: '3' },
497 { tab: 'calls', label: 'Calls', key: '4' },
498 { tab: 'decisions', label: 'Decisions', key: '5' },
499 { tab: 'explain', label: 'Explain', key: '6' },
500]
501
502const KIND_ICON: Record<SymbolKind, string> = { class: '◆', function: 'ƒ', method: '·' }
503const CHANGE_STYLE = {
504 added: { mark: '+', color: 'green' },
505 modified: { mark: '~', color: 'yellow' },
506 removed: { mark: '−', color: 'red' },
507} as const
508
509function filePath(path: string, line?: number): string {
510 return line === undefined || line === 0 ? path : `${path}:${line}`
511}
512
513type GroupActivity = { reads: number; edits: number }
514
515function activityByGroup(activity: readonly Activity[], root: string): Map<string, GroupActivity> {
516 const out = new Map<string, GroupActivity>()
517 for (const one of activity) {
518 const id = groupOf(relative(one.path, root))
519 const sum = out.get(id) ?? { reads: 0, edits: 0 }
520 out.set(id, { reads: sum.reads + one.reads, edits: sum.edits + one.edits })
521 }
522 return out
523}
524
525function mapView($: EngineInterface, t: T, graph: Graph, activity: readonly Activity[], focus: string | null): RenderNode {
526 const { Box, Text, Button } = t
527 const touched = activityByGroup(activity, graph.root)
528 const levels = [...new Set(graph.groups.map(group => group.level))].sort((a, b) => b - a)
529 const focused = graph.groups.find(group => group.id === focus)
530 const files = activity.map(one => relative(one.path, graph.root)).filter(path => focused !== undefined && groupOf(path) === focused.id)
531
532 return (
533 <Box flexDirection="column">
534 <Box flexDirection="row" gap={2}>
535 <Text dimColor>
536 {graph.files} files · {graph.groups.length} folders{graph.isTruncated ? ' (largest shown)' : ''}
537 </Text>
538 {!graph.isGit && <Text color="magenta">folder mode (no git)</Text>}
539 <Text color="yellow">■ edited</Text>
540 <Text color="cyan">■ read</Text>
541 <Text dimColor>■ untouched</Text>
542 </Box>
543 {levels.map((level, index) => (
544 <Box key={`level-${level}`} flexDirection="column">
545 {index > 0 && <Text dimColor>{' ▼ imports'}</Text>}
546 <Box flexDirection="row" flexWrap="wrap" gap={1}>
547 <Box width={4} flexShrink={0}>
548 <Text dimColor>L{level}</Text>
549 </Box>
550 {graph.groups
551 .filter(group => group.level === level)
552 .map(group => {
553 const hit = touched.get(group.id)
554 const color = hit === undefined ? 'gray' : hit.edits > 0 ? 'yellow' : 'cyan'
555 return (
556 <Box
557 key={`box-${group.id}`}
558 flexDirection="column"
559 borderStyle={group.id === focus ? 'double' : 'round'}
560 borderColor={color}
561 paddingX={1}
562 >
563 <Button key={`g:${group.id}`} label={group.id} plain onPress={() => update($, focusAtom, now => (now === group.id ? null : group.id))} />
564 <Text dimColor>
565 {group.files} files · {shortCount(group.lines)} lines
566 </Text>
567 {hit !== undefined && (
568 <Text color={color}>
569 {hit.edits > 0 ? `✎ ${hit.edits} ` : ''}
570 {hit.reads > 0 ? `◉ ${hit.reads}` : ''}
571 </Text>
572 )}
573 </Box>
574 )
575 })}
576 </Box>
577 </Box>
578 ))}
579 {focused !== undefined && (
580 <Box flexDirection="column" borderStyle="round" borderColor="magenta" paddingX={1} marginTop={1}>
581 <Text bold color="magenta">
582 {focused.id} · {focused.symbols} functions and classes · layer L{focused.level}
583 </Text>
584 <Text>
585 imports → {graph.edges.filter(edge => edge.from === focused.id).map(edge => `${edge.to} (${edge.count})`).join(', ') || 'nothing in the repo'}
586 </Text>
587 <Text>
588 used by ← {graph.edges.filter(edge => edge.to === focused.id).map(edge => `${edge.from} (${edge.count})`).join(', ') || 'nothing in the repo'}
589 </Text>
590 {files.length > 0 && <Text dimColor>Touched this session:</Text>}
591 {files.slice(0, 8).map((path, index) => (
592 <Button key={`mf:${index}`} label={`▸ ${path}`} plain onPress={() => void loadOutline($, path)} />
593 ))}
594 <Box flexDirection="row" gap={1}>
595 <Button key="explain-folder" label="explain this folder" hotkey="e" onPress={() => void explainModule($, focused.id)} />
596 </Box>
597 </Box>
598 )}
599 {focused === undefined && <Text dimColor>Select a folder to see what it imports, what uses it, and to explain it.</Text>}
600 </Box>
601 )
602}
603
604function changesView($: EngineInterface, t: T, changes: ChangeMap, isGit: boolean): RenderNode {
605 const { Box, Text, Button } = t
606 const added = changes.files.reduce((sum, file) => sum + file.added, 0)
607 const removed = changes.files.reduce((sum, file) => sum + file.removed, 0)
608
609 return (
610 <Box flexDirection="column">
611 {changes.status === 'loading' && <Text color="cyan">Mapping changes to functions…</Text>}
612 {changes.status === 'error' && <Text color="red">{changes.error}</Text>}
613 {changes.status === 'idle' && <Text dimColor>Press refresh to map the changes to the functions they touch.</Text>}
614 {!isGit && <Text dimColor>No git here: changes are compared with the files as Codebase Atlas first read them this session.</Text>}
615 {changes.status === 'ready' && changes.files.length === 0 && (
616 <Text dimColor>{isGit ? 'No uncommitted changes in source files.' : 'No changes since Codebase Atlas first read this folder.'}</Text>
617 )}
618 {changes.files.length > 0 && (
619 <Box flexDirection="column">
620 <Box flexDirection="row" gap={1}>
621 <Text bold>
622 {changes.files.length} file{changes.files.length === 1 ? '' : 's'}
623 </Text>
624 <Text color="green">+{added}</Text>
625 <Text color="red">−{removed}</Text>
626 </Box>
627 <Text color="magenta" wrap="truncate-end">
628 Layers crossed: {changes.layers.join(' → ')}
629 </Text>
630 </Box>
631 )}
632 {changes.files.map((file, fileIndex) => (
633 <Box key={`cf-${fileIndex}`} flexDirection="column" marginTop={1}>
634 <Box flexDirection="row" gap={1}>
635 <Button key={`cfile:${fileIndex}`} label={file.path} plain onPress={() => void loadOutline($, file.path)} />
636 <Text color={file.status === 'added' ? 'green' : file.status === 'deleted' ? 'red' : 'yellow'}>{file.status}</Text>
637 <Text color="green">+{file.added}</Text>
638 <Text color="red">−{file.removed}</Text>
639 </Box>
640 {file.symbols.length === 0 && <Text dimColor>{' no function-level change (imports, constants or top-level code)'}</Text>}
641 {file.symbols.map((change, index) => (
642 <Box key={`cs-${fileIndex}-${index}`} flexDirection="row" gap={1}>
643 <Text color={CHANGE_STYLE[change.change].color}>
644 {' '}
645 {CHANGE_STYLE[change.change].mark} {KIND_ICON[change.kind]}
646 </Text>
647 {change.change === 'removed' ? (
648 <Text strikethrough dimColor>
649 {change.name}
650 </Text>
651 ) : (
652 <Button key={`csym:${fileIndex}:${index}`} label={change.name} plain onPress={() => void loadCalls($, change.name)} />
653 )}
654 <Text dimColor>
655 {change.change} · L{change.line}
656 {change.change === 'removed' ? '' : ` · ${change.callers} call site${change.callers === 1 ? '' : 's'}`}
657 </Text>
658 </Box>
659 ))}
660 </Box>
661 ))}
662 {changes.dependents.length > 0 && (
663 <Box flexDirection="column" marginTop={1}>
664 <Text bold color="yellow">
665 Affected elsewhere: {changes.dependents.length} file{changes.dependents.length === 1 ? '' : 's'} import what changed
666 </Text>
667 {changes.dependents.slice(0, 10).map((path, index) => (
668 <Button key={`dep:${index}`} label={`▸ ${path}`} plain onPress={() => void loadOutline($, path)} />
669 ))}
670 </Box>
671 )}
672 <Box flexDirection="row" gap={1} marginTop={1}>
673 <Button key="refresh-changes" label="refresh" hotkey="r" onPress={() => void refreshChanges($)} />
674 {changes.files.length > 0 && <Button key="explain-changes" label="explain these changes" hotkey="e" onPress={() => void explainChanges($)} />}
675 </Box>
676 </Box>
677 )
678}
679
680function componentsView($: EngineInterface, t: T, outline: Outline | null, recent: readonly string[], columns: number): RenderNode {
681 const { Box, Text, Button } = t
682 const longest = Math.max(1, ...(outline?.entries ?? []).map(entry => entry.endLine - entry.line + 1))
683 const barWidth = Math.max(6, Math.min(24, Math.floor(columns * 0.2)))
684
685 return (
686 <Box flexDirection="column">
687 {recent.length > 0 && (
688 <Box flexDirection="row" flexWrap="wrap" gap={1}>
689 <Text dimColor>Files:</Text>
690 {recent.slice(0, 8).map((path, index) => (
691 <Button key={`rf:${index}`} label={path.split('/').pop() ?? path} plain onPress={() => void loadOutline($, path)} />
692 ))}
693 </Box>
694 )}
695 {outline === null && <Text dimColor>Pick a file above, or a file in Map or Changes, to see its classes, functions and what each calls.</Text>}
696 {outline?.status === 'loading' && <Text color="cyan">Reading {outline.file}…</Text>}
697 {outline?.status === 'error' && <Text color="red">{outline.error}</Text>}
698 {outline?.status === 'ready' && (
699 <Box flexDirection="column" marginTop={1}>
700 <Text bold>
701 {outline.file} · {outline.lines} lines · {outline.entries.length} components ·{' '}
702 <Text color="yellow">{outline.entries.filter(entry => entry.isChanged).length} changed</Text>
703 </Text>
704 {outline.entries.length === 0 && <Text dimColor>No functions or classes found.</Text>}
705 {outline.entries.map((entry, index) => {
706 const size = entry.endLine - entry.line + 1
707 const fill = Math.max(1, Math.round((size / longest) * barWidth))
708 return (
709 <Box key={`oe-${index}`} flexDirection="column">
710 <Box flexDirection="row" gap={1}>
711 <Text>{' '.repeat(entry.depth)}</Text>
712 <Text color={entry.isChanged ? 'yellow' : entry.kind === 'class' ? 'magenta' : 'cyan'}>{KIND_ICON[entry.kind]}</Text>
713 {entry.kind === 'class' ? (
714 <Text bold color={entry.isChanged ? 'yellow' : undefined}>
715 {entry.name}
716 </Text>
717 ) : (
718 <Button key={`oc:${index}`} label={entry.name} plain onPress={() => void loadCalls($, entry.name)} />
719 )}
720 <Text dimColor>
721 L{entry.line}-{entry.endLine}
722 </Text>
723 <Text color={entry.isChanged ? 'yellow' : 'gray'}>{'▇'.repeat(fill)}</Text>
724 <Text dimColor>{size}</Text>
725 {entry.isChanged && <Text color="yellow">● changed</Text>}
726 </Box>
727 {entry.calls.length > 0 && (
728 <Text dimColor wrap="truncate-end">
729 {' '.repeat(entry.depth + 2)}→ {entry.calls.join(', ')}
730 </Text>
731 )}
732 </Box>
733 )
734 })}
735 </Box>
736 )}
737 </Box>
738 )
739}
740
741function siteRow($: EngineInterface, t: T, site: Site, key: string, arrow: string): RenderNode {
742 const { Box, Text, Button } = t
743 return (
744 <Box key={`row-${key}`} flexDirection="row" gap={1}>
745 <Text dimColor>{arrow}</Text>
746 {site.isKnown ? (
747 <Button key={key} label={site.symbol} plain onPress={() => void loadCalls($, site.symbol)} />
748 ) : (
749 <Text dimColor>{site.symbol}</Text>
750 )}
751 <Text dimColor wrap="truncate-end">
752 {site.file === '' ? '(outside the repo)' : filePath(site.file, site.line)}
753 </Text>
754 </Box>
755 )
756}
757
758function callsView($: EngineInterface, t: T, call: CallView | null, trail: readonly string[], ask: RenderNode | null): RenderNode {
759 const { Box, Text, Button } = t
760 const previous = call === null ? undefined : trail[trail.indexOf(call.symbol) - 1]
761
762 return (
763 <Box flexDirection="column">
764 {ask}
765 <Box flexDirection="row" gap={1}>
766 <Button
767 key="from-selection"
768 label="use my selection"
769 hotkey="s"
770 onPress={async () => {
771 const selected = await $.ui.selection()
772 const symbol = selected === undefined ? null : symbolFromSelection(selected.text)
773 if (symbol === null) $.ui.toast('Codebase Atlas: select a function name in the transcript first.')
774 else await loadCalls($, symbol)
775 }}
776 />
777 {previous !== undefined && <Button key="back" label={`back to ${previous}`} hotkey="b" onPress={() => void loadCalls($, previous, true)} />}
778 </Box>
779 {call === null && <Text dimColor>Type a function name, or select one in the transcript and press "use my selection".</Text>}
780 {call?.status === 'loading' && <Text color="cyan">Tracing {call.symbol}…</Text>}
781 {(call?.status === 'error' || call?.status === 'missing') && (
782 <Text color="red">{call.status === 'missing' ? `No definition or calls of ${call.symbol} found in source files.` : call.error}</Text>
783 )}
784 {call?.status === 'ready' && (
785 <Box flexDirection="column" marginTop={1}>
786 {trail.length > 1 && (
787 <Text dimColor wrap="truncate-start">
788 {trail.join(' › ')}
789 </Text>
790 )}
791 <Box flexDirection="row" gap={1}>
792 <Text bold color="cyan">
793 ƒ {call.symbol}
794 </Text>
795 <Text dimColor>{call.definition === undefined ? 'definition not found' : filePath(call.definition.file, call.definition.line)}</Text>
796 </Box>
797 <Text bold color="magenta">
798 ▲ Called by ({call.callers.length})
799 </Text>
800 {call.callers.length === 0 && <Text dimColor>{' no callers found: an entry point, or only called dynamically'}</Text>}
801 {call.callers.map((site, index) => siteRow($, t, site, `caller:${index}`, ' ├'))}
802 <Text bold color="green">
803 ▼ Calls ({call.callees.filter(site => site.isKnown).length} in the repo)
804 </Text>
805 {call.callees.length === 0 && <Text dimColor>{' calls nothing'}</Text>}
806 {call.callees.map((site, index) => siteRow($, t, site, `callee:${index}`, ' ├'))}
807 <Box marginTop={1}>
808 <Button key="explain-function" label="explain this function" hotkey="e" onPress={() => void explainFunction($, call)} />
809 </Box>
810 </Box>
811 )}
812 </Box>
813 )
814}
815
816function decisionsView($: EngineInterface, t: T, decisions: readonly Decision[], isExtracting: boolean, isAuto: boolean): RenderNode {
817 const { Box, Text } = t
818 return (
819 <Box flexDirection="column">
820 <Text dimColor>
821 {isAuto ? 'Decisions are pulled out after each turn that edits code or answers at length.' : 'Automatic extraction is off (extractDecisions in /config).'}
822 {isExtracting ? ' Reading the last turn…' : ''}
823 </Text>
824 {decisions.length === 0 && <Text dimColor>No decisions recorded yet.</Text>}
825 {[...decisions].reverse().map((decision, index) => (
826 <Box key={`dec-${decision.id}`} flexDirection="row" marginTop={1}>
827 <Box flexDirection="column" width={2} flexShrink={0}>
828 <Text color="magenta">●</Text>
829 {index < decisions.length - 1 && <Text color="magenta">│</Text>}
830 </Box>
831 <Box flexDirection="column" flexShrink={1}>
832 <Text bold>{decision.decision}</Text>
833 {decision.because !== '' && <Text>because {decision.because}</Text>}
834 {decision.alternatives.length > 0 && <Text dimColor>instead of: {decision.alternatives.join(' · ')}</Text>}
835 <Text dimColor wrap="truncate-end">
836 from “{decision.prompt}”
837 </Text>
838 </Box>
839 </Box>
840 ))}
841 </Box>
842 )
843}
844
845function explainView(t: T, insight: Insight | null): RenderNode {
846 const { Box, Text, Markdown } = t
847 if (insight === null) {
848 return <Text dimColor>Use an "explain" button in Map, Changes or Calls. Answers appear here and never enter your conversation.</Text>
849 }
850 return (
851 <Box flexDirection="column">
852 <Text bold color="cyan">
853 {insight.title}
854 </Text>
855 {insight.status === 'loading' && <Text color="cyan">Thinking it through…</Text>}
856 {insight.status === 'error' && <Text color="red">{insight.text}</Text>}
857 {insight.status === 'ready' && <Markdown key="insight" text={insight.text} />}
858 </Box>
859 )
860}
861
862async function openTab($: EngineInterface, args: string): Promise<void> {
863 const [word = '', ...rest] = args.trim().split(/\s+/)
864 const value = rest.join(' ')
865 if (word === 'rescan') {
866 await update($, tabAtom, () => 'map')
867 await scan($)
868 } else if (word === 'changes') {
869 await update($, tabAtom, () => 'changes')
870 await refreshChanges($)
871 } else if ((word === 'components' || word === 'file') && value !== '') {
872 await loadOutline($, value)
873 } else if (word === 'calls' && value !== '') {
874 await loadCalls($, value)
875 } else if (TABS.some(one => one.tab === word)) {
876 await update($, tabAtom, () => word as Tab)
877 }
878}
879
880export const register: Register = (on, options) => {
881 const isAutoDecisions = options.extractDecisions !== false
882 let request = ''
883 const editedThisTurn = new Set<string>()
884 let hasNewFile = false
885
886 on('session.start', async ($, e, next) => {
887 setCwd(e.cwd)
888 await $.command.register({
889 name: 'atlas',
890 description: 'Codebase Atlas: architecture map, changes by function, components, call graph, decisions',
891 argumentHint: '[map|changes|components <file>|calls <name>|decisions|explain|rescan]',
892 immediate: true,
893 })
894 $.clock.after(1500, () => void scan($).catch(() => undefined))
895
896 return next(e)
897 })
898
899 on('tool.call', async ($, e, next) => {
900 const ran = await next(e)
901 try {
902 const tool = String(e.tool)
903 if (ran.deny !== undefined || ran.isError === true || (!READ_TOOLS.has(tool) && !EDIT_TOOLS.has(tool))) return ran
904 const input = e as unknown as { file_path?: unknown; notebook_path?: unknown; type?: unknown }
905 const path = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : null
906 if (path === null) return ran
907 const isEdit = EDIT_TOOLS.has(tool)
908 await recordActivity($, path, isEdit ? 'edit' : 'read')
909 if (isEdit) {
910 editedThisTurn.add(path)
911 if (tool === 'Write' && (ran.result as { type?: unknown } | undefined)?.type === 'create') hasNewFile = true
912 }
913 } catch {
914 // Tracking is best effort: the tool's own result always goes back unchanged.
915 }
916 return ran
917 })
918
919 on('turn.start', async ($, e, next) => {
920 request = e.text
921 editedThisTurn.clear()
922 hasNewFile = false
923
924 return next(e)
925 })
926
927 on('turn.complete', async ($, e, next) => {
928 const ran = await next(e)
929 if (e.agentId !== undefined || e.isAborted) return ran
930
931 const edited = [...editedThisTurn]
932 const isNewFile = hasNewFile
933 if (edited.length > 0) {
934 $.clock.after(300, () => {
935 void (async () => {
936 if (isNewFile) await scan($)
937 await refreshChanges($)
938 })().catch(() => undefined)
939 })
940 }
941 if (isAutoDecisions && (edited.length > 0 || e.answer.length > LONG_ANSWER)) {
942 const turnRequest = request
943 const answer = e.answer
944 $.clock.after(0, () => void extractDecisions($, turnRequest, answer, edited).catch(() => undefined))
945 }
946 return ran
947 })
948
949 on('session.end', async ($, e, next) => {
950 if (e.reason === 'clear') {
951 await update($, activityAtom, () => [])
952 await update($, decisionsAtom, () => [])
953 await update($, changesAtom, (): ChangeMap => ({ status: 'idle', files: [], layers: [], dependents: [] }))
954 }
955 return next(e)
956 })
957
958 on('command.run', { command: 'atlas' }, async ($, e) => {
959 await $.ui.open({ id: PANE, title: TITLE })
960 await openTab($, e.args)
961
962 return {}
963 })
964
965 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
966 const table = $.ui.resolve(e)
967 const t: T = table
968 const { Box, Text, Button } = t
969 const tab = await read($, tabAtom)
970 const status = await read($, scanStatusAtom)
971 const graph = await read($, graphAtom)
972 const activity = await read($, activityAtom)
973 const root = graph?.root ?? ''
974
975 const ask =
976 'Input' in table ? (
977 <table.Input
978 key="symbol"
979 label="Function"
980 placeholder="name of a function or class"
981 submitLabel="trace"
982 onSubmit={value => void loadCalls($, value)}
983 />
984 ) : null
985
986 let body: RenderNode
987 if (tab === 'map') {
988 body =
989 status === 'scanning' ? <Text color="cyan">Scanning the repository…</Text>
990 : status === 'no-folder' ? <Text color="red">Codebase Atlas could not tell which folder to read. Start Claude Code inside your project.</Text>
991 : status === 'error' ? <Text color="red">Scan failed: {await read($, scanErrorAtom)}</Text>
992 : graph === null ? <Text dimColor>Starting the first scan…</Text>
993 : mapView($, t, graph, activity, await read($, focusAtom))
994 } else if (tab === 'changes') {
995 body = changesView($, t, await read($, changesAtom), graph?.isGit ?? true)
996 } else if (tab === 'components') {
997 const recent = [...new Set([...(await read($, changesAtom)).files.map(file => file.path), ...activity.map(one => relative(one.path, root))])]
998 body = componentsView($, t, await read($, outlineAtom), recent, e.props.bodyColumns)
999 } else if (tab === 'calls') {
1000 body = callsView($, t, await read($, callAtom), await read($, trailAtom), ask)
1001 } else if (tab === 'decisions') {
1002 body = decisionsView($, t, await read($, decisionsAtom), await read($, isExtractingAtom), isAutoDecisions)
1003 } else {
1004 body = explainView(t, await read($, insightAtom))
1005 }
1006
1007 return (
1008 <Box flexDirection="column">
1009 <Box flexDirection="row" flexWrap="wrap" gap={1}>
1010 {TABS.map(one => (
1011 <Button
1012 key={`tab:${one.tab}`}
1013 label={one.label}
1014 hotkey={one.key}
1015 variant={one.tab === tab ? 'primary' : 'secondary'}
1016 onPress={async () => {
1017 await update($, tabAtom, () => one.tab)
1018 if (one.tab === 'changes' && (await read($, changesAtom)).status === 'idle') await refreshChanges($)
1019 }}
1020 />
1021 ))}
1022 <Button key="rescan" label="rescan" hotkey="x" onPress={() => void scan($)} />
1023 </Box>
1024 <Box marginTop={1} flexDirection="column">
1025 {body}
1026 </Box>
1027 </Box>
1028 )
1029 })
1030}
1031hooks/calls.ts 94 lines1import type { Site } from '../types'
2import { isPython, symbolName } from './scan'
3import type { SymbolIndex } from './scan'
4
5const MAX_BODY_LINES = 400
6const MAX_CALLEES = 40
7const KEYWORDS = new Set([
8 'if', 'for', 'while', 'switch', 'return', 'catch', 'function', 'typeof', 'await', 'new', 'print', 'super',
9 'elif', 'with', 'assert', 'lambda', 'yield', 'not', 'and', 'or', 'in', 'is', 'def', 'class',
10])
11
12function indent(line: string): number {
13 return (/^\s*/.exec(line)?.[0] ?? '').replace(/\t/g, ' ').length
14}
15
16/** The lines of the definition starting at 1-based `line`: by indentation for Python, by braces otherwise. */
17export function bodyOf(text: string, line: number, path: string): string[] {
18 const lines = text.split('\n')
19 const start = line - 1
20 const head = lines[start]
21 if (head === undefined) return []
22
23 const body = [head]
24 if (isPython(path)) {
25 const base = indent(head)
26 for (let index = start + 1; index < lines.length && body.length < MAX_BODY_LINES; index += 1) {
27 const current = lines[index] ?? ''
28 if (current.trim() !== '' && indent(current) <= base) break
29 body.push(current)
30 }
31 while (body.length > 1 && (body[body.length - 1] ?? '').trim() === '') body.pop()
32 return body
33 }
34
35 let depth = (head.match(/\{/g) ?? []).length - (head.match(/\}/g) ?? []).length
36 let hasOpened = depth > 0
37 for (let index = start + 1; index < lines.length && body.length < MAX_BODY_LINES; index += 1) {
38 if (hasOpened && depth <= 0) break
39 const current = lines[index] ?? ''
40 body.push(current)
41 depth += (current.match(/\{/g) ?? []).length - (current.match(/\}/g) ?? []).length
42 if (depth > 0) hasOpened = true
43 }
44 return body
45}
46
47/** The function or class around 1-based `line`, found by walking up to the nearest definition that encloses it. */
48export function enclosingSymbol(text: string, line: number, path: string): string {
49 const lines = text.split('\n')
50 const target = lines[line - 1] ?? ''
51 for (let index = line - 2; index >= 0; index -= 1) {
52 const current = lines[index] ?? ''
53 const name = symbolName(current)
54 if (name === null) continue
55 if (isPython(path) ? indent(current) < indent(target) : bodyOf(text, index + 1, path).length >= line - index) {
56 return name
57 }
58 }
59 return '(module level)'
60}
61
62/** Functions a body calls, each marked known when the repo defines it. */
63export function calleesIn(body: readonly string[], self: string, index: SymbolIndex): Site[] {
64 const seen = new Set<string>()
65 const out: Site[] = []
66 for (const line of body.slice(1)) {
67 const code = line.replace(/(#|\/\/).*$/, '')
68 for (const match of code.matchAll(/([A-Za-z_$][\w$]*)\s*\(/g)) {
69 const name = match[1] ?? ''
70 if (name === self || KEYWORDS.has(name) || seen.has(name)) continue
71 seen.add(name)
72 const definition = index.get(name)?.[0]
73 out.push(definition ?? { symbol: name, file: '', line: 0, isKnown: false })
74 if (out.length >= MAX_CALLEES) return sortKnownFirst(out)
75 }
76 }
77 return sortKnownFirst(out)
78}
79
80function sortKnownFirst(sites: Site[]): Site[] {
81 return sites.sort((a, b) => Number(b.isKnown) - Number(a.isKnown) || a.symbol.localeCompare(b.symbol))
82}
83
84/** True when a grep hit for `name(` is the definition itself rather than a call. */
85export function isDefinitionLine(text: string, name: string): boolean {
86 return symbolName(text) === name
87}
88
89/** The identifier under or around a selection: the last word that looks like a name. */
90export function symbolFromSelection(selection: string): string | null {
91 const words = selection.match(/[A-Za-z_$][\w$]*/g)
92 return words === null ? null : (words[words.length - 1] ?? null)
93}
94hooks/changes.ts 223 lines1import type { OutlineEntry, SymbolChange, SymbolKind } from '../types'
2import { bodyOf } from './calls'
3import { isPython, symbolName } from './scan'
4
5export type Range = readonly [number, number]
6
7export type DiffFile = {
8 path: string
9 status: 'modified' | 'added' | 'deleted'
10 added: number
11 removed: number
12 /** Changed lines in the current file, 1-based and inclusive. */
13 ranges: Range[]
14}
15
16/** Reads `git diff -U0` output into one entry per file. */
17export function parseDiff(diff: string): DiffFile[] {
18 const files: DiffFile[] = []
19 let current: DiffFile | null = null
20 for (const line of diff.split('\n')) {
21 const header = /^diff --git a\/(.+?) b\/(.+)$/.exec(line)
22 if (header !== null) {
23 current = { path: header[2] ?? '', status: 'modified', added: 0, removed: 0, ranges: [] }
24 files.push(current)
25 continue
26 }
27 if (current === null) continue
28 if (line.startsWith('new file mode')) current.status = 'added'
29 else if (line.startsWith('deleted file mode')) current.status = 'deleted'
30 else if (line.startsWith('+++') || line.startsWith('---')) continue
31 else if (line.startsWith('@@')) {
32 const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line)
33 if (hunk !== null) {
34 const start = Number(hunk[1])
35 const count = hunk[2] === undefined ? 1 : Number(hunk[2])
36 current.ranges.push([Math.max(1, start), Math.max(1, start + Math.max(count, 1) - 1)])
37 }
38 } else if (line.startsWith('+')) current.added += 1
39 else if (line.startsWith('-')) current.removed += 1
40 }
41 return files
42}
43
44/** The LCS table above which a diff gives up line matching and marks the whole middle as replaced. */
45const MAX_DIFF_CELLS = 4_000_000
46
47/**
48 * Compares two versions of a file line by line, as `git diff -U0` would: changed ranges in the new
49 * version, added and removed counts, and unified-diff text for the explanation prompt.
50 */
51export function lineDiff(path: string, before: string | null, after: string | null): DiffFile & { text: string } {
52 const split = (text: string): string[] => (text === '' ? [] : text.replace(/\n$/, '').split('\n'))
53 const a = split(before ?? '')
54 const b = split(after ?? '')
55 const status = before === null ? 'added' : after === null ? 'deleted' : 'modified'
56
57 let head = 0
58 while (head < a.length && head < b.length && a[head] === b[head]) head += 1
59 let tail = 0
60 while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail += 1
61 const midA = a.slice(head, a.length - tail)
62 const midB = b.slice(head, b.length - tail)
63
64 type Op = { kind: ' ' | '-' | '+'; text: string }
65 const ops: Op[] = []
66 if ((midA.length + 1) * (midB.length + 1) > MAX_DIFF_CELLS) {
67 ops.push(...midA.map(text => ({ kind: '-' as const, text })), ...midB.map(text => ({ kind: '+' as const, text })))
68 } else {
69 const width = midB.length + 1
70 const table = new Uint32Array((midA.length + 1) * width)
71 for (let i = midA.length - 1; i >= 0; i -= 1) {
72 for (let j = midB.length - 1; j >= 0; j -= 1) {
73 table[i * width + j] =
74 midA[i] === midB[j]
75 ? (table[(i + 1) * width + j + 1] ?? 0) + 1
76 : Math.max(table[(i + 1) * width + j] ?? 0, table[i * width + j + 1] ?? 0)
77 }
78 }
79 let i = 0
80 let j = 0
81 while (i < midA.length || j < midB.length) {
82 if (i < midA.length && j < midB.length && midA[i] === midB[j]) {
83 ops.push({ kind: ' ', text: midA[i] ?? '' })
84 i += 1
85 j += 1
86 } else if (j >= midB.length || (i < midA.length && (table[(i + 1) * width + j] ?? 0) >= (table[i * width + j + 1] ?? 0))) {
87 ops.push({ kind: '-', text: midA[i] ?? '' })
88 i += 1
89 } else {
90 ops.push({ kind: '+', text: midB[j] ?? '' })
91 j += 1
92 }
93 }
94 }
95
96 const ranges: Range[] = []
97 const text: string[] = [`--- a/${path}`, `+++ b/${path}`]
98 let oldLine = head + 1
99 let newLine = head + 1
100 let added = 0
101 let removed = 0
102 let index = 0
103 while (index < ops.length) {
104 if (ops[index]?.kind === ' ') {
105 oldLine += 1
106 newLine += 1
107 index += 1
108 continue
109 }
110 const hunk: Op[] = []
111 while (index < ops.length && ops[index]?.kind !== ' ') {
112 hunk.push(ops[index] as Op)
113 index += 1
114 }
115 const minus = hunk.filter(op => op.kind === '-').length
116 const plus = hunk.filter(op => op.kind === '+').length
117 text.push(`@@ -${oldLine},${minus} +${newLine},${plus} @@`, ...hunk.map(op => `${op.kind}${op.text}`))
118 ranges.push(plus > 0 ? [newLine, newLine + plus - 1] : [Math.max(1, newLine), Math.max(1, newLine)])
119 oldLine += minus
120 newLine += plus
121 added += plus
122 removed += minus
123 }
124 return { path, status, added, removed, ranges, text: ranges.length === 0 ? '' : text.join('\n') }
125}
126
127function indentOf(line: string): number {
128 return (/^\s*/.exec(line)?.[0] ?? '').replace(/\t/g, ' ').length
129}
130
131const JS_METHOD = /^\s+(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*(?::[^{]*)?\{\s*$/
132const NOT_METHODS = new Set(['if', 'for', 'while', 'switch', 'catch', 'with', 'function', 'return'])
133
134/** The classes, functions and methods of a file, each with its line span and depth. */
135export function outlineOf(text: string, path: string): OutlineEntry[] {
136 const lines = text.split('\n')
137 const entries: OutlineEntry[] = []
138 const classes: { endLine: number; indent: number }[] = []
139
140 lines.forEach((line, index) => {
141 const number = index + 1
142 while (classes.length > 0 && (classes[classes.length - 1]?.endLine ?? 0) < number) classes.pop()
143
144 let name = symbolName(line)
145 let kind: SymbolKind | null = null
146 if (name !== null) {
147 kind = /^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s/.test(line) ? 'class' : 'function'
148 } else if (!isPython(path) && classes.length > 0) {
149 const method = JS_METHOD.exec(line)
150 if (method?.[1] !== undefined && !NOT_METHODS.has(method[1])) {
151 name = method[1]
152 kind = 'method'
153 }
154 }
155 if (name === null || kind === null) return
156
157 const insideClass = classes.length > 0 && (!isPython(path) || indentOf(line) > (classes[classes.length - 1]?.indent ?? 0))
158 if (kind === 'function' && insideClass) kind = 'method'
159 const endLine = number + bodyOf(text, number, path).length - 1
160 entries.push({ name, kind, line: number, endLine, depth: insideClass ? classes.length : 0, isChanged: false, calls: [] })
161 if (kind === 'class') classes.push({ endLine, indent: indentOf(line) })
162 })
163 return entries
164}
165
166function overlaps(entry: OutlineEntry, ranges: readonly Range[]): boolean {
167 return ranges.some(([start, end]) => start <= entry.endLine && end >= entry.line)
168}
169
170/** Marks entries whose span a change touches; a class counts only for changes outside its methods. */
171export function markChanged(entries: readonly OutlineEntry[], ranges: readonly Range[]): OutlineEntry[] {
172 return entries.map(entry => {
173 if (!overlaps(entry, ranges)) return entry
174 if (entry.kind !== 'class') return { ...entry, isChanged: true }
175 const members = entries.filter(other => other !== entry && other.line > entry.line && other.endLine <= entry.endLine)
176 const outsideMembers = ranges.some(
177 ([start, end]) =>
178 start <= entry.endLine &&
179 end >= entry.line &&
180 !members.some(member => start >= member.line && end <= member.endLine),
181 )
182 return { ...entry, isChanged: outsideMembers }
183 })
184}
185
186/** What changed in one file at the level of its functions and classes. */
187export function symbolChanges(current: readonly OutlineEntry[], before: readonly OutlineEntry[], ranges: readonly Range[]): SymbolChange[] {
188 const beforeNames = new Set(before.map(entry => `${entry.kind}:${entry.name}`))
189 const currentNames = new Set(current.map(entry => `${entry.kind}:${entry.name}`))
190 const changes: SymbolChange[] = []
191 for (const entry of markChanged(current, ranges)) {
192 const key = `${entry.kind}:${entry.name}`
193 if (!beforeNames.has(key)) changes.push({ name: entry.name, kind: entry.kind, change: 'added', line: entry.line, callers: 0 })
194 else if (entry.isChanged) changes.push({ name: entry.name, kind: entry.kind, change: 'modified', line: entry.line, callers: 0 })
195 }
196 for (const entry of before) {
197 if (!currentNames.has(`${entry.kind}:${entry.name}`)) {
198 changes.push({ name: entry.name, kind: entry.kind, change: 'removed', line: entry.line, callers: 0 })
199 }
200 }
201 return changes
202}
203
204/**
205 * Counts call sites per name from `git grep -o` output, leaving out each name's own definition lines
206 * and longer identifiers that only end in a name (`refetch(` is not a call of `fetch`).
207 */
208export function countCallers(grepOutput: string, definitions: ReadonlySet<string>, names: ReadonlySet<string>): Map<string, number> {
209 const counts = new Map<string, number>()
210 for (const line of grepOutput.split('\n')) {
211 const match = /^(.+?):(\d+):([A-Za-z_$][\w$]*)/.exec(line)
212 if (match === null) continue
213 const [, file = '', number = '', name = ''] = match
214 if (!names.has(name) || definitions.has(`${file}:${number}:${name}`)) continue
215 counts.set(name, (counts.get(name) ?? 0) + 1)
216 }
217 return counts
218}
219
220export function escapeRegex(name: string): string {
221 return name.replace(/[$]/g, '\\$')
222}
223hooks/prompts.ts 88 lines1import type { Decision } from '../types'
2
3export const DECISION_MODEL = 'claude-haiku-4-5-20251001'
4const MAX_DECISIONS = 3
5const MAX_ANSWER = 6000
6export const MAX_CONTEXT = 12000
7
8export const DECISION_SYSTEM =
9 'You extract engineering decisions from one turn of a coding session. A decision is a choice between real options that shapes the code or design, such as a library, a data model, an algorithm, a layout or a trade-off. Ignore routine steps such as reading files or running tests. Reply with JSON only.'
10
11export function decisionPrompt(request: string, answer: string, editedFiles: readonly string[]): string {
12 return [
13 `User request: ${request}`,
14 `Files edited this turn: ${editedFiles.length === 0 ? 'none' : editedFiles.join(', ')}`,
15 `Assistant's final answer:\n${answer.slice(0, MAX_ANSWER)}`,
16 '',
17 `List at most ${MAX_DECISIONS} decisions made in this turn, in this exact shape, or an empty list when there were none:`,
18 '{"decisions":[{"decision":"what was chosen, one line","because":"why, one line","alternatives":["option not taken"]}]}',
19 ].join('\n')
20}
21
22function text(value: unknown, limit: number): string {
23 return typeof value === 'string' ? value.trim().slice(0, limit) : ''
24}
25
26export function parseDecisions(reply: string, prompt: string, idPrefix: string): Decision[] {
27 const start = reply.indexOf('{')
28 const end = reply.lastIndexOf('}')
29 if (start < 0 || end <= start) return []
30 let parsed: unknown
31 try {
32 parsed = JSON.parse(reply.slice(start, end + 1))
33 } catch {
34 return []
35 }
36 const raw = (parsed as { decisions?: unknown }).decisions
37 if (!Array.isArray(raw)) return []
38 return raw
39 .map((item: Record<string, unknown>, index) => ({
40 id: `${idPrefix}-${index}`,
41 prompt,
42 decision: text(item.decision, 200),
43 because: text(item.because, 240),
44 alternatives: Array.isArray(item.alternatives)
45 ? item.alternatives.map(option => text(option, 80)).filter(Boolean).slice(0, 4)
46 : [],
47 }))
48 .filter(decision => decision.decision !== '')
49 .slice(0, MAX_DECISIONS)
50}
51
52const SIDE_QUESTION =
53 'Side question from the user. Answer it on its own in Markdown: do not use tools and do not continue or change the current task.'
54
55export function explainModulePrompt(id: string, files: readonly string[], symbols: readonly string[], dependsOn: readonly string[], usedBy: readonly string[]): string {
56 return [
57 SIDE_QUESTION,
58 `Explain the "${id}" folder of this codebase to someone new to it: what it is responsible for, how its main pieces fit together, and how it relates to the folders around it.`,
59 `Files: ${files.slice(0, 40).join(', ')}`,
60 `Functions and classes defined there: ${symbols.slice(0, 80).join(', ') || 'unknown'}`,
61 `It imports from: ${dependsOn.join(', ') || 'nothing in the repo'}`,
62 `It is imported by: ${usedBy.join(', ') || 'nothing in the repo'}`,
63 'Use a one-sentence summary, then short sections with bullets. Say when something is inferred from names rather than seen.',
64 ].join('\n')
65}
66
67export function explainFunctionPrompt(symbol: string, file: string, body: string, callers: readonly string[], callees: readonly string[]): string {
68 return [
69 SIDE_QUESTION,
70 `Explain the function "${symbol}" in ${file}: what it does, step by step, why it is shaped this way, and its edge cases.`,
71 `Called by: ${callers.join(', ') || 'no callers found'}`,
72 `It calls: ${callees.join(', ') || 'nothing in the repo'}`,
73 'Its code:',
74 '```',
75 body.slice(0, MAX_CONTEXT),
76 '```',
77 ].join('\n')
78}
79
80export function explainChangesPrompt(summary: string, diff: string): string {
81 return [
82 SIDE_QUESTION,
83 'Explain the changes made in this session: the approach, how the pieces fit together, which layers of the architecture they touch, and anything risky or worth testing.',
84 `Changed files:\n${summary}`,
85 diff === '' ? '' : `The uncommitted diff (may be cut):\n\`\`\`diff\n${diff.slice(0, MAX_CONTEXT)}\n\`\`\``,
86 ].join('\n')
87}
88hooks/scan.ts 298 lines1import type { Edge, Graph, Group, Site } from '../types'
2
3export const SOURCE_GLOBS = ['*.py', '*.ts', '*.tsx', '*.js', '*.jsx', '*.mjs', '*.cjs']
4
5/** Lines `git grep -E` matches as possible imports, in POSIX classes so every git build reads them. */
6export const IMPORT_PATTERN =
7 '^[[:space:]]*(import|from)[[:space:]]|require\\(|^[[:space:]]*export[[:space:]].*[[:space:]]from[[:space:]]'
8
9/** Lines `git grep -E` matches as possible definitions. */
10export const SYMBOL_PATTERN =
11 '^[[:space:]]*(async[[:space:]]+)?def[[:space:]]+[A-Za-z_]|^[[:space:]]*class[[:space:]]+[A-Za-z_]|function[[:space:]]*\\*?[[:space:]]+[A-Za-z_$]|^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?(\\(|function|[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=>)'
12
13/** The same two patterns as JavaScript expressions, for folders searched without git. */
14export const IMPORT_RE = /^\s*(import|from)\s|require\(|^\s*export\s.*\sfrom\s/
15export const SYMBOL_RE =
16 /^\s*(async\s+)?def\s+[A-Za-z_]|^\s*class\s+[A-Za-z_]|function\s*\*?\s+[A-Za-z_$]|^\s*(export\s+)?(const|let)\s+[A-Za-z_$][\w$]*\s*=\s*(async\s*)?(\(|function|[A-Za-z_$][\w$]*\s*=>)/
17
18export const SOURCE_EXTENSIONS = ['.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']
19
20/** Folders a walk without git never enters: dependencies, environments, caches and build output. */
21export const SKIPPED_DIRS = new Set([
22 'node_modules', 'venv', '.venv', 'env', '.env', '__pycache__', 'dist', 'build', 'out', 'target', 'coverage',
23 'site-packages', '.git', '.hg', '.svn', '.next', '.nuxt', '.tox', '.mypy_cache', '.pytest_cache', '.ruff_cache',
24 '.idea', '.vscode', '.cache', '.turbo', 'vendor',
25])
26
27export function isSource(name: string): boolean {
28 return SOURCE_EXTENSIONS.some(ext => name.endsWith(ext)) && !name.endsWith('.d.ts') && !name.endsWith('.min.js')
29}
30
31/** What `git grep -n -E` prints, produced from texts held in memory. */
32export function grepTexts(texts: ReadonlyMap<string, string>, pattern: RegExp): string {
33 const out: string[] = []
34 for (const [path, text] of texts) {
35 text.split('\n').forEach((line, index) => {
36 if (pattern.test(line)) out.push(`${path}:${index + 1}:${line}`)
37 })
38 }
39 return out.join('\n')
40}
41
42/** What `git grep -n -o -E` prints: each match on its own line. */
43export function grepMatches(texts: ReadonlyMap<string, string>, pattern: RegExp): string {
44 const global = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`)
45 const out: string[] = []
46 for (const [path, text] of texts) {
47 text.split('\n').forEach((line, index) => {
48 for (const match of line.matchAll(global)) out.push(`${path}:${index + 1}:${match[0]}`)
49 })
50 }
51 return out.join('\n')
52}
53
54/** What `git grep -c ''` prints: the line count of each file. */
55export function countTexts(texts: ReadonlyMap<string, string>): string {
56 return [...texts].map(([path, text]) => `${path}:${text.split('\n').length - (text.endsWith('\n') ? 1 : 0)}`).join('\n')
57}
58
59export const MAX_FILES = 4000
60const MAX_GROUPS = 80
61const MAX_EDGES = 1000
62const GROUP_DEPTH = 3
63const JS_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']
64
65export type FileImports = Map<string, Set<string>>
66export type SymbolIndex = Map<string, Site[]>
67
68/** Everything a scan learned: the map for the pane, and the indexes later lookups use. */
69export type Scan = { graph: Graph; imports: FileImports; symbols: SymbolIndex }
70
71export function isPython(path: string): boolean {
72 return path.endsWith('.py')
73}
74
75/** The folder a file is drawn under: its directory, cut to GROUP_DEPTH levels. */
76export function groupOf(path: string): string {
77 const parts = path.split('/').slice(0, -1)
78 return parts.length === 0 ? '(root)' : parts.slice(0, GROUP_DEPTH).join('/')
79}
80
81/** Splits one `git grep -n` line into file, line number and text. */
82export function grepLine(line: string): { file: string; line: number; text: string } | null {
83 const match = /^(.+?):(\d+):(.*)$/.exec(line)
84 if (match === null) return null
85 return { file: match[1] ?? '', line: Number(match[2]), text: match[3] ?? '' }
86}
87
88function normalize(parts: readonly string[]): string {
89 const out: string[] = []
90 for (const part of parts) {
91 if (part === '' || part === '.') continue
92 if (part === '..') out.pop()
93 else out.push(part)
94 }
95 return out.join('/')
96}
97
98function dirOf(path: string): string[] {
99 return path.split('/').slice(0, -1)
100}
101
102/** Finds the repo file a dotted Python module names, also under a prefix such as src/. */
103function pythonFile(dotted: string, base: readonly string[], files: ReadonlySet<string>, bySuffix: Map<string, string>): string | null {
104 const stem = normalize([...base, ...dotted.split('.')])
105 for (const candidate of [`${stem}.py`, `${stem}/__init__.py`]) {
106 if (files.has(candidate)) return candidate
107 if (base.length === 0) {
108 const found = bySuffix.get(candidate)
109 if (found !== undefined) return found
110 }
111 }
112 return null
113}
114
115/** The repo files one Python import line brings in. */
116export function resolvePython(from: string, text: string, files: ReadonlySet<string>, bySuffix: Map<string, string>): string[] {
117 const out: string[] = []
118 const fromImport = /^\s*from\s+(\.*)([\w.]*)\s+import\s+(.+)$/.exec(text)
119 if (fromImport !== null) {
120 const dots = (fromImport[1] ?? '').length
121 const module = fromImport[2] ?? ''
122 const base = dots === 0 ? [] : dirOf(from).slice(0, Math.max(0, dirOf(from).length - (dots - 1)))
123 const whole = module === '' ? null : pythonFile(module, base, files, bySuffix)
124 if (whole !== null) out.push(whole)
125 const names = (fromImport[3] ?? '').replace(/[()]/g, '').split(',')
126 for (const name of names) {
127 const bare = name.trim().split(/\s+/)[0] ?? ''
128 if (!/^\w+$/.test(bare)) continue
129 const sub = pythonFile(module === '' ? bare : `${module}.${bare}`, base, files, bySuffix)
130 if (sub !== null) out.push(sub)
131 }
132 return out
133 }
134 const plain = /^\s*import\s+([\w.]+(?:\s*,\s*[\w.]+)*)/.exec(text)
135 if (plain !== null) {
136 for (const module of (plain[1] ?? '').split(',')) {
137 const found = pythonFile(module.trim(), [], files, bySuffix)
138 if (found !== null) out.push(found)
139 }
140 }
141 return out
142}
143
144/** The repo files one JS or TS import line brings in; package imports are left out. */
145export function resolveJs(from: string, text: string, files: ReadonlySet<string>): string[] {
146 const out: string[] = []
147 const pattern = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\(\s*)['"]([^'"]+)['"]/g
148 for (const match of text.matchAll(pattern)) {
149 const spec = match[1] ?? ''
150 if (!spec.startsWith('.')) continue
151 const stem = normalize([...dirOf(from), ...spec.split('/')])
152 const candidates = [
153 stem,
154 ...JS_EXTENSIONS.map(ext => `${stem}${ext}`),
155 ...JS_EXTENSIONS.map(ext => `${stem}/index${ext}`),
156 stem.replace(/\.js$/, '.ts'),
157 stem.replace(/\.js$/, '.tsx'),
158 ]
159 const found = candidates.find(candidate => files.has(candidate))
160 if (found !== undefined) out.push(found)
161 }
162 return out
163}
164
165/** The name a definition line declares, or null. */
166export function symbolName(text: string): string | null {
167 const patterns = [
168 /^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)/,
169 /^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/,
170 /function\s*\*?\s+([A-Za-z_$][\w$]*)/,
171 /^\s*(?:export\s+)?(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=/,
172 ]
173 for (const pattern of patterns) {
174 const match = pattern.exec(text)
175 if (match?.[1] !== undefined) return match[1]
176 }
177 return null
178}
179
180/** Height of each folder in the dependency order, cycles broken where they close. */
181export function levels(ids: readonly string[], edges: readonly Edge[]): Map<string, number> {
182 const out = new Map<string, string[]>()
183 for (const edge of edges) out.set(edge.from, [...(out.get(edge.from) ?? []), edge.to])
184 const memo = new Map<string, number>()
185 const visiting = new Set<string>()
186 const level = (id: string): number => {
187 const known = memo.get(id)
188 if (known !== undefined) return known
189 if (visiting.has(id)) return 0
190 visiting.add(id)
191 const deps = out.get(id) ?? []
192 const value = deps.length === 0 ? 0 : 1 + Math.max(...deps.map(level))
193 visiting.delete(id)
194 memo.set(id, value)
195 return value
196 }
197 return new Map(ids.map(id => [id, level(id)]))
198}
199
200/** Builds the map and indexes from the four git outputs a scan collects, or their in-memory equivalents. */
201export function buildScan(root: string, fileList: string, lineCounts: string, importLines: string, symbolLines: string, isGit = true): Scan {
202 const fileNames = fileList.split('\n').filter(Boolean)
203 const isTruncated = fileNames.length > MAX_FILES
204 const files = new Set(fileNames.slice(0, MAX_FILES))
205 const bySuffix = new Map<string, string>()
206 for (const file of files) {
207 const parts = file.split('/')
208 for (let start = 1; start < parts.length; start += 1) {
209 const suffix = parts.slice(start).join('/')
210 if (!bySuffix.has(suffix)) bySuffix.set(suffix, file)
211 }
212 }
213
214 const lines = new Map<string, number>()
215 for (const row of lineCounts.split('\n')) {
216 const match = /^(.+):(\d+)$/.exec(row)
217 if (match !== null && files.has(match[1] ?? '')) lines.set(match[1] ?? '', Number(match[2]))
218 }
219
220 const imports: FileImports = new Map()
221 for (const row of importLines.split('\n')) {
222 const hit = grepLine(row)
223 if (hit === null || !files.has(hit.file)) continue
224 const targets = isPython(hit.file) ? resolvePython(hit.file, hit.text, files, bySuffix) : resolveJs(hit.file, hit.text, files)
225 for (const target of targets) {
226 if (target === hit.file) continue
227 const set = imports.get(hit.file) ?? new Set<string>()
228 set.add(target)
229 imports.set(hit.file, set)
230 }
231 }
232
233 const symbols: SymbolIndex = new Map()
234 const symbolCount = new Map<string, number>()
235 for (const row of symbolLines.split('\n')) {
236 const hit = grepLine(row)
237 if (hit === null || !files.has(hit.file)) continue
238 const name = symbolName(hit.text)
239 if (name === null) continue
240 symbols.set(name, [...(symbols.get(name) ?? []), { symbol: name, file: hit.file, line: hit.line, isKnown: true }])
241 symbolCount.set(groupOf(hit.file), (symbolCount.get(groupOf(hit.file)) ?? 0) + 1)
242 }
243
244 const groupFiles = new Map<string, string[]>()
245 for (const file of files) groupFiles.set(groupOf(file), [...(groupFiles.get(groupOf(file)) ?? []), file])
246
247 const edgeCount = new Map<string, number>()
248 for (const [from, targets] of imports) {
249 for (const to of targets) {
250 const key = `${groupOf(from)}\u0000${groupOf(to)}`
251 if (groupOf(from) !== groupOf(to)) edgeCount.set(key, (edgeCount.get(key) ?? 0) + 1)
252 }
253 }
254 const edges: Edge[] = [...edgeCount]
255 .map(([key, count]) => {
256 const [from = '', to = ''] = key.split('\u0000')
257 return { from, to, count }
258 })
259 .sort((a, b) => b.count - a.count)
260 .slice(0, MAX_EDGES)
261
262 const byLines = [...groupFiles.keys()].sort(
263 (a, b) => (groupFiles.get(b)?.length ?? 0) - (groupFiles.get(a)?.length ?? 0),
264 )
265 const kept = new Set(byLines.slice(0, MAX_GROUPS))
266 const keptEdges = edges.filter(edge => kept.has(edge.from) && kept.has(edge.to))
267 const heights = levels([...kept], keptEdges)
268 const groups: Group[] = [...kept]
269 .map(id => ({
270 id,
271 files: groupFiles.get(id)?.length ?? 0,
272 lines: (groupFiles.get(id) ?? []).reduce((sum, file) => sum + (lines.get(file) ?? 0), 0),
273 symbols: symbolCount.get(id) ?? 0,
274 level: heights.get(id) ?? 0,
275 }))
276 .sort((a, b) => b.level - a.level || a.id.localeCompare(b.id))
277
278 return {
279 graph: { root, isGit, files: files.size, groups, edges: keptEdges, isTruncated: isTruncated || kept.size < groupFiles.size },
280 imports,
281 symbols,
282 }
283}
284
285/** Files outside `changed` that import any file in it. */
286export function dependentsOf(changed: ReadonlySet<string>, imports: FileImports): string[] {
287 const out: string[] = []
288 for (const [from, targets] of imports) {
289 if (changed.has(from)) continue
290 if ([...targets].some(target => changed.has(target))) out.push(from)
291 }
292 return out.sort()
293}
294
295export function shortCount(value: number): string {
296 return value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(value)
297}
298types/index.d.ts 120 lines1/** A folder of source files: one box on the map. */
2export type Group = {
3 id: string
4 files: number
5 lines: number
6 symbols: number
7 /** 0 for folders that import nothing in the repo; higher for folders built on top of others. */
8 level: number
9}
10
11/** `from` imports `to`, `count` times across their files. */
12export type Edge = { from: string; to: string; count: number }
13
14export type Graph = {
15 root: string
16 /** False when the folder has no git: changes are then measured against the first scan's snapshot. */
17 isGit: boolean
18 files: number
19 groups: Group[]
20 edges: Edge[]
21 isTruncated: boolean
22}
23
24export type ScanStatus = 'idle' | 'scanning' | 'ready' | 'no-folder' | 'error'
25
26/** What Claude did to one file this session; `path` is absolute as the tools name it. */
27export type Activity = { path: string; reads: number; edits: number }
28
29/** A place in the code: a definition, a caller or a callee. */
30export type Site = { symbol: string; file: string; line: number; isKnown: boolean }
31
32export type CallView = {
33 symbol: string
34 status: 'loading' | 'ready' | 'missing' | 'error'
35 definition?: Site
36 callers: Site[]
37 callees: Site[]
38 error?: string
39}
40
41export type SymbolKind = 'class' | 'function' | 'method'
42
43/** One function, method or class of a file, with what it calls. */
44export type OutlineEntry = {
45 name: string
46 kind: SymbolKind
47 line: number
48 endLine: number
49 depth: number
50 isChanged: boolean
51 calls: string[]
52}
53
54export type Outline = {
55 file: string
56 status: 'loading' | 'ready' | 'error'
57 lines: number
58 entries: OutlineEntry[]
59 error?: string
60}
61
62export type SymbolChange = {
63 name: string
64 kind: SymbolKind
65 change: 'added' | 'modified' | 'removed'
66 line: number
67 callers: number
68}
69
70export type FileChange = {
71 path: string
72 group: string
73 status: 'modified' | 'added' | 'deleted'
74 added: number
75 removed: number
76 symbols: SymbolChange[]
77}
78
79export type ChangeMap = {
80 status: 'idle' | 'loading' | 'ready' | 'error'
81 files: FileChange[]
82 /** Folders touched, highest layer first. */
83 layers: string[]
84 /** Unchanged files that import a changed file. */
85 dependents: string[]
86 error?: string
87}
88
89export type Decision = {
90 id: string
91 prompt: string
92 decision: string
93 because: string
94 alternatives: string[]
95}
96
97export type Insight = { title: string; status: 'loading' | 'ready' | 'error'; text: string }
98
99export type Tab = 'map' | 'changes' | 'components' | 'calls' | 'decisions' | 'explain'
100
101declare module 'claude-code' {
102 interface PluginState {
103 'codebase-atlas': {
104 graph: Graph | null
105 scanStatus: ScanStatus
106 scanError: string
107 activity: Activity[]
108 tab: Tab
109 focus: string | null
110 changes: ChangeMap
111 outline: Outline | null
112 call: CallView | null
113 trail: string[]
114 decisions: Decision[]
115 isExtracting: boolean
116 insight: Insight | null
117 }
118 }
119}
120