An Obsidian-style graph of your repo in a Claude Code pane: file imports and function calls, with Files and Changes tabs beside it

<img src="assets/logo.svg" width="160" alt="repograph logo — a dependency graph: a hub file and its linked files">
<h1 align="center">repograph</h1>
<em>A live map of your repo inside Claude Code.</em>
<img src="https://img.shields.io/badge/Claude%20Code-mod-a882ff?style=flat-square" alt="Claude Code mod"> <img src="https://img.shields.io/badge/dependencies-0-53dfdd?style=flat-square" alt="Zero dependencies"> <img src="https://img.shields.io/github/license/XyndoX/repograph?style=flat-square&color=f5a623" alt="MIT license">
A Claude Code mod that puts your repo in a side pane with three tabs: Files, an Obsidian-style Graph of its dependencies, and a lazygit-style Changes view to review what your agent is editing while it works.
claude plugin marketplace add XyndoX/repograph
claude plugin install repograph@repograph --scope user
Then /tui fullscreen (the pane docks to the right) and /repograph (or /repograph functions, /repograph changes). Click the pane once to give it the keyboard.
p switches to a braille text drawing.m cycles them: folders (a map of the repo: each folder one node, links summed), files (imports), functions (calls). A big repo opens on folders.g on a folder in the Files tab, or run /repograph apps/web. The graph then shows what's inside that folder, and everything it links to outside as hollow nodes named after the folder they leave by. The breadcrumb (⌂ repo / apps / web) and u go back up.[ ] (or ‹ ›) set how deep the folder map splits: level 1 is the top folders, level 2 their subfolders (src/components, src/stores, …), up to 6.[ ] set the depth, 1 to 5 rings. What the focus imports or calls is purple (→), what uses it is teal (←), and the bottom lines list both. g shows the whole graph again with the focus highlighted.+ - to zoom.← used by, → imports). Enter on a file shows it in the graph; g on a folder graphs that folder./ search box: type a file or function name, Enter focuses and centres it.+/- counts, and the selected file's diff below. It rereads git every 2.5 s and after each tool call, so you can review while the agent works. space marks a file reviewed (✓); if the agent edits it again it reopens (●). The tab title counts what's still open: Changes 3/5.| Key | Does |
|---|---|
1 / 2 / 3 | Files / Graph / Changes |
| click, drag, scroll | focus a node (again: open a folder), pan, zoom |
arrows, + -, 0 | pan, zoom, reset the view |
m | folders → files → functions |
[ ] | folders: how many folder levels; files/functions: rings around the focus |
u | scope one folder up |
g | Graph: near the focus ⇄ everything. Files: graph this folder |
o | show nodes with no links |
p | pixels ⇄ braille text |
enter | Graph: open a focused folder, or show the file in Files. Files: open folder, or show the file in the graph |
esc | clear the focus, then scope up |
r | rescan |
Changes tab:
| Key | Does |
|---|---|
↑ ↓ / j k, click | pick a file |
J K, pgup pgdn, wheel | scroll the diff |
space (or click its ●) | mark reviewed and go to the next |
n / a | next unreviewed / mark all reviewed |
enter | show the file in the graph |
The Changes tab only reads: it never stages, commits or discards, and it runs git with --no-optional-locks so it can't block the agent's own git calls.
git ls-files, then regexes, no parser and no dependencies:
.js meaning .ts, @/ and ~/ aliases, workspace packages by their package.json name) and Python (absolute and relative). Other languages get function nodes only.( inside another function. A name resolves to the same file, then to files it imports, then to a name defined once in the repo; anything else is skipped as ambiguous.Image element; pointer moves, drags and wheel turns redraw at most every 40 ms; labels are absolutely positioned text over it, and an empty client box on top catches the pointer. The whole-repo view keeps the 350 best-connected nodes.It misses what regexes miss: dynamic dispatch, calls through objects passed around, re-exports by name. Treat it as a map, not a proof.
claude plugin test .
MIT
hooks/register.tsx 596 lines1import type { EngineInterface, Register } from 'claude-code'
2import { build, CODE, type Graph } from './scan'
3import type { Seg } from './layout'
4import { treeRows, type Row } from './tree'
5import { type Change, diffRows, readChanges, readDiff, stamp, STATUS_COLOR } from './changes'
6import { draw, IN, OUT, PALETTE, type Drawn } from './graph'
7import { cellSize } from './paint'
8import type { Mode } from './view'
9import type { CanvasProps } from './canvas'
10import type { OverlayProps } from './overlay'
11
12const PANE = 'repograph'
13const MAX_FILES = 4000
14const MAX_BYTES = 400_000
15const GLOBAL_CAP = 350 // the whole-repo graph keeps the best-connected nodes
16const POLL_MS = 2500 // the Changes tab rereads git this often, to show edits while the agent works
17const MODES: Mode[] = ['folders', 'files', 'functions']
18const C = { fg: '#dadada', muted: '#6b6b6b', hot: '#a882ff', touched: '#f5a623', sel: '#2e2a3d', ok: '#98c379' }
19
20type Tab = 'files' | 'graph' | 'changes'
21
22const S = {
23 root: '',
24 files: [] as string[],
25 graph: null as Graph | null,
26 scanning: false,
27 stale: false,
28 error: '',
29 tab: 'graph' as Tab,
30 // Graph tab
31 mode: 'files' as Mode,
32 scope: '', // a folder: the graph shows what's inside, and what it links to outside
33 focus: '',
34 hover: '',
35 local: true, // a focus narrows the graph to its neighbourhood
36 orphans: false,
37 depth: 1, // rings around a focus
38 levels: 2, // folders mode: how deep the folder map splits
39 pixels: true,
40 cam: { cx: 0.5, cy: 0.5, z: 1 },
41 center: '',
42 pendingClear: false,
43 drawn: null as Drawn | null,
44 box: { cols: 60, rows: 20 },
45 // Files tab
46 expanded: new Set<string>(),
47 cursor: '',
48 top: 0,
49 tree: [] as Row[],
50 touched: new Set<string>(),
51 // Changes tab
52 changes: [] as Change[],
53 sel: '',
54 listTop: 0,
55 listRows: 5,
56 diff: [] as Seg[][],
57 diffText: '',
58 diffTop: 0,
59 reviewed: new Map<string, string>(), // path → stamp when reviewed; a new edit clears it
60 reading: false,
61 poll: null as { cancel: () => void } | null,
62}
63const redraw = ($: EngineInterface) => $.ui.invalidate('ui.render')
64
65// Pointer moves, drags and wheel turns come fast; draw at most every 40 ms.
66let soonPending = false
67function soon($: EngineInterface) {
68 if (soonPending) return
69 soonPending = true
70 $.clock.after(40, () => { soonPending = false; redraw($) })
71}
72
73// The picture goes to the terminal as a file it reads itself, so the drawing
74// tree carries a path, not megabytes of pixels. Two files take turns, so the
75// terminal never reads one half-written.
76const RUN_ID = Math.random().toString(36).slice(2, 8)
77const pic = { key: '', ready: null as null | { file: string; width: number; height: number; generation: number }, gen: 0, inline: false }
78async function ensurePicture($: EngineInterface, img: { rgba: string; width: number; height: number; key: string }) {
79 if (pic.key === img.key || pic.inline) return
80 pic.key = img.key
81 const gen = pic.gen + 1
82 try {
83 const tmp = ((await $.env.get('TMPDIR')) || '/tmp').replace(/\/$/, '')
84 const file = `${tmp}/repograph-${RUN_ID}-${gen % 2}.rgba`
85 const r = await $.process.run(['/bin/sh', '-c', 'base64 -d > "$1"', 'sh', file], { stdin: img.rgba, timeoutMs: 10_000 })
86 if (r.exitCode !== 0) throw new Error(r.stderr)
87 if (pic.key !== img.key) return // a newer picture is on its way
88 pic.gen = gen
89 pic.ready = { file, width: img.width, height: img.height, generation: gen }
90 } catch {
91 pic.inline = true // no shell to write with: send the pixels inline instead
92 }
93 redraw($)
94}
95const resetCam = () => { S.cam = { cx: 0.5, cy: 0.5, z: 1 } }
96
97async function topOf($: EngineInterface): Promise<string> {
98 const cwd = await $.session.cwd()
99 const r = await $.process.run(['git', '--no-optional-locks', '-C', cwd, 'rev-parse', '--show-toplevel'], { timeoutMs: 5_000 })
100 if (r.exitCode !== 0) throw new Error('not a git repo: repograph reads git')
101 return r.stdout.trim()
102}
103
104async function scan($: EngineInterface) {
105 if (S.scanning) return
106 S.scanning = true
107 S.error = ''
108 redraw($)
109 try {
110 const root = await topOf($)
111 const ls = await $.process.run(['git', '--no-optional-locks', '-C', root, 'ls-files', '-z'], { timeoutMs: 15_000 })
112 const all = ls.stdout.split('\0').filter(Boolean)
113 const wanted = all.filter(f => CODE.test(f) || f.endsWith('package.json')).slice(0, MAX_FILES)
114 const texts: Record<string, string> = {}
115 await Promise.all(
116 wanted.map(async f => {
117 try {
118 const t = String(await $.fs.read(`${root}/${f}`))
119 if (t.length <= MAX_BYTES) texts[f] = t
120 } catch { /* deleted or unreadable: skip */ }
121 }),
122 )
123 const fresh = root !== S.root
124 S.graph = build(texts)
125 if (fresh) {
126 Object.assign(S, { focus: '', scope: '', cursor: '', top: 0, expanded: new Set<string>(), sel: '', reviewed: new Map() })
127 resetCam()
128 // A big repo opens on its folders: a map first, the streets when you zoom in.
129 S.mode = S.graph.files.length > 120 ? 'folders' : 'files'
130 }
131 S.root = root
132 S.files = all
133 S.stale = false
134 } catch (err) {
135 S.error = err instanceof Error ? err.message : String(err)
136 } finally {
137 S.scanning = false
138 redraw($)
139 }
140 void refreshChanges($)
141}
142
143async function refreshChanges($: EngineInterface) {
144 if (S.reading || !S.root) return
145 S.reading = true
146 try {
147 const run = (argv: string[], o: { timeoutMs: number }) => $.process.run(argv, o)
148 const list = await readChanges(run, S.root)
149 if (!list) return
150 for (const [path, st] of S.reviewed) {
151 const c = list.find(x => x.path === path)
152 if (!c || stamp(c) !== st) S.reviewed.delete(path)
153 }
154 S.changes = list
155 if (!list.some(c => c.path === S.sel)) { S.sel = list[0]?.path ?? ''; S.diffTop = 0 }
156 const cur = list.find(c => c.path === S.sel)
157 const text = cur ? await readDiff(run, S.root, cur) : ''
158 if (text !== S.diffText) { S.diffText = text; S.diff = diffRows(text, S.box.cols) }
159 } catch { /* git busy or gone: try again on the next tick */ } finally {
160 S.reading = false
161 redraw($)
162 }
163}
164
165const groupsOf = (g: Graph) => [...new Set(g.files.map(n => n.group))].sort()
166const groupColor = (groups: string[]) => (group: string) => PALETTE[Math.max(0, groups.indexOf(group)) % PALETTE.length]!
167
168function setFocus(id: string, center = false) {
169 S.focus = id
170 if (center) S.center = id
171}
172
173// Scope the graph to a folder; '' is the whole repo.
174function setScope(folder: string) {
175 S.scope = folder.replace(/\/$/, '')
176 S.focus = ''
177 resetCam()
178 // A folder with no subfolders has nothing to show as folders.
179 if (S.mode === 'folders' && !S.files.some(f => f.startsWith(S.scope + '/') && f.slice(S.scope.length + 1).includes('/'))) S.mode = 'files'
180}
181
182function scopeUp() {
183 if (!S.scope) return
184 setScope(S.scope.includes('/') ? S.scope.slice(0, S.scope.lastIndexOf('/')) : '')
185}
186
187// Clicking a focused folder node (or one outside the scope) steps into it.
188function open(id: string) {
189 const n = S.drawn?.v.nodes.find(x => x.id === id)
190 if (!n) return
191 if (n.kind === 'folder') return setScope(n.id)
192 if (n.kind === 'outside') {
193 const isDir = S.files.some(f => f.startsWith(n.file + '/'))
194 setScope(isDir ? n.file : n.file.includes('/') ? n.file.slice(0, n.file.lastIndexOf('/')) : '')
195 }
196}
197
198function showInGraph(file: string) {
199 if (!S.graph?.files.some(f => f.id === file)) return false
200 if (S.scope && !file.startsWith(S.scope + '/')) setScope('')
201 if (S.mode === 'folders') S.mode = 'files'
202 setFocus(file, true)
203 S.tab = 'graph'
204 return true
205}
206
207function revealInTree(file: string) {
208 const parts = file.split('/')
209 for (let i = 1; i < parts.length; i++) S.expanded.add(parts.slice(0, i).join('/'))
210 S.cursor = file
211}
212
213// Search: an exact name first, then a name that starts with it, then any path that holds it.
214function find(q: string) {
215 const g = S.graph
216 const s = q.trim().toLowerCase()
217 if (!g || !s) return
218 const nodes = S.mode === 'functions' ? g.fns : g.files
219 const hit =
220 nodes.find(n => n.label.toLowerCase() === s) ??
221 nodes.find(n => n.label.toLowerCase().startsWith(s)) ??
222 nodes.find(n => n.id.toLowerCase().includes(s))
223 if (!hit) return
224 if (S.scope && !hit.file.startsWith(S.scope + '/')) setScope('')
225 if (S.mode === 'folders') S.mode = 'files'
226 setFocus(hit.id, true)
227}
228
229function selectChange(d: number) {
230 const at = S.changes.findIndex(c => c.path === S.sel)
231 const next = S.changes[Math.max(0, Math.min(S.changes.length - 1, at + d))]
232 if (next && next.path !== S.sel) { S.sel = next.path; S.diffTop = 0; S.diffText = ''; S.diff = [] }
233}
234
235function toggleReviewed(path: string) {
236 const c = S.changes.find(x => x.path === path)
237 if (!c) return
238 if (S.reviewed.has(path)) S.reviewed.delete(path)
239 else S.reviewed.set(path, stamp(c))
240}
241
242async function key($: EngineInterface, k: string) {
243 if (k === 'space') k = ' '
244 if (k === '1') S.tab = 'files'
245 else if (k === '2') S.tab = 'graph'
246 else if (k === '3') { S.tab = 'changes'; void refreshChanges($) }
247 else if (k === 'r') return void scan($)
248 else if (S.tab === 'graph') {
249 const step = 0.12 / S.cam.z
250 if (k === 'left') S.cam.cx -= step
251 else if (k === 'right') S.cam.cx += step
252 else if (k === 'up') S.cam.cy -= step
253 else if (k === 'down') S.cam.cy += step
254 else if (k === '+' || k === '=') S.cam.z = Math.min(12, S.cam.z * 1.25)
255 else if (k === '-' || k === '_') S.cam.z = Math.max(0.3, S.cam.z / 1.25)
256 else if (k === '0') resetCam()
257 else if (k === 'm') { S.mode = MODES[(MODES.indexOf(S.mode) + 1) % MODES.length]!; resetCam(); if (S.focus) S.center = S.focus.split('#')[0]! }
258 else if (k === ']') { if (S.mode === 'folders') S.levels = Math.min(6, S.levels + 1); else S.depth = Math.min(5, S.depth + 1) }
259 else if (k === '[') { if (S.mode === 'folders') S.levels = Math.max(1, S.levels - 1); else S.depth = Math.max(1, S.depth - 1) }
260 else if (k === 'g') { S.local = !S.local; resetCam(); if (S.focus) S.center = S.focus }
261 else if (k === 'o') S.orphans = !S.orphans
262 else if (k === 'p') S.pixels = !S.pixels
263 else if (k === 'u' || k === 'backspace') scopeUp()
264 else if (k === 'escape') { if (S.focus) S.focus = ''; else scopeUp() }
265 else if (k === 'return' && S.focus) {
266 const n = S.drawn?.v.nodes.find(x => x.id === S.focus)
267 if (n && (n.kind === 'folder' || n.kind === 'outside')) open(n.id)
268 else { revealInTree(S.focus.split('#')[0]!); S.tab = 'files' }
269 } else return
270 } else if (S.tab === 'changes') {
271 const page = Math.max(1, S.box.rows - S.listRows - 2)
272 if (k === 'up' || k === 'k') selectChange(-1)
273 else if (k === 'down' || k === 'j') selectChange(1)
274 else if (k === 'K') S.diffTop = Math.max(0, S.diffTop - 3)
275 else if (k === 'J') S.diffTop += 3
276 else if (k === 'pageup') S.diffTop = Math.max(0, S.diffTop - page)
277 else if (k === 'pagedown' || k === 'f') S.diffTop += page
278 else if (k === 'home') S.diffTop = 0
279 else if (k === ' ') { toggleReviewed(S.sel); selectChange(1) }
280 else if (k === 'n') {
281 const at = S.changes.findIndex(c => c.path === S.sel)
282 const next = [...S.changes.slice(at + 1), ...S.changes.slice(0, at + 1)].find(c => !S.reviewed.has(c.path))
283 if (next) { S.sel = next.path; S.diffTop = 0; S.diffText = ''; S.diff = [] }
284 } else if (k === 'a') for (const c of S.changes) S.reviewed.set(c.path, stamp(c))
285 else if (k === 'return') { if (!showInGraph(S.sel)) { revealInTree(S.sel); S.tab = 'files' } }
286 else return
287 if (!S.diffText) void refreshChanges($)
288 } else {
289 const at = S.tree.findIndex(r => r.id === S.cursor)
290 const row = S.tree[at]
291 const move = (d: number) => { S.cursor = S.tree[Math.max(0, Math.min(S.tree.length - 1, at + d))]?.id ?? S.cursor }
292 if (k === 'up' || k === 'k') move(-1)
293 else if (k === 'down' || k === 'j') move(1)
294 else if (k === 'pageup') move(-S.box.rows)
295 else if (k === 'pagedown') move(S.box.rows)
296 else if (!row) return
297 else if (k === 'right' || k === 'l') { if (row.dir) S.expanded.add(row.id) }
298 else if (k === 'left' || k === 'h') {
299 if (row.dir && S.expanded.has(row.id)) S.expanded.delete(row.id)
300 else if (row.id.includes('/')) S.cursor = row.id.slice(0, row.id.lastIndexOf('/'))
301 } else if (k === 'g') { if (row.dir) { setScope(row.id); S.tab = 'graph' } else showInGraph(row.id) }
302 else if (k === 'return' || k === ' ') pressRow(row)
303 else return
304 }
305 redraw($)
306}
307
308function pressRow(row: Row) {
309 S.cursor = row.id
310 if (!row.dir) return void showInGraph(row.id)
311 if (S.expanded.has(row.id)) S.expanded.delete(row.id)
312 else S.expanded.add(row.id)
313}
314
315async function openPane($: EngineInterface) {
316 await $.ui.open({ id: PANE, title: 'Repo graph', focus: true, rows: 40, columns: 80 })
317 S.poll ??= $.clock.every(POLL_MS, () => { if (S.tab === 'changes') void refreshChanges($) })
318 let top = S.root
319 try { top = await topOf($) } catch { /* scan reports it */ }
320 if (!S.graph || S.stale || S.root !== top) void scan($)
321 else void refreshChanges($)
322}
323
324function names(ids: string[], label: (id: string) => string, width: number): string {
325 let out = ''
326 for (const [i, id] of ids.entries()) {
327 const next = (i ? ', ' : '') + label(id)
328 if (out.length + next.length > width - 6) return `${out} +${ids.length - i}`
329 out += next
330 }
331 return out || '—'
332}
333
334function changeRows(cols: number, rows: number): Seg[][] {
335 const at = Math.max(0, S.changes.findIndex(c => c.path === S.sel))
336 if (at < S.listTop) S.listTop = at
337 if (at >= S.listTop + rows) S.listTop = at - rows + 1
338 S.listTop = Math.max(0, Math.min(S.listTop, Math.max(0, S.changes.length - rows)))
339 return S.changes.slice(S.listTop, S.listTop + rows).map(c => {
340 const bg = c.path === S.sel ? C.sel : undefined
341 const done = S.reviewed.has(c.path)
342 const counts = c.status === '?' ? (c.path.endsWith('/') ? ' folder' : ' new') : `${c.add ? ` +${c.add}` : ''}${c.del ? ` -${c.del}` : ''}`
343 const room = Math.max(4, cols - 4 - counts.length - 1)
344 const path = c.path.length > room ? '…' + c.path.slice(c.path.length - room + 1) : c.path
345 const pad = ' '.repeat(Math.max(0, cols - 4 - path.length - counts.length))
346 const segs: Seg[] = [
347 { t: done ? '✓ ' : '● ', c: done ? C.ok : C.touched },
348 { t: `${c.status} `, c: STATUS_COLOR[c.status] ?? C.fg, b: true },
349 { t: path, c: done ? C.muted : C.fg },
350 { t: pad },
351 { t: counts, c: C.muted },
352 ]
353 return segs.filter(s => s.t).map(s => (bg ? { ...s, bg } : s))
354 })
355}
356
357export const register: Register = on => {
358 on('session.start', async ($, e, next) => {
359 const r = await next(e)
360 await $.command.register({ name: 'repograph', description: 'Repo graph: files, dependencies and changes side by side; args: [folders|files|functions|changes|<folder>]' })
361 return r
362 })
363
364 on('command.run', { command: 'repograph' }, async ($, e) => {
365 const arg = (e.args ?? '').trim().replace(/\/$/, '')
366 await openPane($)
367 if (arg === 'functions' || arg === 'files' || arg === 'folders') { S.mode = arg; S.tab = 'graph' }
368 else if (arg === 'changes') S.tab = 'changes'
369 else if (arg) { setScope(arg); S.tab = 'graph' }
370 redraw($)
371 return { text: 'Repo graph: 1 files · 2 graph · 3 changes. Click the pane to give it the keys.' }
372 })
373
374 // Claude's edits light the file up, refresh the Changes tab, and mark the graph for a rescan.
375 on('tool.call', async ($, e, next) => {
376 const r = await next(e)
377 try {
378 const path = 'file_path' in e && typeof e.file_path === 'string' ? e.file_path : ''
379 if (path && S.root && path.startsWith(S.root + '/') && (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'MultiEdit')) {
380 S.touched.add(path.slice(S.root.length + 1))
381 S.stale = true
382 }
383 if (S.root && (path || e.tool === 'Bash')) $.clock.after(300, () => void refreshChanges($))
384 redraw($)
385 } catch { /* the pane must never break a tool call */ }
386 return r
387 })
388
389 on('turn.complete', async ($, e, next) => {
390 const r = await next(e)
391 if (!e.agentId && S.stale && S.graph) void scan($)
392 return r
393 })
394
395 on('ui.message', async ($, e, next) => {
396 if (e.requestId !== PANE || !['canvas', 'list', 'diff', 'graph'].includes(String(e.element)) || !e.data || typeof e.data !== 'object') return next(e)
397 const data = e.data as { key?: string; press?: [number, number]; release?: boolean; click?: [number, number]; drag?: [number, number]; hover?: [number, number] | null }
398 if (typeof data.key === 'string') await key($, data.key)
399 else if (data.click && e.element === 'list') {
400 const c = S.changes[S.listTop + Math.floor(data.click[1])]
401 if (c && c.path === S.sel && data.click[0] < 2) toggleReviewed(c.path)
402 else if (c) { S.sel = c.path; S.diffTop = 0; S.diffText = ''; S.diff = []; void refreshChanges($) }
403 redraw($)
404 } else if (data.press && S.tab === 'graph') {
405 // A press on a node acts at once; a press on empty space clears the focus
406 // only if it ends without a drag (a drag pans instead).
407 const id = S.drawn?.hit(data.press[0], data.press[1]) ?? ''
408 S.pendingClear = !id
409 if (id && id === S.focus) open(id)
410 else if (id) setFocus(id)
411 redraw($)
412 } else if (data.release && S.tab === 'graph') {
413 if (S.pendingClear) { S.focus = ''; redraw($) }
414 S.pendingClear = false
415 } else if (data.click && S.tab === 'graph') {
416 const id = S.drawn?.hit(data.click[0], data.click[1]) ?? ''
417 if (id && id === S.focus) open(id)
418 else setFocus(id)
419 redraw($)
420 } else if (data.click && S.tab === 'files') {
421 const row = S.tree[S.top + Math.floor(data.click[1])]
422 if (row) pressRow(row)
423 redraw($)
424 } else if (data.drag && S.tab === 'graph') {
425 S.pendingClear = false
426 const { cw, ch } = S.pixels ? cellSize(S.box.cols, S.box.rows) : { cw: 2, ch: 4 }
427 const scale = Math.min(S.box.cols * cw, S.box.rows * ch) * (S.pixels ? 0.92 : 1) * S.cam.z
428 S.cam.cx -= (data.drag[0] * cw) / scale
429 S.cam.cy -= (data.drag[1] * ch) / scale
430 soon($)
431 } else if ('hover' in data && S.tab === 'graph') {
432 const id = data.hover ? (S.drawn?.hit(data.hover[0], data.hover[1]) ?? '') : ''
433 if (id !== S.hover) { S.hover = id; soon($) }
434 }
435 return {}
436 })
437
438 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
439 if (S.tab === 'graph') S.cam.z = Math.max(0.3, Math.min(12, S.cam.z * (e.by < 0 ? 1.06 : 1 / 1.06)))
440 else if (S.tab === 'changes') S.diffTop = Math.max(0, S.diffTop + Math.sign(e.by) * 3)
441 else S.top = Math.max(0, S.top + Math.sign(e.by) * 3)
442 soon($)
443 return {}
444 })
445
446 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
447 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
448 const ui = $.ui.resolve(e)
449 const { Box, Text, Button, Input, Client } = ui
450 const cols = Math.max(30, (e.props?.bodyColumns ?? 80) - 1)
451 const height = Math.max(12, e.props?.scroll?.bodyRows ?? 36)
452 const g = S.graph
453 const open = S.changes.filter(c => !S.reviewed.has(c.path)).length
454 const tab = (id: Tab, label: string) => (
455 <Button key={`tab-${id}`} plain bold={S.tab === id} dimColor={S.tab !== id} label={S.tab === id ? `[ ${label} ]` : ` ${label} `} onPress={() => void key($, id === 'files' ? '1' : id === 'graph' ? '2' : '3')} />
456 )
457 const header = (
458 <Box flexDirection="row">
459 {tab('files', 'Files')}
460 {tab('graph', 'Graph')}
461 {tab('changes', S.changes.length ? `Changes ${open}/${S.changes.length}` : 'Changes')}
462 <Box flexGrow={1} />
463 <Button key="rescan" plain dimColor label={S.scanning ? '…' : '↻'} onPress={() => void scan($)} />
464 </Box>
465 )
466 const frame = (...body: unknown[]) => (
467 <Box flexDirection="column" minHeight={height}>
468 {header}
469 {body}
470 </Box>
471 )
472 if (!g) {
473 return frame(
474 <Text color={S.error ? 'red' : C.muted}>{S.error || (S.scanning ? 'Scanning the repo…' : 'Press r to scan.')}</Text>,
475 <Client key="canvas" module="./canvas.tsx" props={{ rows: [] } satisfies CanvasProps} />,
476 )
477 }
478
479 if (S.tab === 'files') {
480 const rows = Math.max(4, height - 2)
481 S.box = { cols, rows }
482 const t = treeRows(S.files, g.fileEdges, S, groupColor(groupsOf(g)), C, cols, rows)
483 S.tree = t.list
484 return frame(
485 <Client key="canvas" module="./canvas.tsx" props={{ rows: t.rows } satisfies CanvasProps} />,
486 <Box flexGrow={1} />,
487 <Text color={C.muted} wrap="truncate-end">{`${S.files.length} files · ←used by →imports · enter: open / show · g: graph this folder`}</Text>,
488 )
489 }
490
491 if (S.tab === 'changes') {
492 S.listRows = Math.max(1, Math.min(S.changes.length, Math.max(3, Math.floor((height - 4) / 3))))
493 const diffRowsN = Math.max(3, height - 3 - S.listRows)
494 S.box = { cols, rows: height - 1 }
495 if (!S.changes.length) {
496 return frame(<Text color={C.muted}>{S.reading ? 'Reading git…' : 'No changes since the last commit.'}</Text>, <Box flexGrow={1} />, <Text color={C.muted}>{`watching git every ${POLL_MS / 1000}s`}</Text>)
497 }
498 S.diffTop = Math.max(0, Math.min(S.diffTop, Math.max(0, S.diff.length - diffRowsN)))
499 const cur = S.changes.find(c => c.path === S.sel)
500 const shown = S.diff.slice(S.diffTop, S.diffTop + diffRowsN)
501 const where = S.diff.length > diffRowsN ? ` ${S.diffTop + 1}-${Math.min(S.diff.length, S.diffTop + diffRowsN)}/${S.diff.length}` : ''
502 return frame(
503 <Client key="list" module="./canvas.tsx" props={{ rows: changeRows(cols, S.listRows) } satisfies CanvasProps} />,
504 <Box flexDirection="row">
505 <Text color={C.hot} bold wrap="truncate-start">{`─ ${cur?.path ?? ''} `}</Text>
506 <Box flexGrow={1} />
507 <Text color={C.muted}>{where}</Text>
508 </Box>,
509 <Client key="diff" module="./canvas.tsx" props={{ rows: shown.length ? shown : [[{ t: S.reading ? 'loading…' : '(no line changes)', c: C.muted }]] } satisfies CanvasProps} />,
510 <Box flexGrow={1} />,
511 <Text color={C.muted} wrap="truncate-end">↑↓ file · J/K, pgup/pgdn, wheel: scroll · space reviewed · n next open · a all · enter: graph</Text>,
512 )
513 }
514
515 // Graph tab: the picture, labels laid over it as text, and an empty box over
516 // both that catches the pointer.
517 const rows = Math.max(6, height - 7)
518 S.box = { cols, rows }
519 const pixels = S.pixels && e.surface === 'terminal'
520 const input = { mode: S.mode, scope: S.scope, focus: S.focus, depth: S.depth, levels: S.levels, local: S.local, orphans: S.orphans, cap: GLOBAL_CAP }
521 let d = draw(g, input, S.cam, cols, rows, pixels, S.hover, S.touched)
522 if (S.center) {
523 const p = d.centre(S.center) ?? d.centre(d.v.seeds[0] ?? '')
524 S.center = ''
525 if (p) { S.cam.cx = p[0]; S.cam.cy = p[1]; d = draw(g, input, S.cam, cols, rows, pixels, S.hover, S.touched) }
526 }
527 S.drawn = d
528 const v = d.v
529 const nodeById = new Map(v.nodes.map(n => [n.id, n]))
530 const label = (id: string) => {
531 const n = nodeById.get(id)
532 return !n ? id : n.kind === 'fn' ? `${n.label}()` : n.kind === 'outside' ? `${n.label} (outside)` : n.file
533 }
534 const words = S.mode === 'functions' ? ['calls', 'called by'] : ['imports', 'used by']
535 const focusNode = nodeById.get(v.seeds[0] ?? '')
536 const hovered = nodeById.get(S.hover)
537 const Image = e.surface === 'terminal' ? ($.ui.resolve(e) as { Image?: (p: object) => unknown }).Image : undefined
538 if (pixels && d.image) void ensurePicture($, d.image)
539 const source = pic.inline && d.image ? { rgba: d.image.rgba, width: d.image.width, height: d.image.height } : pic.ready ? { ...pic.ready, format: 'rgba' as const } : null
540 const picture =
541 pixels && Image ? (
542 source ? <Image key="graph-image" source={source} columns={cols} rows={rows} alt="repo graph" /> : <Box width={cols} height={rows} />
543 ) : (
544 <Client key="canvas" module="./canvas.tsx" props={{ rows: d.text ?? [] } satisfies CanvasProps} />
545 )
546 const crumbs = S.scope ? S.scope.split('/') : []
547 const groups = groupsOf(g)
548 const lines = S.focus
549 ? [
550 <Text bold color="#ffffff" wrap="truncate-start">{focusNode ? label(focusNode.id) : S.focus}</Text>,
551 <Text color={OUT} wrap="truncate-end">{`→ ${words[0]} (${v.out.size}): ${names([...v.out], label, cols)}`}</Text>,
552 <Text color={IN} wrap="truncate-end">{`← ${words[1]} (${v.inn.size}): ${names([...v.inn], label, cols)}`}</Text>,
553 ]
554 : [
555 <Text color={hovered ? '#ffffff' : C.fg} wrap="truncate-end">
556 {hovered ? label(hovered.id) : `${v.nodes.length} ${S.mode} · ${v.edges.length} links${v.hidden ? ` · ${v.hidden} unlinked hidden (o)` : ''}${v.total - v.hidden > v.nodes.length ? ` · top ${v.nodes.length}` : ''}`}
557 </Text>,
558 <Text color={C.muted} wrap="truncate-end">{`hubs: ${names([...v.nodes].filter(n => n.kind !== 'outside').sort((a, b) => v.deg(b.id) - v.deg(a.id)).slice(0, 5).map(n => n.id), label, cols)}`}</Text>,
559 <Box flexDirection="row" overflow="hidden" height={1}>
560 {groups.slice(0, 10).map(gr => (
561 <Text color={groupColor(groups)(gr)}>{`● ${gr} `}</Text>
562 ))}
563 </Box>,
564 ]
565 return frame(
566 <Box flexDirection="row">
567 <Button key="scope-root" plain dimColor={!!S.scope} bold={!S.scope} label="⌂ repo" onPress={() => { setScope(''); redraw($) }} />
568 {crumbs.map((c, i) => (
569 <Button key={`crumb-${i}`} plain dimColor={i < crumbs.length - 1} bold={i === crumbs.length - 1} label={` / ${c}`} onPress={() => { setScope(crumbs.slice(0, i + 1).join('/')); redraw($) }} />
570 ))}
571 <Box flexGrow={1} />
572 <Button key="mode" plain dimColor label={`${S.mode} ⇄`} onPress={() => void key($, 'm')} />
573 <Text> </Text>
574 <Button key="depth-down" plain dimColor label="‹" onPress={() => void key($, '[')} />
575 <Text color={C.fg}>{S.mode === 'folders' ? ` levels ${S.levels} ` : ` depth ${S.depth} `}</Text>
576 <Button key="depth-up" plain dimColor label="›" onPress={() => void key($, ']')} />
577 </Box>,
578 <Input key="find" label="/ " placeholder={S.mode === 'functions' ? 'find a function' : 'find a file'} submitLabel="focus" value="" onSubmit={(q: string) => { find(q); redraw($) }} />,
579 <Box key="stage" position="relative" width={cols} height={rows}>
580 {picture}
581 {d.labels.map((l, i) => (
582 <Box key={`l${i}`} position="absolute" top={l.y} left={l.x}>
583 <Text color={l.color} bold={l.bold}>{l.text}</Text>
584 </Box>
585 ))}
586 <Box key="hit" position="absolute" top={0} left={0}>
587 <Client key="graph" module="./overlay.tsx" props={{ cols, rows } satisfies OverlayProps} />
588 </Box>
589 </Box>,
590 ...lines,
591 <Box flexGrow={1} />,
592 <Text color={C.muted} wrap="truncate-end">click: focus · again: open folder · drag · wheel zoom · [ ] levels/depth · m mode · u up · g near/all · o unlinked · p pixels/text · esc</Text>,
593 )
594 })
595}
596hooks/scan.ts 180 lines1// Builds the dependency graph from file texts: file imports, and function calls.
2// Regex based on purpose: no parser per language, so it's fast and dependency
3// free, and it misses what regexes miss (dynamic dispatch, re-exports by name).
4
5export type Node = { id: string; label: string; file: string; group: string }
6export type Graph = { files: Node[]; fileEdges: [string, string][]; fns: Node[]; fnEdges: [string, string][] }
7
8export const CODE = /\.(ts|tsx|js|jsx|mjs|cjs|py|swift|go|rs|kt|java|rb|php|vue|svelte)$/
9const JS = /\.(ts|tsx|js|jsx|mjs|cjs|vue|svelte)$/
10const EXTS = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte', '/index.ts', '/index.tsx', '/index.js', '/index.jsx', '/index.mjs']
11
12const group = (f: string) => (f.includes('/') ? f.split('/')[0]! : '.')
13const dirname = (f: string) => (f.includes('/') ? f.slice(0, f.lastIndexOf('/')) : '')
14
15function normalize(p: string): string {
16 const out: string[] = []
17 for (const part of p.split('/')) {
18 if (part === '..') out.pop()
19 else if (part && part !== '.') out.push(part)
20 }
21 return out.join('/')
22}
23
24function tryFile(base: string, files: Set<string>): string | null {
25 const bases = [base, base.replace(/\.(m|c)?js$/, '')] // TS ESM writes './x.js' for x.ts
26 for (const b of bases) for (const ext of EXTS) if (files.has(b + ext)) return b + ext
27 return null
28}
29
30function jsImports(text: string): string[] {
31 const out: string[] = []
32 const re = /(?:import|export)\s[^'"]*?from\s*['"]([^'"]+)['"]|import\s*\(?\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)/g
33 for (const m of text.matchAll(re)) out.push((m[1] ?? m[2] ?? m[3])!)
34 return out
35}
36
37function resolveJs(from: string, spec: string, files: Set<string>, pkgs: [string, string][]): string | null {
38 if (spec.startsWith('.')) return tryFile(normalize(`${dirname(from)}/${spec}`), files)
39 if (spec.startsWith('@/') || spec.startsWith('~/')) {
40 // Path alias: try every ancestor folder of the importer, nearest first.
41 const rest = spec.slice(2)
42 const parts = dirname(from).split('/').filter(Boolean)
43 for (let i = parts.length; i >= 0; i--) {
44 const dir = parts.slice(0, i).join('/')
45 const hit = tryFile(normalize(`${dir}/src/${rest}`), files) ?? tryFile(normalize(`${dir}/${rest}`), files)
46 if (hit) return hit
47 }
48 return null
49 }
50 // Workspace package: `@acme/engine` or `@acme/engine/sub`.
51 const pkg = pkgs.find(([name]) => spec === name || spec.startsWith(name + '/'))
52 if (!pkg) return null
53 const sub = spec.slice(pkg[0].length)
54 return sub
55 ? (tryFile(normalize(`${pkg[1]}/src${sub}`), files) ?? tryFile(normalize(pkg[1] + sub), files))
56 : (tryFile(normalize(`${pkg[1]}/src/index`), files) ?? tryFile(normalize(`${pkg[1]}/index`), files))
57}
58
59function pyIndex(files: string[]): Map<string, string> {
60 // Every dotted suffix of a module path, e.g. src/pkg/mod.py → mod, pkg.mod, src.pkg.mod.
61 const index = new Map<string, string | null>()
62 for (const f of files) {
63 if (!f.endsWith('.py')) continue
64 const parts = f.replace(/(\/__init__)?\.py$/, '').split('/')
65 for (let i = 0; i < parts.length; i++) {
66 const key = parts.slice(i).join('.')
67 index.set(key, index.has(key) && index.get(key) !== f ? null : f) // ambiguous → null
68 }
69 }
70 return new Map([...index].filter((e): e is [string, string] => e[1] !== null))
71}
72
73function pyImports(from: string, text: string, index: Map<string, string>): string[] {
74 const out: string[] = []
75 const add = (mod: string) => {
76 const hit = index.get(mod)
77 if (hit && hit !== from) out.push(hit)
78 }
79 for (const m of text.matchAll(/^\s*from\s+(\.*)([\w.]*)\s+import\s+([\w, ()*]+)/gm)) {
80 const [, dots = '', mod = '', names = ''] = m
81 let base = mod
82 if (dots) {
83 const dir = dirname(from).split('/').filter(Boolean)
84 base = [...dir.slice(0, dir.length - (dots.length - 1)), ...(mod ? [mod] : [])].join('.')
85 }
86 for (const n of names.replace(/[()]/g, '').split(',').map(s => s.trim()).filter(Boolean)) add(base ? `${base}.${n}` : n)
87 if (base) add(base)
88 }
89 for (const m of text.matchAll(/^\s*import\s+([\w., ]+)/gm)) for (const mod of m[1]!.split(',')) add(mod.trim().split(/\s/)[0]!)
90 return out
91}
92
93const KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'constructor', 'super', 'else', 'do', 'try', 'with', 'elif', 'print', 'match'])
94const DEF = [
95 /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\*?\s+([A-Za-z_$][\w$]*)/,
96 /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:function\b|\([^)]*\)\s*(?::[^=]+)?=>|[A-Za-z_$][\w$]*\s*=>)/,
97 /^\s+(?:(?:public|private|protected|static|async|override|get|set)\s+)*([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*(?::\s*[^{=]+)?\{\s*$/,
98 /^\s*(?:async\s+)?def\s+(\w+)/,
99 /^\s*(?:@\w+\s+)*(?:(?:public|private|fileprivate|internal|open|static|override|mutating|class)\s+)*func\s+(\w+)/,
100 /^func\s+(?:\([^)]*\)\s*)?(\w+)/,
101 /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+(\w+)/,
102]
103
104type Fn = { id: string; name: string; file: string; body: string }
105
106function functions(file: string, text: string): Fn[] {
107 const lines = text.split('\n')
108 const starts: [number, string][] = []
109 lines.forEach((line, i) => {
110 for (const re of DEF) {
111 const name = re.exec(line)?.[1]
112 if (name && !KEYWORDS.has(name)) {
113 starts.push([i, name])
114 break
115 }
116 }
117 })
118 const seen = new Set<string>()
119 return starts.flatMap(([at, name], k) => {
120 if (seen.has(name)) return [] // overloads and same-named methods: first one stands for all
121 seen.add(name)
122 const body = lines.slice(at + 1, starts[k + 1]?.[0] ?? lines.length).join('\n')
123 return [{ id: `${file}#${name}`, name, file, body }]
124 })
125}
126
127export function build(texts: Record<string, string>): Graph {
128 const all = Object.keys(texts).sort()
129 const files = all.filter(f => CODE.test(f))
130 const set = new Set(files)
131 const pkgs: [string, string][] = []
132 for (const f of all) {
133 if (!f.endsWith('package.json') || f.includes('node_modules/')) continue
134 try {
135 const name = JSON.parse(texts[f]!).name
136 if (typeof name === 'string' && dirname(f)) pkgs.push([name, dirname(f)])
137 } catch { /* not json: skip */ }
138 }
139 pkgs.sort((a, b) => b[0].length - a[0].length)
140 const py = pyIndex(files)
141
142 const imports = new Map<string, Set<string>>()
143 for (const f of files) {
144 const text = texts[f]!
145 const targets = JS.test(f)
146 ? jsImports(text).map(s => resolveJs(f, s, set, pkgs))
147 : f.endsWith('.py') ? pyImports(f, text, py) : []
148 imports.set(f, new Set(targets.filter((t): t is string => Boolean(t) && t !== f)))
149 }
150 const fileEdges = files.flatMap(f => [...imports.get(f)!].map(t => [f, t] as [string, string]))
151
152 // Calls: a defined name followed by `(` inside another function's body.
153 // A name resolves to the same file first, then to files it imports, then to
154 // a name defined exactly once in the repo. Anything else is too ambiguous.
155 const fns = files.flatMap(f => functions(f, texts[f]!))
156 const byName = new Map<string, Fn[]>()
157 for (const fn of fns) byName.set(fn.name, [...(byName.get(fn.name) ?? []), fn])
158 const fnEdges: [string, string][] = []
159 for (const fn of fns) {
160 const called = new Set<string>()
161 for (const m of fn.body.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)\s*\(|\.([A-Za-z_$][\w$]*)\s*\(/g)) called.add((m[1] ?? m[2])!)
162 for (const name of called) {
163 const cands = byName.get(name)
164 if (!cands) continue
165 const target =
166 cands.find(c => c.file === fn.file) ??
167 cands.find(c => imports.get(fn.file)?.has(c.file)) ??
168 (cands.length === 1 && name.length > 3 ? cands[0] : undefined)
169 if (target && target.id !== fn.id) fnEdges.push([fn.id, target.id])
170 }
171 }
172
173 return {
174 files: files.map(f => ({ id: f, label: f.split('/').pop()!, file: f, group: group(f) })),
175 fileEdges,
176 fns: fns.map(fn => ({ id: fn.id, label: fn.name, file: fn.file, group: group(fn.file) })),
177 fnEdges,
178 }
179}
180hooks/layout.ts 164 lines1// Force layout and a braille rasterizer: the Obsidian graph look in a terminal.
2
3export type Pos = Map<string, [number, number]>
4export type Seg = { t: string; c?: string; b?: boolean; bg?: string }
5export type View = { cx: number; cy: number; z: number }
6export type Style = { color: (id: string) => string; edge: string; hot: string; dim: string; label: string }
7
8function seed(id: string): number {
9 let h = 2166136261
10 for (let i = 0; i < id.length; i++) h = Math.imul(h ^ id.charCodeAt(i), 16777619)
11 return ((h >>> 0) % 10000) / 10000
12}
13
14// Fruchterman–Reingold with a pull to the centre, so loose parts stay close.
15// A link pulls less when it touches a hub, so hubs don't swallow their
16// neighbours (the same trick as d3-force, which Obsidian's graph uses).
17// ponytail: O(n²) per step, fine to ~500 nodes; Barnes–Hut if graphs get bigger.
18// `from` keeps nodes where an earlier layout put them, so a view that changes
19// (a new focus, a deeper ring) moves calmly instead of jumping.
20export function layout(ids: string[], edges: [string, string][], steps = 220, from?: Pos): Pos {
21 const n = ids.length
22 const at = new Map(ids.map((id, i) => [id, i]))
23 const x = ids.map(id => from?.get(id)?.[0] ?? seed(id))
24 const y = ids.map(id => from?.get(id)?.[1] ?? seed(id + '~'))
25 const links = edges.flatMap(([a, b]) => (at.has(a) && at.has(b) && a !== b ? [[at.get(a)!, at.get(b)!]] : []))
26 const deg = new Array(n).fill(0)
27 for (const [a, b] of links) { deg[a!]++; deg[b!]++ }
28 const k = Math.sqrt(1 / Math.max(1, n))
29 const far = (4 * k) ** 2
30 let temp = from && ids.some(id => from.has(id)) ? 0.04 : 0.1
31 for (let s = 0; s < steps; s++) {
32 const dx = new Array(n).fill(0)
33 const dy = new Array(n).fill(0)
34 for (let i = 0; i < n; i++) {
35 for (let j = i + 1; j < n; j++) {
36 const ux = x[i]! - x[j]!
37 const uy = y[i]! - y[j]!
38 const d2 = Math.max(1e-6, ux * ux + uy * uy)
39 if (d2 > far) continue // repel only nearby nodes, so loose parts aren't pushed out
40 const f = (k * k) / d2
41 dx[i] += ux * f; dy[i] += uy * f
42 dx[j] -= ux * f; dy[j] -= uy * f
43 }
44 dx[i] += (0.5 - x[i]!) * 0.15 * k
45 dy[i] += (0.5 - y[i]!) * 0.15 * k
46 }
47 for (const [a, b] of links) {
48 const ux = x[a!]! - x[b!]!
49 const uy = y[a!]! - y[b!]!
50 const d = Math.sqrt(ux * ux + uy * uy) || 1e-3
51 const f = d / k / Math.sqrt(Math.min(deg[a!], deg[b!]))
52 dx[a!] -= ux * f; dy[a!] -= uy * f
53 dx[b!] += ux * f; dy[b!] += uy * f
54 }
55 for (let i = 0; i < n; i++) {
56 const d = Math.sqrt(dx[i] * dx[i] + dy[i] * dy[i]) || 1
57 const step = Math.min(d, temp)
58 x[i] = x[i]! + (dx[i] / d) * step
59 y[i] = y[i]! + (dy[i] / d) * step
60 }
61 temp *= 0.985
62 }
63 // Fit the middle 90% into 0.05..0.95, same scale on both axes; stragglers
64 // (small loose components) sit on the rim instead of shrinking everything.
65 const q = (v: number[], p: number) => [...v].sort((a, b) => a - b)[Math.floor(p * (v.length - 1))]!
66 const [x0, x1, y0, y1] = n > 20 ? [q(x, 0.05), q(x, 0.95), q(y, 0.05), q(y, 0.95)] : [Math.min(...x), Math.max(...x), Math.min(...y), Math.max(...y)]
67 const span = Math.max(x1 - x0, y1 - y0, 1e-6)
68 const fit = (v: number, v0: number, v1: number) => Math.max(0.01, Math.min(0.99, 0.05 + (0.9 * (v - v0 + (span - (v1 - v0)) / 2)) / span))
69 return new Map(ids.map((id, i) => [id, [fit(x[i]!, x0, x1), fit(y[i]!, y0, y1)]]))
70}
71
72const BITS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]]
73
74type Draw = { pos: Pos; edges: [string, string][]; focus: string; near: Set<string>; labels: string[]; names: Map<string, string>; big: Set<string> }
75
76// Draws the graph into `cols`×`rows` cells. Returns the rows as colour runs and
77// where each node landed, for clicks.
78export function raster(d: Draw, cols: number, rows: number, view: View, s: Style) {
79 const W = cols * 2
80 const H = rows * 4
81 const scale = Math.min(W, H) // a cell is ~1:2, so its 2×4 braille dots are square
82 const proj = ([px, py]: [number, number]): [number, number] => [
83 Math.round(((px - view.cx) * view.z) * scale + W / 2),
84 Math.round(((py - view.cy) * view.z) * scale + H / 2),
85 ]
86 const mask = new Uint8Array(cols * rows)
87 const hot = new Uint8Array(cols * rows)
88 const dot = (X: number, Y: number, isHot: boolean) => {
89 if (X < 0 || Y < 0 || X >= W || Y >= H) return
90 const c = (Y >> 2) * cols + (X >> 1)
91 mask[c]! |= BITS[Y & 3]![X & 1]!
92 if (isHot) hot[c] = 1
93 }
94 for (const [a, b] of d.edges) {
95 const pa = d.pos.get(a)
96 const pb = d.pos.get(b)
97 if (!pa || !pb) continue
98 let [x0, y0] = proj(pa)
99 const [x1, y1] = proj(pb)
100 const isHot = Boolean(d.focus) && (a === d.focus || b === d.focus)
101 const dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1
102 const dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1
103 let err = dx + dy
104 for (let guard = 0; guard < 4 * (W + H); guard++) {
105 dot(x0, y0, isHot)
106 if (x0 === x1 && y0 === y1) break
107 const e2 = 2 * err
108 if (e2 >= dy) { err += dy; x0 += sx }
109 if (e2 <= dx) { err += dx; y0 += sy }
110 }
111 }
112 const cells: { t: string; c?: string; b?: boolean }[] = Array.from({ length: cols * rows }, (_, i) =>
113 mask[i] ? { t: String.fromCharCode(0x2800 + mask[i]!), c: hot[i] ? s.hot : s.edge } : { t: ' ' })
114 const taken = new Uint8Array(cols * rows)
115 const hits: { id: string; x: number; y: number }[] = []
116 for (const [id, p] of d.pos) {
117 const [X, Y] = proj(p)
118 if (X < 0 || Y < 0 || X >= W || Y >= H) continue
119 const cx = X >> 1, cy = Y >> 2, c = cy * cols + cx
120 const isFocus = id === d.focus
121 const faded = Boolean(d.focus) && !d.near.has(id)
122 cells[c] = { t: isFocus || d.big.has(id) ? '●' : '•', c: faded ? s.dim : s.color(id), b: isFocus }
123 taken[c] = 1
124 hits.push({ id, x: cx, y: cy })
125 }
126 // Labels: to the right of the node, else to the left; skipped if they'd overlap.
127 for (const id of d.labels) {
128 const hit = hits.find(h => h.id === id)
129 if (!hit) continue
130 const text = d.names.get(id) ?? id
131 const fits = (from: number) => from >= 0 && from + text.length <= cols && [...text].every((_, i) => !taken[hit.y * cols + from + i])
132 const from = fits(hit.x + 2) ? hit.x + 2 : fits(hit.x - 1 - text.length) ? hit.x - 1 - text.length : -1
133 if (from < 0) continue
134 ;[...text].forEach((ch, i) => {
135 const c = hit.y * cols + from + i
136 cells[c] = { t: ch, c: id === d.focus ? s.hot : s.label, b: id === d.focus }
137 taken[c] = 1
138 })
139 }
140 const out: Seg[][] = []
141 for (let r = 0; r < rows; r++) {
142 const line: Seg[] = []
143 for (let c = 0; c < cols; c++) {
144 const cell = cells[r * cols + c]!
145 const last = line[line.length - 1]
146 if (last && last.c === cell.c && last.b === cell.b) last.t += cell.t
147 else line.push({ t: cell.t, c: cell.c, b: cell.b })
148 }
149 out.push(line.map(seg => Object.fromEntries(Object.entries(seg).filter(([, v]) => v !== undefined)) as Seg))
150 }
151 return { rows: out, hits }
152}
153
154// The node under a click, if one is within a couple of cells.
155export function nearest(hits: { id: string; x: number; y: number }[], x: number, y: number): string {
156 let best = ''
157 let bestD = 6
158 for (const h of hits) {
159 const d = Math.abs(h.x - x) + 2 * Math.abs(h.y - y)
160 if (d < bestD) { best = h.id; bestD = d }
161 }
162 return best
163}
164hooks/tree.ts 58 lines1// The Files tab: every tracked file in its folders, with link counts per code file.
2import type { Seg } from './layout'
3
4export type TreeState = { expanded: Set<string>; cursor: string; top: number; focus: string; touched: Set<string> }
5export type Row = { id: string; dir: boolean }
6
7const clean = (segs: Seg[]) => segs.filter(s => s.t).map(s => Object.fromEntries(Object.entries(s).filter(([, v]) => v !== undefined)) as Seg)
8
9// Moves `st.cursor` and `st.top` so the cursor stays on screen.
10export function treeRows(files: string[], edges: [string, string][], st: TreeState, color: (group: string) => string, C: Record<string, string>, cols: number, rows: number) {
11 const ins = new Map<string, number>()
12 const outs = new Map<string, number>()
13 for (const [a, b] of edges) { outs.set(a, (outs.get(a) ?? 0) + 1); ins.set(b, (ins.get(b) ?? 0) + 1) }
14 const kids = new Map<string, Set<string>>()
15 for (const f of files) {
16 const parts = f.split('/')
17 for (let i = 0; i < parts.length; i++) {
18 const parent = parts.slice(0, i).join('/')
19 kids.set(parent, (kids.get(parent) ?? new Set()).add(parts.slice(0, i + 1).join('/')))
20 }
21 }
22 const isDir = (id: string) => kids.has(id)
23 const list: { id: string; depth: number }[] = []
24 const walk = (dir: string, depth: number) => {
25 const sorted = [...(kids.get(dir) ?? [])].sort((a, b) => Number(isDir(b)) - Number(isDir(a)) || a.localeCompare(b))
26 for (const id of sorted) {
27 list.push({ id, depth })
28 if (isDir(id) && st.expanded.has(id)) walk(id, depth + 1)
29 }
30 }
31 walk('', 0)
32 if (!st.cursor || !list.some(r => r.id === st.cursor)) st.cursor = list[0]?.id ?? ''
33 const at = list.findIndex(r => r.id === st.cursor)
34 if (at < st.top) st.top = at
35 if (at >= st.top + rows) st.top = at - rows + 1
36 st.top = Math.max(0, Math.min(st.top, Math.max(0, list.length - rows)))
37 const drawn = list.slice(st.top, st.top + rows).map(({ id, depth }) => {
38 const dir = isDir(id)
39 const name = id.split('/').pop()!
40 const bg = id === st.cursor ? C.sel : undefined
41 const counts = dir ? '' : `${ins.get(id) ? ` ←${ins.get(id)}` : ''}${outs.get(id) ? ` →${outs.get(id)}` : ''}`
42 const left = `${' '.repeat(depth)}${dir ? (st.expanded.has(id) ? '▾ ' : '▸ ') : ' '}`
43 const room = Math.max(4, cols - left.length - counts.length - 2)
44 const shown = name.length > room ? name.slice(0, room - 1) + '…' : name
45 const dot = dir ? '■ ' : '• '
46 const tint = st.touched.has(id) ? C.touched : color(id.includes('/') ? id.split('/')[0]! : '.')
47 const pad = ' '.repeat(Math.max(0, cols - left.length - dot.length - shown.length - counts.length))
48 return clean([
49 { t: left, c: C.muted, bg },
50 { t: dot, c: tint, bg },
51 { t: shown, c: id === st.focus ? C.hot : C.fg, b: id === st.focus, bg },
52 { t: pad, bg },
53 { t: counts, c: C.muted, bg },
54 ])
55 })
56 return { rows: drawn, list: list.map(r => ({ id: r.id, dir: isDir(r.id) })) }
57}
58hooks/changes.ts 73 lines1// The Changes tab: what git says changed since HEAD, and one file's diff.
2import type { Seg } from './layout'
3
4export type Change = { path: string; status: string; add: number; del: number }
5
6// `git status --porcelain=v1 -z` → one entry per path. Renames carry their old path next.
7export function parseStatus(z: string): Change[] {
8 const parts = z.split('\0')
9 const out: Change[] = []
10 for (let i = 0; i < parts.length; i++) {
11 const p = parts[i]!
12 if (p.length < 4) continue
13 const xy = p.slice(0, 2)
14 out.push({ path: p.slice(3), status: xy === '??' ? '?' : (xy.trim()[0] ?? 'M'), add: 0, del: 0 })
15 if (xy[0] === 'R' || xy[0] === 'C') i++
16 }
17 // Tracked changes first (what the agent edited), then untracked files and folders.
18 return out.sort((a, b) => Number(a.status === '?') - Number(b.status === '?') || a.path.localeCompare(b.path))
19}
20
21// `git diff HEAD --numstat` → line counts per path ("-" for binary files).
22export function applyNumstat(changes: Change[], numstat: string): Change[] {
23 const counts = new Map<string, [number, number]>()
24 for (const line of numstat.split('\n')) {
25 const [a, d, path] = line.split('\t')
26 if (path) counts.set(path, [Number(a) || 0, Number(d) || 0])
27 }
28 return changes.map(c => ({ ...c, add: counts.get(c.path)?.[0] ?? c.add, del: counts.get(c.path)?.[1] ?? c.del }))
29}
30
31// A stamp for "has this file changed again since I reviewed it".
32export const stamp = (c: Change) => `${c.status}|${c.add}|${c.del}`
33
34export const STATUS_COLOR: Record<string, string> = { M: '#e5c07b', A: '#98c379', '?': '#98c379', D: '#e06c75', R: '#61afef', C: '#61afef', U: '#e06c75' }
35
36// The diff as colour runs: hunks in cyan, adds green, removes red, file headers dropped.
37export function diffRows(diff: string, cols: number): Seg[][] {
38 const rows: Seg[][] = []
39 for (const line of diff.split('\n')) {
40 if (/^(diff --git|index |--- |\+\+\+ |new file mode|deleted file mode|similarity|rename (from|to))/.test(line)) continue
41 const t = line.replace(/\t/g, ' ')
42 const shown = t.length > cols ? t.slice(0, cols - 1) + '…' : t
43 const c = t.startsWith('@@') ? '#56b6c2' : t.startsWith('+') ? '#98c379' : t.startsWith('-') ? '#e06c75' : t.startsWith('\\') ? '#6b6b6b' : '#bdbdbd'
44 rows.push([{ t: shown || ' ', c }])
45 }
46 while (rows.length && rows[rows.length - 1]![0]!.t === ' ') rows.pop()
47 return rows
48}
49
50type Run = (argv: string[], opts: { timeoutMs: number }) => Promise<{ exitCode: number; stdout: string }>
51
52// Reads status and line counts. `--no-optional-locks` so we never fight the agent's git.
53export async function readChanges(run: Run, root: string): Promise<Change[] | null> {
54 const git = (...args: string[]) => run(['git', '--no-optional-locks', '-C', root, ...args], { timeoutMs: 10_000 })
55 const status = await git('status', '--porcelain=v1', '-z', '--untracked-files=normal')
56 if (status.exitCode !== 0) return null
57 const numstat = await git('diff', 'HEAD', '--numstat', '--no-renames')
58 return applyNumstat(parseStatus(status.stdout), numstat.exitCode === 0 ? numstat.stdout : '')
59}
60
61export async function readDiff(run: Run, root: string, c: Change): Promise<string> {
62 const git = (...args: string[]) => run(['git', '--no-optional-locks', '-C', root, ...args], { timeoutMs: 10_000 })
63 // An untracked folder is one row: list what's in it.
64 if (c.status === '?' && c.path.endsWith('/')) {
65 const r = await git('ls-files', '--others', '--exclude-standard', '--', c.path)
66 const files = r.stdout.split('\n').filter(Boolean)
67 return [`@@ untracked folder: ${files.length} files @@`, ...files.slice(0, 2000).map(f => `+${f}`)].join('\n')
68 }
69 // An untracked file has no diff against HEAD: compare it with nothing (exit 1 means "differs").
70 const r = c.status === '?' ? await git('diff', '--no-color', '--no-index', '--', '/dev/null', c.path) : await git('diff', 'HEAD', '--no-color', '--', c.path)
71 return r.stdout.split('\n').slice(0, 3000).join('\n')
72}
73hooks/graph.ts 128 lines1// Draws the Graph tab: the layout, the picture (or braille text as a fallback),
2// where the labels go, and which node a click lands on.
3import type { Graph } from './scan'
4import { layout, raster, type Pos, type Seg } from './layout'
5import { Canvas, cellSize, hex, type RGB } from './paint'
6import { view, type ViewInput, type VNode } from './view'
7
8export const PALETTE = ['#a882ff', '#53dfdd', '#e5c07b', '#98c379', '#e06c75', '#61afef', '#d19a66', '#c678dd', '#56b6c2', '#ff79c6']
9export const OUT = '#a882ff' // what the focus imports or calls
10export const IN = '#53dfdd' // what uses or calls the focus
11const MUTED = '#8a8a99'
12
13export type Cam = { cx: number; cy: number; z: number }
14export type Label = { x: number; y: number; text: string; color: string; bold: boolean }
15export type Drawn = {
16 v: ReturnType<typeof view>
17 image?: { rgba: string; width: number; height: number; key: string }
18 text?: Seg[][]
19 labels: Label[]
20 hit: (x: number, y: number) => string // cell coordinates, fractions allowed
21 centre: (id: string) => [number, number] | undefined
22}
23
24let cache = { key: '', pos: new Map() as Pos }
25let picture = { key: '', rgba: '' }
26
27export function draw(g: Graph, vi: ViewInput, cam: Cam, cols: number, rows: number, pixels: boolean, hover: string, touched: Set<string>): Drawn {
28 const v = view(g, vi)
29 const ids = v.nodes.map(n => n.id)
30 const key = `${vi.mode}|${vi.scope}|${vi.local ? vi.focus : ''}|${vi.depth}|${vi.levels}|${ids.length}|${v.edges.length}|${ids.slice(0, 50).join(',')}`
31 if (cache.key !== key) cache = { key, pos: layout(ids, v.edges.map(e => [e.a, e.b]), 220, cache.pos) }
32 const pos = cache.pos
33 const groups = [...new Set(g.files.map(n => n.group))].sort()
34 const colorOf = (n: VNode) => (touched.has(n.file) ? '#f5a623' : n.kind === 'outside' ? MUTED : PALETTE[Math.max(0, groups.indexOf(n.group)) % PALETTE.length]!)
35 const focused = v.seeds.length > 0
36 const seeds = new Set(v.seeds)
37
38 const { cw, ch } = pixels ? cellSize(cols, rows) : { cw: 2, ch: 4 }
39 const W = cols * cw
40 const H = rows * ch
41 const S = Math.min(W, H) * (pixels ? 0.92 : 1) // the braille fallback projects as raster() does
42 const proj = (id: string): [number, number] | undefined => {
43 const p = pos.get(id)
44 return p && [(p[0] - cam.cx) * cam.z * S + W / 2, (p[1] - cam.cy) * cam.z * S + H / 2]
45 }
46 const maxDeg = Math.max(1, ...ids.map(v.deg))
47 const radius = (n: VNode) => (cw / 6) * (2.2 + 5 * Math.sqrt(v.deg(n.id) / maxDeg)) * (seeds.has(n.id) ? 1.4 : 1)
48
49 // Labels: the focus and its ring nearest first, then the hubs; never overlapping.
50 const taken = new Set<string>()
51 const labels: Label[] = []
52 const ranked = [...v.nodes].sort((a, b) =>
53 Number(b.id === hover) - Number(a.id === hover) ||
54 (v.dist.get(a.id) ?? 99) - (v.dist.get(b.id) ?? 99) ||
55 v.deg(b.id) - v.deg(a.id))
56 const budget = focused ? 30 : 14
57 for (const n of ranked) {
58 if (labels.length >= budget && n.id !== hover) break
59 if (focused && !v.dist.has(n.id) && n.id !== hover) continue
60 const p = proj(n.id)
61 if (!p) continue
62 const cx = Math.floor(p[0] / cw)
63 const cy = Math.floor(p[1] / ch)
64 const text = n.label
65 for (const from of [cx + 2, cx - 1 - text.length]) {
66 if (cy < 0 || cy >= rows || from < 0 || from + text.length > cols) continue
67 const cells = [...Array(text.length).keys()].map(i => `${from + i},${cy}`)
68 if (cells.some(c => taken.has(c))) continue
69 for (const c of cells) taken.add(c)
70 const color = seeds.has(n.id) ? '#ffffff' : v.out.has(n.id) ? OUT : v.inn.has(n.id) ? IN : n.id === hover ? '#ffffff' : n.kind === 'outside' ? MUTED : '#c8c8d0'
71 labels.push({ x: from, y: cy, text, color, bold: seeds.has(n.id) || n.id === hover })
72 break
73 }
74 }
75
76 const hit = (x: number, y: number) => {
77 let best = ''
78 let bestD = Infinity
79 for (const n of v.nodes) {
80 const p = proj(n.id)
81 if (!p) continue
82 const d = Math.hypot(p[0] - x * cw, p[1] - y * ch)
83 if (d < bestD && d < Math.max(radius(n) + cw, cw * 2.5)) { best = n.id; bestD = d }
84 }
85 return best
86 }
87 const centre = (id: string) => pos.get(id)
88
89 if (!pixels) {
90 const out = raster(
91 { pos, edges: v.edges.map(e => [e.a, e.b]), focus: v.seeds[0] ?? '', near: new Set(v.dist.keys()), labels: [], names: new Map(), big: new Set() },
92 cols, rows, cam,
93 { color: id => colorOf(v.nodes.find(n => n.id === id)!), edge: '#4a4a52', hot: OUT, dim: '#3a3a3a', label: '#dadada' },
94 )
95 return { v, text: out.rows, labels, hit, centre }
96 }
97
98 const pkey = `${key}|${cam.cx.toFixed(4)},${cam.cy.toFixed(4)},${cam.z.toFixed(3)}|${W}x${H}|${vi.focus}|${[...touched].join(',')}`
99 if (picture.key !== pkey) {
100 const c = new Canvas(W, H)
101 const lw = Math.max(1, cw / 6)
102 const edgeStyle = (a: string, b: string): [RGB, number, number] => {
103 if (!focused) return [hex(MUTED), 0.28, lw]
104 if (seeds.has(a)) return [hex(OUT), 0.95, lw * 1.8]
105 if (seeds.has(b)) return [hex(IN), 0.95, lw * 1.8]
106 if (v.dist.has(a) && v.dist.has(b)) return [hex(MUTED), 0.35, lw]
107 return [hex(MUTED), 0.07, lw]
108 }
109 // Faint links first, the focus's links on top.
110 const order = [...v.edges].sort((e, f) => Number(seeds.has(e.a) || seeds.has(e.b)) - Number(seeds.has(f.a) || seeds.has(f.b)))
111 for (const e of order) {
112 const pa = proj(e.a), pb = proj(e.b)
113 if (!pa || !pb) continue
114 const [col, a, w] = edgeStyle(e.a, e.b)
115 c.line({ x0: pa[0], y0: pa[1], x1: pb[0], y1: pb[1], c: col, a, w: w * (1 + Math.log2(e.w) * 0.5) })
116 }
117 for (const n of v.nodes) {
118 const p = proj(n.id)
119 if (!p || p[0] < -20 || p[1] < -20 || p[0] > W + 20 || p[1] > H + 20) continue
120 const d = v.dist.get(n.id)
121 const a = !focused ? 1 : d === undefined ? 0.18 : d <= 1 ? 1 : 0.7
122 c.dot({ x: p[0], y: p[1], r: radius(n), c: hex(colorOf(n)), a, ring: n.kind === 'outside' || n.kind === 'folder', halo: seeds.has(n.id) })
123 }
124 picture = { key: pkey, rgba: c.base64() }
125 }
126 return { v, image: { rgba: picture.rgba, width: W, height: H, key: picture.key }, labels, hit, centre }
127}
128hooks/paint.ts 80 lines1// Paints the graph into RGBA pixels for an `Image` (kitty graphics: Ghostty, cmux,
2// kitty). Transparent background, so the terminal's own theme shows through.
3
4export type RGB = [number, number, number]
5export type Line = { x0: number; y0: number; x1: number; y1: number; c: RGB; a: number; w: number }
6export type Dot = { x: number; y: number; r: number; c: RGB; a: number; ring?: boolean; halo?: boolean }
7
8export function hex(h: string): RGB {
9 const v = parseInt(h.slice(1), 16)
10 return [(v >> 16) & 255, (v >> 8) & 255, v & 255]
11}
12
13export class Canvas {
14 W: number
15 H: number
16 px: Uint8ClampedArray
17 constructor(W: number, H: number) {
18 this.W = W
19 this.H = H
20 this.px = new Uint8ClampedArray(W * H * 4)
21 }
22
23 // "Over" compositing on straight alpha.
24 blend(x: number, y: number, c: RGB, a: number) {
25 if (a <= 0 || x < 0 || y < 0 || x >= this.W || y >= this.H) return
26 const i = (y * this.W + x) * 4
27 const da = this.px[i + 3]! / 255
28 const oa = a + da * (1 - a)
29 for (let k = 0; k < 3; k++) this.px[i + k] = (c[k]! * a + this.px[i + k]! * da * (1 - a)) / oa
30 this.px[i + 3] = oa * 255
31 }
32
33 // A line `w` pixels wide with soft edges. It walks the longer axis and covers a
34 // short run across it, so each pixel is touched once and long lines stay cheap.
35 line(l: Line) {
36 const steep = Math.abs(l.y1 - l.y0) > Math.abs(l.x1 - l.x0)
37 // Work as if the line were flat: swap x and y for a steep one.
38 let [a0, b0, a1, b1] = steep ? [l.y0, l.x0, l.y1, l.x1] : [l.x0, l.y0, l.x1, l.y1]
39 if (a0 > a1) [a0, b0, a1, b1] = [a1, b1, a0, b0]
40 const slope = a1 - a0 < 1e-6 ? 0 : (b1 - b0) / (a1 - a0)
41 const cos = 1 / Math.sqrt(1 + slope * slope)
42 const half = l.w / 2
43 const lim = (steep ? this.H : this.W) - 1
44 for (let a = Math.max(0, Math.floor(a0)); a <= Math.min(lim, Math.ceil(a1)); a++) {
45 const end = Math.min(1, Math.max(0, Math.min(a + 1 - a0, a1 - a + 0.5))) // soft caps
46 const bc = b0 + (Math.max(a0, Math.min(a1, a + 0.5)) - a0) * slope
47 for (let b = Math.floor(bc - half - 1); b <= Math.ceil(bc + half + 1); b++) {
48 const d = Math.abs(b + 0.5 - bc) * cos
49 const cover = Math.max(0, Math.min(1, half + 0.5 - d)) * end
50 if (cover > 0) steep ? this.blend(b, a, l.c, cover * l.a) : this.blend(a, b, l.c, cover * l.a)
51 }
52 }
53 }
54
55 dot(d: Dot) {
56 const R = d.r + (d.halo ? d.r * 1.2 : 0) + 1
57 for (let y = Math.floor(d.y - R); y <= Math.ceil(d.y + R); y++) {
58 for (let x = Math.floor(d.x - R); x <= Math.ceil(d.x + R); x++) {
59 const dist = Math.hypot(x + 0.5 - d.x, y + 0.5 - d.y)
60 if (d.halo) this.blend(x, y, d.c, Math.max(0, Math.min(1, d.r * 2.2 - dist)) * 0.18 * d.a)
61 const cover = d.ring
62 ? Math.max(0, Math.min(1, 1.6 - Math.abs(dist - d.r)))
63 : Math.max(0, Math.min(1, d.r + 0.5 - dist))
64 this.blend(x, y, d.c, cover * d.a)
65 }
66 }
67 }
68
69 base64(): string {
70 return (new Uint8Array(this.px.buffer) as Uint8Array & { toBase64(): string }).toBase64()
71 }
72}
73
74// Pixels per cell for a box of `cols`×`rows` cells under the inline image limit
75// (2 MiB of RGBA); a cell is about twice as tall as wide.
76export function cellSize(cols: number, rows: number, budget = 2 * 1024 * 1024 / 4) {
77 const cw = Math.max(3, Math.min(16, Math.floor(Math.sqrt(budget / (cols * rows * 2)))))
78 return { cw, ch: cw * 2 }
79}
80hooks/view.ts 109 lines1// What the Graph tab shows: the nodes and links for a mode, a folder scope, and
2// a focus with its neighbourhood up to a depth. Pure, so it's easy to test.
3import type { Graph } from './scan'
4
5export type Mode = 'folders' | 'files' | 'functions'
6export type VNode = { id: string; label: string; group: string; kind: 'file' | 'fn' | 'folder' | 'outside'; file: string }
7export type VEdge = { a: string; b: string; w: number }
8export type ViewInput = { mode: Mode; scope: string; focus: string; depth: number; levels: number; local: boolean; orphans: boolean; cap: number }
9
10const inside = (scope: string, f: string) => !scope || f.startsWith(scope + '/')
11const top = (f: string) => (f.includes('/') ? f.split('/')[0]! : '.')
12
13// Where a path outside the scope is drawn: the folder where it leaves the scope's
14// path, so a sibling folder is one node and a far one is its top-level folder.
15export function bucket(scope: string, f: string): string {
16 const s = scope ? scope.split('/') : []
17 const p = f.split('/')
18 let i = 0
19 while (i < s.length && p[i] === s[i]) i++
20 return p.slice(0, i + 1).join('/')
21}
22
23// Inside the scope, folders mode draws each folder `levels` deep below the scope
24// as one node; files that sit higher up stay single nodes.
25function child(scope: string, f: string, levels: number): { id: string; label: string; folder: boolean } {
26 const rel = scope ? f.slice(scope.length + 1) : f
27 const dirs = rel.split('/').slice(0, -1)
28 if (!dirs.length) return { id: f, label: rel, folder: false }
29 const sub = dirs.slice(0, levels).join('/')
30 return { id: (scope ? scope + '/' : '') + sub, label: sub + '/', folder: true }
31}
32
33export function view(g: Graph, v: ViewInput) {
34 const base = v.mode === 'functions' ? g.fns : g.files
35 const baseEdges = v.mode === 'functions' ? g.fnEdges : g.fileEdges
36 const byId = new Map(base.map(n => [n.id, n]))
37 const fileOf = new Map(base.map(n => [n.id, n.file]))
38 const nodes = new Map<string, VNode>()
39 const keyOf = (id: string): string => {
40 const file = fileOf.get(id)!
41 if (!inside(v.scope, file)) {
42 const b = bucket(v.scope, file)
43 const key = `out:${b}`
44 if (!nodes.has(key)) nodes.set(key, { id: key, label: b === file ? b : `${b}/`, group: top(file), kind: 'outside', file: b })
45 return key
46 }
47 if (v.mode === 'folders') {
48 const c = child(v.scope, file, v.levels)
49 if (!nodes.has(c.id)) nodes.set(c.id, { id: c.id, label: c.label, group: top(file), kind: c.folder ? 'folder' : 'file', file: c.id })
50 return c.id
51 }
52 const n = byId.get(id)!
53 if (!nodes.has(id)) nodes.set(id, { id, label: n.label, group: n.group, kind: v.mode === 'functions' ? 'fn' : 'file', file })
54 return id
55 }
56 for (const n of base) if (inside(v.scope, n.file)) keyOf(n.id)
57 const weights = new Map<string, number>()
58 for (const [a0, b0] of baseEdges) {
59 const fa = fileOf.get(a0), fb = fileOf.get(b0)
60 if (!fa || !fb || (!inside(v.scope, fa) && !inside(v.scope, fb))) continue
61 const a = keyOf(a0), b = keyOf(b0)
62 if (a !== b) weights.set(`${a}\0${b}`, (weights.get(`${a}\0${b}`) ?? 0) + 1)
63 }
64 const edges: VEdge[] = [...weights].map(([k, w]) => { const [a, b] = k.split('\0') as [string, string]; return { a, b, w } })
65
66 const adj = new Map<string, Set<string>>()
67 const link = (a: string, b: string) => adj.set(a, (adj.get(a) ?? new Set()).add(b))
68 for (const e of edges) { link(e.a, e.b); link(e.b, e.a) }
69 const deg = (id: string) => adj.get(id)?.size ?? 0
70
71 // A focus can be a node, or a file whose functions stand in for it.
72 const seeds = !v.focus ? [] : nodes.has(v.focus) ? [v.focus] : [...nodes.values()].filter(n => n.file === v.focus).map(n => n.id)
73 const dist = new Map(seeds.map(s => [s, 0]))
74 let ring = seeds
75 for (let d = 1; d <= v.depth; d++) {
76 ring = ring.flatMap(id => [...(adj.get(id) ?? [])]).filter(id => !dist.has(id))
77 for (const id of ring) dist.set(id, d)
78 }
79
80 let ids = [...nodes.keys()]
81 let hidden = 0
82 if (v.local && seeds.length) ids = ids.filter(id => dist.has(id))
83 else {
84 const before = ids.length
85 ids = ids.filter(id => v.orphans || deg(id) > 0 || dist.has(id))
86 hidden = before - ids.length
87 if (ids.length > v.cap) {
88 const keep = new Set([...ids].sort((a, b) => deg(b) - deg(a)).slice(0, v.cap))
89 for (const id of dist.keys()) keep.add(id)
90 ids = ids.filter(id => keep.has(id))
91 }
92 }
93 const shown = new Set(ids)
94 const seedSet = new Set(seeds)
95 const out = new Set(edges.filter(e => seedSet.has(e.a) && !seedSet.has(e.b)).map(e => e.b))
96 const inn = new Set(edges.filter(e => seedSet.has(e.b) && !seedSet.has(e.a)).map(e => e.a))
97 return {
98 nodes: ids.map(id => nodes.get(id)!),
99 edges: edges.filter(e => shown.has(e.a) && shown.has(e.b)),
100 total: nodes.size,
101 hidden,
102 seeds,
103 dist,
104 deg,
105 out,
106 inn,
107 }
108}
109hooks/canvas.tsx 43 lines1import type { ClientModule } from 'claude-code'
2import type { Seg } from './layout'
3
4// Draws rows of colour runs and reports keys, clicks and drags to the mod.
5export type CanvasProps = { rows: Seg[][] }
6type Local = { down: [number, number] | null; last: [number, number] | null; moved: boolean }
7
8const Canvas: ClientModule<CanvasProps, Local> = (props, surface) => {
9 const { Box, Text } = surface.elements
10 if (surface.state === undefined) surface.setState({ down: null, last: null, moved: false })
11 surface.onPointer(e => {
12 const cur = surface.state ?? { down: null, last: null, moved: false }
13 if (e.type === 'down' && (e.button ?? 'left') === 'left') {
14 surface.setState({ down: [e.x, e.y], last: [e.x, e.y], moved: false })
15 } else if (e.type === 'move' && e.button && cur.last) {
16 const [lx, ly] = cur.last
17 if (e.x !== lx || e.y !== ly) {
18 surface.post({ drag: [e.x - lx, e.y - ly] })
19 surface.setState({ ...cur, last: [e.x, e.y], moved: true })
20 }
21 } else if (e.type === 'up' || e.type === 'leave') {
22 if (e.type === 'up' && cur.down && !cur.moved) surface.post({ click: cur.down })
23 surface.setState({ down: null, last: null, moved: false })
24 }
25 })
26 surface.onKey(e => surface.post({ key: e.key, shift: Boolean(e.shift) }))
27 return (
28 <Box flexDirection="column">
29 {props.rows.map(row => (
30 <Box flexDirection="row" height={1} overflow="hidden">
31 {row.map(s => (
32 <Text color={s.c} backgroundColor={s.bg} bold={s.b}>
33 {s.t}
34 </Text>
35 ))}
36 </Box>
37 ))}
38 </Box>
39 )
40}
41
42export default Canvas
43hooks/overlay.tsx 41 lines1import type { ClientModule } from 'claude-code'
2
3// An empty box laid over the graph picture: it draws nothing and reports the
4// pointer (with sub-cell precision where the terminal gives it) and the keys.
5export type OverlayProps = { cols: number; rows: number }
6type Local = { last: [number, number] | null; hover: string }
7
8const Overlay: ClientModule<OverlayProps, Local> = (props, surface) => {
9 const { Box } = surface.elements
10 if (surface.state === undefined) surface.setState({ last: null, hover: '' })
11 // Presses, drags and releases go to the mod as they happen; it decides what a
12 // click is, so nothing here depends on state surviving between events.
13 surface.onPointer(e => {
14 const cur = surface.state ?? { last: null, hover: '' }
15 const at: [number, number] = [e.fine?.x ?? e.x + 0.5, e.fine?.y ?? e.y + 0.5]
16 if (e.type === 'down' && (e.button ?? 'left') === 'left') {
17 surface.post({ press: at })
18 surface.setState({ ...cur, last: at })
19 } else if (e.type === 'move' && e.button) {
20 const [lx, ly] = cur.last ?? at
21 if (Math.abs(at[0] - lx) + Math.abs(at[1] - ly) >= 0.5) {
22 surface.post({ drag: [at[0] - lx, at[1] - ly] })
23 surface.setState({ ...cur, last: at })
24 }
25 } else if (e.type === 'move') {
26 const cell = `${e.x},${e.y}`
27 if (cell !== cur.hover) {
28 surface.post({ hover: at })
29 surface.setState({ ...cur, hover: cell })
30 }
31 } else if (e.type === 'up') {
32 surface.post({ release: true })
33 surface.setState({ ...cur, last: null })
34 } else if (e.type === 'leave') surface.post({ hover: null })
35 })
36 surface.onKey(e => surface.post({ key: e.key }))
37 return <Box width={props.cols} height={props.rows} />
38}
39
40export default Overlay
41