SLOPSHOPPER

repograph

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

newpaneguardcommandprocesstimer
★ 1v0.3.1MITupdated 2026-10-16XyndoX/repograph
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · repograph
│ ┃ Repo graph ✕ › fix the failing auth test and add an audit log call │ ┃ Files [ Graph ] Changes ↻ │ ┃ ⌂ repofiles ⇄ ‹ depth 1 › ⏺ Read(src/auth.ts) │ ┃ / : find a file ⏎ focus ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /repograph │ ┃ ⎿ repograph: Repo graph: 1 files · 2 graph · 3 changes. Click the │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Repo graph
Files [ Graph ] Changes ↻ ⌂ repofiles ⇄ ‹ depth 1 › / : find a file ⏎ focus ▣ cli 0 files · 0 links hubs: — click: focus · again: open folder · drag · wheel zoom · [ ]…
README

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

Install

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.

What you get

  • Graph tab: drawn in real pixels in terminals with the kitty graphics protocol (Ghostty, cmux, kitty): soft anti-aliased lines, round nodes, labels as crisp terminal text on top. Elsewhere (tmux, iTerm2) p switches to a braille text drawing.
  • Three levels, 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.
  • Scope: click a folder node twice, press 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.
  • Levels (folders mode): [ ] (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.
  • Focus and depth: click a node and the graph narrows to it and its neighbours. In files and functions mode [ ] 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.
  • Hover a node to see its path; drag to pan, wheel or + - to zoom.
  • Files tab: the repo's folders with link counts per file (← 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.
  • Changes tab: every file changed since the last commit (tracked edits first, untracked folders as one row) with +/- 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.
  • Files Claude edits turn orange, and the graph rescans after the turn.

Keys

KeyDoes
1 / 2 / 3Files / Graph / Changes
click, drag, scrollfocus a node (again: open a folder), pan, zoom
arrows, + -, 0pan, zoom, reset the view
mfolders → files → functions
[ ]folders: how many folder levels; files/functions: rings around the focus
uscope one folder up
gGraph: near the focus ⇄ everything. Files: graph this folder
oshow nodes with no links
ppixels ⇄ braille text
enterGraph: open a focused folder, or show the file in Files. Files: open folder, or show the file in the graph
escclear the focus, then scope up
rrescan

Changes tab:

KeyDoes
↑ ↓ / j k, clickpick a file
J K, pgup pgdn, wheelscroll the diff
space (or click its ●)mark reviewed and go to the next
n / anext unreviewed / mark all reviewed
entershow 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.

How it works

git ls-files, then regexes, no parser and no dependencies:

  • Imports: JS/TS (relative, .js meaning .ts, @/ and ~/ aliases, workspace packages by their package.json name) and Python (absolute and relative). Other languages get function nodes only.
  • Calls: a defined function name followed by ( 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.
  • Layout: a force layout (Fruchterman–Reingold, with hub-weighted links and a near-range repulsion like d3-force) that starts from the last positions, so a new focus moves calmly. The picture is painted in the mod (RGBA, transparent background), written to a temp file the terminal reads itself (so the drawing tree carries a path, not megabytes), and shown with Claude Code's 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.

Test

claude plugin test .

License

MIT

Source 10 files
hooks/register.tsx 596 lines
1import 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}
596
hooks/scan.ts 180 lines
1// 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}
180
hooks/layout.ts 164 lines
1// 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}
164
hooks/tree.ts 58 lines
1// 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}
58
hooks/changes.ts 73 lines
1// 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}
73
hooks/graph.ts 128 lines
1// 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}
128
hooks/paint.ts 80 lines
1// 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}
80
hooks/view.ts 109 lines
1// 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}
109
hooks/canvas.tsx 43 lines
1import 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
43
hooks/overlay.tsx 41 lines
1import 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