Your repo as a live force-directed starfield in braille: files are stars, imports are faint links, and Claude's attention is a comet that flies to every file…


Watch the MP4 · Screenshot · Screenshot
Your repo as a live, force-directed starfield in braille, inside Claude Code.
src/ui) is its own star system.@/ or ~/ read as src/), Python (relative and package imports) and Kotlin/Java (matched by package path) are parsed. Swift, Go, Rust and the rest still get stars and systems, but no links./galaxy open the galaxy (scans the repo the first time)
/galaxy rescan rescan after big changes
In the pane: click it to give it the keyboard, then
| Key / mouse | Does |
|---|---|
| ← ↑ ↓ → or drag | pan |
+ / - | zoom |
0 | fit everything again |
f | follow the comet |
r | re-heat the layout |
| hover a star | show its path |
| click a star | copy its path |
Big repos are capped at 600 stars, picked round-robin across systems so every one shows up, plus up to 100 more for files Claude touches. The scan skips node_modules, build output, caches, dotfolders and symlinked folders, and stops at fixed budgets; a status of of 2001+ means the scan was cut short. Surfaces without interactive views (mobile, VS Code) get a text chart of the systems instead.
/plugin marketplace add ccdwyer/claude-mods
/plugin install codebase-galaxy@ccdwyer-mods
/reload-plugins
claude plugin validate .
claude plugin test .
It runs entirely on your machine and sends nothing over the network. It lists your repo's folders and reads up to a few hundred source files only to find import links, and runs no programs.
Full policy: PRIVACY.md.
Events this mod hooks, as claude plugin validate reads the module:
session.startcommand.run{command=galaxy}tool.callui.messageui.render{component=Pane, requestId=codebase-galaxy}Engine calls it makes: $.command.register, $.fs.exists, $.fs.list, $.fs.read, $.session.cwd, $.state.get, $.state.set, $.ui.copy, $.ui.open, $.ui.resolve, $.ui.toast.
A tool.call hook sits in the middle of every tool call: it can see the call, refuse it, or add context to its result. This mod only watches successful calls to move the comet; it never changes or refuses one.
MIT
hooks/register.tsx 346 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Attention, Graph } from '../types'
5import { addStar, buildGraph, clusterKey, inDrawnTree, isDrawable, langName, normalize, PARSED, pickFiles, repoPath, SKIP_DIRS } from './galaxy'
6import type { ScannedFile } from './galaxy'
7
8const PANE = 'codebase-galaxy'
9const graph = atom({ plugin: 'codebase-galaxy', key: 'graph' } as const, null)
10const events = atom({ plugin: 'codebase-galaxy', key: 'events' } as const, [])
11const status = atom({ plugin: 'codebase-galaxy', key: 'status' } as const, '')
12const seqNo = atom({ plugin: 'codebase-galaxy', key: 'seq' } as const, 0)
13
14// Bounds that keep a scan quick on a huge repo.
15const MAX_STARS = 600
16// Stars Claude's own reads and edits may add past the scan's cap.
17const MAX_ATTENTION_STARS = 700
18const MAX_DIRS = 3000
19const MAX_QUEUE = 6000
20const MAX_CANDIDATES = 12_000
21const MAX_PER_DIR = 300
22const MAX_PARSE = 450
23const MAX_PARSE_BYTES = 200_000
24const MAX_EVENTS = 24
25
26async function findRoot($: EngineInterface): Promise<string> {
27 const cwd = await $.session.cwd()
28 let dir = cwd
29 for (let up = 0; up < 25; up += 1) {
30 if (await $.fs.exists(`${dir}/.git`)) return dir
31 const parent = dir.slice(0, dir.lastIndexOf('/'))
32 if (parent === '' || parent === dir) break
33 dir = parent
34 }
35 return cwd
36}
37
38type Walk = { files: ScannedFile[]; total: number; truncated: boolean }
39
40// Walk the repo through $.fs, breadth first. Skips dependency and build folders and symlinked folders (which
41// can leave the repo or loop). Every subfolder of a listing is queued whatever its size, so one huge folder
42// can't keep the walk from the rest; each folder adds at most MAX_PER_DIR files; and candidates are kept per
43// system, the fullest giving way, so a system found late still gets its first stars.
44async function walk($: EngineInterface, root: string): Promise<Walk> {
45 const buckets = new Map<string, ScannedFile[]>()
46 const queue: string[] = ['']
47 let count = 0
48 let dirs = 0
49 let total = 0
50 let truncated = false
51 const add = (f: ScannedFile) => {
52 const key = clusterKey(f.path)
53 const mine = buckets.get(key) ?? []
54 if (count >= MAX_CANDIDATES) {
55 let fullest: ScannedFile[] = mine
56 for (const b of buckets.values()) if (b.length > fullest.length) fullest = b
57 // Only take room from a system with more than this one would have.
58 if (fullest.length <= mine.length + 1) return false
59 fullest.pop()
60 count -= 1
61 }
62 mine.push(f)
63 buckets.set(key, mine)
64 count += 1
65 return true
66 }
67 while (queue.length > 0) {
68 if (dirs >= MAX_DIRS) {
69 truncated = true
70 break
71 }
72 const rel = queue.shift() as string
73 dirs += 1
74 let entries: { name: string; kind: string; size: number; isLink?: boolean }[] = []
75 try {
76 entries = await $.fs.list(rel === '' ? root : `${root}/${rel}`)
77 } catch {
78 continue
79 }
80 let here = 0
81 for (const entry of entries) {
82 const path = rel === '' ? entry.name : `${rel}/${entry.name}`
83 if (entry.kind === 'dir') {
84 if (entry.isLink === true || SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue
85 if (queue.length >= MAX_QUEUE) truncated = true
86 else queue.push(path)
87 } else if (entry.kind === 'file' && isDrawable(path)) {
88 total += 1
89 if (here >= MAX_PER_DIR || !add({ path, size: entry.size })) {
90 truncated = true
91 continue
92 }
93 here += 1
94 }
95 }
96 }
97 return { files: [...buckets.values()].flat(), total, truncated }
98}
99
100async function readSource($: EngineInterface, root: string, rel: string, size: number): Promise<string | null> {
101 if (size > MAX_PARSE_BYTES || !PARSED.has(langName(rel))) return null
102 try {
103 return await $.fs.read(`${root}/${rel}`)
104 } catch {
105 return null
106 }
107}
108
109async function scan($: EngineInterface, rev: number): Promise<Graph> {
110 const root = await findRoot($)
111 const found = await walk($, root)
112 const picked = pickFiles(found.files, MAX_STARS)
113 const texts = new Map<string, string>()
114 for (const f of picked) {
115 if (texts.size >= MAX_PARSE) break
116 const text = await readSource($, root, f.path, f.size)
117 if (text !== null) texts.set(f.path, text)
118 }
119 return buildGraph(root, picked, texts, found.total, found.truncated, rev)
120}
121
122async function rescan($: EngineInterface): Promise<Graph | null> {
123 const prev = await read($, graph)
124 await update($, status, () => 'scanning the repo…')
125 try {
126 const scanned = await scan($, 0)
127 // The scan took a while: build on the graph as it is now. Its revision moves past anything the view
128 // has seen, and stars Claude's reads added during the scan are kept.
129 const g = await update($, graph, cur => {
130 let merged: Graph = { ...scanned, rev: Math.max(cur?.rev ?? 0, prev?.rev ?? 0) + 1 }
131 if (cur !== null && cur.root === scanned.root) {
132 const before = new Set(prev?.files ?? [])
133 for (const f of cur.files) {
134 if (!before.has(f) && !merged.files.includes(f)) merged = { ...addStar(merged, f, null, MAX_ATTENTION_STARS), rev: merged.rev }
135 }
136 }
137 return merged
138 })
139 await update($, events, () => [])
140 await update($, status, () => '')
141 return g
142 } catch {
143 await update($, status, () => (prev === null ? 'scan failed: try /galaxy rescan' : 'rescan failed: showing the last scan'))
144 return null
145 }
146}
147
148// The repo root with symlinks resolved, so a file read through a link that leaves the repo is never read.
149let realRoot: { root: string; real: string } | null = null
150
151async function resolvedRoot($: EngineInterface, root: string): Promise<string> {
152 if (realRoot?.root === root) return realRoot.real
153 let real = root
154 try {
155 real = (await $.fs.stat(root, { resolve: true })).realPath ?? root
156 } catch {
157 // Keep the lexical root.
158 }
159 realRoot = { root, real }
160 return real
161}
162
163// The text of a file Claude touched that the scan left out, for its import links: only when it can still
164// become a star, it really lives inside the repo, and it is small enough to parse. `ok` is false when the
165// file must not become a star at all (it leaves the repo, or the sky is full).
166async function attentionSource($: EngineInterface, g: Graph, rel: string): Promise<{ ok: boolean; text: string | null }> {
167 if (g.files.length >= MAX_ATTENTION_STARS) return { ok: false, text: null }
168 const real = await resolvedRoot($, g.root)
169 try {
170 const stat = await $.fs.stat(`${g.root}/${rel}`, { resolve: true })
171 const where = stat.realPath ?? `${g.root}/${rel}`
172 if (!where.startsWith(`${real}/`)) return { ok: false, text: null }
173 return { ok: true, text: await readSource($, g.root, rel, stat.size) }
174 } catch {
175 return { ok: false, text: null }
176 }
177}
178
179// Paths a shell command names, quote-aware, each resolved from the folder it runs in: simple `cd`/`pushd`
180// steps earlier in the same command are followed. A command with substitutions or subshells names nothing,
181// since a wrong guess is worse than a miss.
182export function commandPaths(command: string, cwd: string): string[] {
183 if (/\$\(|`|(^|[;&|]\s*)\(/.test(command)) return []
184 const out: string[] = []
185 let dir = cwd
186 for (const seg of command.split(/&&|\|\||;|\||\n/)) {
187 const words = seg.match(/"[^"]*"|'[^']*'|[^\s'"]+/g) ?? []
188 const plain = words.map(w => w.replace(/^["']|["']$/g, ''))
189 if ((plain[0] === 'cd' || plain[0] === 'pushd') && plain.length === 2) {
190 const target = plain[1] as string
191 const next = normalize(target.startsWith('/') ? target : `${dir}/${target}`)
192 if (next !== null) dir = next
193 continue
194 }
195 for (const w of plain.slice(1)) {
196 if (w === '' || w.startsWith('-') || w.includes('=') || !/[./]/.test(w)) continue
197 out.push(w.startsWith('/') ? w : `${dir}/${w}`)
198 if (out.length >= 40) return out
199 }
200 }
201 return out
202}
203
204// The shell's own folder: Claude Code's Bash keeps a `cd` between calls, which the session's cwd doesn't follow.
205let shellDir: string | null = null
206
207// Record attention on files. Sequence numbers come from their own counter, which never restarts.
208async function attend($: EngineInterface, rev: number, hits: number[], kind: Attention['kind']): Promise<void> {
209 if (hits.length === 0) return
210 const last = await update($, seqNo, n => n + hits.length)
211 const first = last - hits.length + 1
212 const fresh = hits.map((file, i) => ({ seq: first + i, rev, file, kind }))
213 await update($, events, list => [...list, ...fresh].sort((a, b) => a.seq - b.seq).slice(-MAX_EVENTS))
214}
215
216export const register: Register = on => {
217 on('session.start', async ($, e, next) => {
218 shellDir = null
219 await $.command.register({
220 name: 'galaxy',
221 description: 'Codebase Galaxy: your repo as a live starfield; Claude is the comet',
222 argumentHint: '[rescan]',
223 immediate: true,
224 })
225 return next(e)
226 })
227
228 on('command.run', { command: 'galaxy' }, async ($, e) => {
229 const current = await read($, graph)
230 const root = await findRoot($)
231 const fresh = e.args.trim() === 'rescan' || current === null || current.root !== root
232 await $.ui.open({ id: PANE, title: 'Codebase Galaxy', rows: 28 })
233 const g = fresh ? await rescan($) : current
234 if (g === null) return { text: 'Codebase Galaxy: the scan failed; try /galaxy rescan.' }
235 const capped = g.total > g.files.length || g.truncated ? ` (of ${g.total}${g.truncated ? '+' : ''})` : ''
236 return { text: `Codebase Galaxy: ${g.files.length}${capped} stars, ${g.edges.length / 2} links, ${g.dirs.length} systems.` }
237 })
238
239 on('tool.call', async ($, e, next) => {
240 const ran = await next(e)
241 if (ran.deny !== undefined || ran.isError === true) return ran
242 try {
243 const g0 = await read($, graph)
244 if (g0 === null) return ran
245 const call = e as { tool: string; [k: string]: unknown }
246 const cwd = await $.session.cwd()
247 if (call.tool === 'Read' || call.tool === 'Edit' || call.tool === 'Write' || call.tool === 'NotebookEdit') {
248 const raw = call.file_path ?? call.notebook_path
249 const rel = typeof raw === 'string' ? repoPath(g0.root, cwd, raw) : null
250 if (rel === null) return ran
251 let hit = g0.files.indexOf(rel)
252 let g = g0
253 // A file the scan left out (past the cap, or new) gets a star of its own.
254 if (hit === -1 && inDrawnTree(rel)) {
255 const source = await attentionSource($, g0, rel)
256 if (source.ok) {
257 g = (await update($, graph, cur => (cur === null || cur.root !== g0.root ? cur : addStar(cur, rel, source.text, MAX_ATTENTION_STARS)))) ?? g0
258 hit = g.files.indexOf(rel)
259 }
260 }
261 if (hit !== -1) await attend($, g.rev, [hit], call.tool === 'Read' ? 'read' : 'edit')
262 } else if (call.tool === 'Bash' && typeof call.command === 'string') {
263 const here = shellDir ?? cwd
264 const hits: number[] = []
265 const note = (abs: string) => {
266 const rel = repoPath(g0.root, here, abs)
267 const i = rel === null ? -1 : g0.files.indexOf(rel)
268 if (i !== -1 && !hits.includes(i)) hits.push(i)
269 }
270 // Files the command actually changed come first; then the paths it names.
271 const result = ran.result as { bashEditDiff?: { changedFiles?: string[] } } | undefined
272 for (const f of result?.bashEditDiff?.changedFiles ?? []) if (hits.length < 3) note(f)
273 for (const p of commandPaths(call.command, here)) if (hits.length < 3) note(p)
274 await attend($, g0.rev, hits, 'run')
275 // A plain `cd` that succeeded moves the shell for the next command.
276 const only = call.command.trim().match(/^cd\s+("[^"]+"|'[^']+'|\S+)$/)
277 if (only !== null) {
278 const target = (only[1] as string).replace(/^["']|["']$/g, '')
279 shellDir = normalize(target.startsWith('/') ? target : `${here}/${target}`) ?? shellDir
280 }
281 }
282 } catch {
283 // Drawing is never worth failing a tool call over.
284 }
285 return ran
286 })
287
288 on('ui.message', async ($, e, next) => {
289 const data = e.data as { copy?: unknown } | null
290 const g = await read($, graph)
291 if (g !== null && data !== null && typeof data === 'object' && typeof data.copy === 'string' && g.files.includes(data.copy)) {
292 await $.ui.copy({ text: data.copy, surface: e.surface })
293 $.ui.toast(`Copied ${data.copy}`)
294 }
295 return next(e)
296 })
297
298 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
299 const { Box, Text } = $.ui.resolve(e)
300 const g = await read($, graph)
301 const evs = await read($, events)
302 const note = await read($, status)
303 const name = g === null ? '' : g.root.slice(g.root.lastIndexOf('/') + 1)
304 const header = (
305 <Box key="hdr" flexDirection="row">
306 <Text color="#ff3df0" bold>
307 ◉ CODEBASE GALAXY{' '}
308 </Text>
309 <Text color="#5ef1ff">{name}</Text>
310 <Text dimColor> {note}</Text>
311 </Box>
312 )
313 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
314 // Surfaces without a Client get a text chart of the systems instead.
315 const systems = g === null ? [] : g.dirs.map((d, i) => ({ d: d.replace(/[\u0000-\u001f\u007f-\u009f]/g, '?'), n: g.cluster.filter(c => c === i).length }))
316 return (
317 <Box flexDirection="column">
318 {header}
319 {g === null && <Text dimColor>No galaxy yet: run /galaxy to scan the repo.</Text>}
320 {systems.slice(0, 20).map(s => (
321 <Text key={`sys-${s.d}`}>
322 {s.d.padEnd(24)} {'★'.repeat(Math.min(40, Math.ceil(s.n / 3)))} {s.n}
323 </Text>
324 ))}
325 </Box>
326 )
327 }
328 const { Client } = $.ui.resolve(e)
329 // Inline, the pane's body follows its content's height, so sizing the Client from bodyRows
330 // would feed back into itself and collapse it: take a share of the viewport instead.
331 // One body budget for header + Client: no taller than the open request (28) or the surface,
332 // about half the surface otherwise, and never under 6 so a short terminal still fits.
333 const vp = e.viewport?.rows ?? 40
334 const inlineBody = Math.min(28, vp, Math.max(6, Math.floor(vp * 0.5)))
335 const rows = e.props.placement === 'inline'
336 ? inlineBody - 1
337 : Math.max(1, (e.props.scroll?.bodyRows ?? vp) - 1)
338 return (
339 <Box flexDirection="column">
340 {header}
341 <Client key="galaxy" module="./view.tsx" props={{ graph: g, events: evs }} width="100%" height={rows} />
342 </Box>
343 )
344 })
345}
346hooks/galaxy.ts 714 lines1// The galaxy's pure core: languages, paths, import parsing, graph building, the
2// force layout and the braille renderer. No `$` here, so the hooks module, the
3// Client surface module and the tests all share it.
4
5import type { Graph } from '../types'
6
7// ---------------------------------------------------------------- languages
8
9export type Lang = { name: string; color: number; exts: string[] }
10
11export const LANGS: Lang[] = [
12 { name: 'TypeScript', color: 0x3fb7ff, exts: ['ts', 'tsx', 'mts', 'cts'] },
13 { name: 'JavaScript', color: 0xffd93f, exts: ['js', 'jsx', 'mjs', 'cjs'] },
14 { name: 'Python', color: 0x6bff8f, exts: ['py', 'pyi'] },
15 { name: 'Swift', color: 0xff7a3d, exts: ['swift', 'm', 'mm'] },
16 { name: 'Kotlin/Java', color: 0xc77dff, exts: ['kt', 'kts', 'java'] },
17 { name: 'Go', color: 0x5ef1ff, exts: ['go'] },
18 { name: 'Rust', color: 0xff5d5d, exts: ['rs'] },
19 { name: 'Styles', color: 0xff6ad5, exts: ['css', 'scss', 'sass', 'less'] },
20 { name: 'C/C++', color: 0x8ab4ff, exts: ['c', 'h', 'cc', 'cpp', 'hpp'] },
21 { name: 'Ruby', color: 0xff4f81, exts: ['rb'] },
22 { name: 'Shell', color: 0xa3e635, exts: ['sh', 'bash', 'zsh'] },
23 { name: 'Data', color: 0x9ee6d6, exts: ['json', 'yaml', 'yml', 'toml', 'xml', 'plist', 'gradle', 'ipynb'] },
24 { name: 'Docs', color: 0x9aa0a6, exts: ['md', 'mdx', 'txt', 'rst'] },
25]
26const OTHER = LANGS.length
27const BY_EXT = new Map<string, number>()
28LANGS.forEach((l, i) => l.exts.forEach(x => BY_EXT.set(x, i)))
29
30// Languages whose imports become links.
31export const PARSED = new Set(['TypeScript', 'JavaScript', 'Python', 'Kotlin/Java'])
32
33export function extOf(path: string): string {
34 const base = path.slice(path.lastIndexOf('/') + 1)
35 const dot = base.lastIndexOf('.')
36 return dot <= 0 ? '' : base.slice(dot + 1).toLowerCase()
37}
38
39export function langOf(path: string): number {
40 return BY_EXT.get(extOf(path)) ?? OTHER
41}
42
43export function langName(path: string): string {
44 return LANGS[langOf(path)]?.name ?? ''
45}
46
47export function colorOf(lang: number): number {
48 return LANGS[lang]?.color ?? 0x7f8c8d
49}
50
51// Directories never worth drawing: dependencies, build output, caches, VCS.
52export const SKIP_DIRS = new Set([
53 '.git', 'node_modules', 'dist', 'build', 'out', '.next', '.nuxt', '.expo', '.turbo', '.cache', 'coverage',
54 'Pods', 'DerivedData', '.gradle', 'target', 'vendor', '.venv', 'venv', '__pycache__', '.idea', '.vscode',
55 '.claude-plugin', '.svelte-kit', 'bower_components', '.parcel-cache', '.serverless', 'tmp', '.tox',
56])
57
58// Files that are noise as stars.
59export function isDrawable(path: string): boolean {
60 const base = path.slice(path.lastIndexOf('/') + 1)
61 if (base.startsWith('.')) return false
62 if (/\.(lock|map|min\.js|snap|png|jpe?g|gif|webp|ico|svg|pdf|zip|gz|mp4|mov|ttf|otf|woff2?|jar|class|o|a|so|dylib)$/i.test(base)) return false
63 if (base === 'package-lock.json' || base === 'yarn.lock' || base === 'pnpm-lock.yaml') return false
64 return langOf(path) !== OTHER
65}
66
67// A repo-relative path whose folders are all ones the walk would enter.
68export function inDrawnTree(rel: string): boolean {
69 const segs = rel.split('/')
70 for (let i = 0; i < segs.length - 1; i += 1) {
71 const s = segs[i] as string
72 if (s === '' || s.startsWith('.') || SKIP_DIRS.has(s)) return false
73 }
74 return isDrawable(rel)
75}
76
77// ---------------------------------------------------------------- paths
78
79// Lexical normalisation: `.`, `..` and repeated slashes; null when `..` climbs
80// past the start of a relative path.
81export function normalize(path: string): string | null {
82 const abs = path.startsWith('/')
83 const out: string[] = []
84 for (const seg of path.split('/')) {
85 if (seg === '' || seg === '.') continue
86 if (seg === '..') {
87 if (out.length === 0) {
88 if (abs) continue
89 return null
90 }
91 out.pop()
92 } else out.push(seg)
93 }
94 return (abs ? '/' : '') + out.join('/')
95}
96
97// The repo-relative path a tool or a shell token means, or null when it is
98// outside the repo. Relative paths are taken from `cwd`; a trailing
99// `:line` or `:line:col` (as in `src/a.ts:40`) is dropped.
100export function repoPath(root: string, cwd: string, raw: string): string | null {
101 if (raw === '') return null
102 const bare = raw.replace(/:\d+(?::\d+)?$/, '')
103 const abs = bare.startsWith('/') ? bare : `${cwd}/${bare}`
104 const norm = normalize(abs)
105 if (norm === null || !norm.startsWith(`${root}/`)) return null
106 return norm.slice(root.length + 1)
107}
108
109// The cluster a file belongs to: its first two folders (`src/ui`), or the top
110// folder for files directly inside it, or `.` for files at the root.
111export function clusterKey(path: string): string {
112 const segs = path.split('/')
113 if (segs.length <= 1) return '.'
114 if (segs.length === 2) return segs[0] as string
115 return `${segs[0]}/${segs[1]}`
116}
117
118export function topDir(path: string): string {
119 const slash = path.indexOf('/')
120 return slash === -1 ? '.' : path.slice(0, slash)
121}
122
123// ---------------------------------------------------------------- graph
124
125export type ScannedFile = { path: string; size: number }
126
127// Pick at most `cap` files, round-robin across clusters so every system
128// survives the cap on a big repo.
129export function pickFiles(files: ScannedFile[], cap: number): ScannedFile[] {
130 const sorted = [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
131 if (sorted.length <= cap) return sorted
132 const groups = new Map<string, ScannedFile[]>()
133 for (const f of sorted) {
134 const key = clusterKey(f.path)
135 const g = groups.get(key)
136 if (g) g.push(f)
137 else groups.set(key, [f])
138 }
139 const lists = [...groups.values()]
140 const out: ScannedFile[] = []
141 for (let round = 0; out.length < cap; round += 1) {
142 let any = false
143 for (const list of lists) {
144 const f = list[round]
145 if (f === undefined) continue
146 any = true
147 out.push(f)
148 if (out.length >= cap) break
149 }
150 if (!any) break
151 }
152 return out
153}
154
155// Imports sit at the top of a file; parsing more is wasted work.
156const PARSE_HEAD = 64_000
157
158// Linear by construction: no two adjacent repeats can both match the same
159// characters, so a run of whitespace never backtracks quadratically.
160const JS_SPEC = /\b(?:from|import|require)\s*(?:\(\s*)?['"]([^'"\n]{1,300})['"]/g
161const PY_FROM = /^[ \t]*from[ \t]+([.\w]+)[ \t]+import[ \t]+([\w., \t]+)/gm
162// `from . import (\n util,\n core, # comment\n)`: one bounded run up to the closing paren.
163const PY_FROM_PAREN = /^[ \t]*from[ \t]+([.\w]+)[ \t]+import[ \t]*\(([^)]{0,4000})\)/gm
164const PY_IMPORT = /^[ \t]*import[ \t]+([\w., \t]+)/gm
165const JVM_IMPORT = /^[ \t]*import[ \t]+(?:static[ \t]+)?([\w.]+)/gm
166
167export type ImportSpec = { spec: string; names?: string[] }
168
169// Import specifiers a file names, by language. Cheap regexes, not parsers.
170export function importsOf(path: string, text: string): ImportSpec[] {
171 const lang = langName(path)
172 const head = text.length > PARSE_HEAD ? text.slice(0, PARSE_HEAD) : text
173 const out: ImportSpec[] = []
174 const each = (re: RegExp, fn: (m: RegExpExecArray) => void) => {
175 re.lastIndex = 0
176 for (let m = re.exec(head); m !== null; m = re.exec(head)) fn(m)
177 }
178 if (lang === 'TypeScript' || lang === 'JavaScript') {
179 each(JS_SPEC, m => {
180 if (m[1]) out.push({ spec: m[1] })
181 })
182 } else if (lang === 'Python') {
183 each(PY_FROM, m => {
184 if (!m[1]) return
185 const names = (m[2] ?? '').split(',').map(s => s.trim().split(/\s+/)[0] ?? '').filter(Boolean)
186 out.push({ spec: m[1], names })
187 })
188 each(PY_FROM_PAREN, m => {
189 if (!m[1]) return
190 const body = (m[2] ?? '').split('\n').map(line => line.replace(/#.*$/, '')).join(' ')
191 const names = body.split(',').map(s => s.trim().split(/\s+/)[0] ?? '').filter(n => /^\w+$/.test(n))
192 out.push({ spec: m[1], names })
193 })
194 each(PY_IMPORT, m => {
195 for (const part of (m[1] ?? '').split(',')) {
196 const name = part.trim().split(/\s+/)[0]
197 if (name) out.push({ spec: name })
198 }
199 })
200 } else if (lang === 'Kotlin/Java') {
201 each(JVM_IMPORT, m => {
202 if (m[1]) out.push({ spec: m[1] })
203 })
204 }
205 return out
206}
207
208const JS_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts']
209const JVM_EXTS = ['.kt', '.kts', '.java']
210
211export type FileIndex = {
212 byPath: Map<string, number>
213 // Path without extension → files, for JVM package-suffix lookups.
214 byStem: Map<string, number[]>
215}
216
217export function indexFiles(files: string[]): FileIndex {
218 const byPath = new Map<string, number>()
219 const byStem = new Map<string, number[]>()
220 files.forEach((p, i) => {
221 byPath.set(p, i)
222 if (langName(p) !== 'Kotlin/Java') return
223 const stem = p.slice(p.lastIndexOf('/') + 1).replace(/\.(kt|kts|java)$/, '')
224 const list = byStem.get(stem)
225 if (list) list.push(i)
226 else byStem.set(stem, [i])
227 })
228 return { byPath, byStem }
229}
230
231// Where a Python module lives: beside the importer's root, or in a src-layout package. A bare `src/<name>.py`
232// is not a candidate for a top-level name, so `import os` never links to a file that happens to be src/os.py.
233function pyCandidates(rel: string): string[] {
234 if (rel === '') return []
235 const out = [`${rel}.py`, `${rel}/__init__.py`, `src/${rel}/__init__.py`]
236 if (rel.includes('/')) out.push(`src/${rel}.py`)
237 return out
238}
239
240// The files an import points at, if they are ours.
241export function resolveImport(from: string, imp: ImportSpec, idx: FileIndex, files: string[]): number[] {
242 const dir = from.includes('/') ? from.slice(0, from.lastIndexOf('/')) : ''
243 const lang = langName(from)
244 const spec = imp.spec
245 const hit = (p: string) => idx.byPath.get(p)
246 if (lang === 'TypeScript' || lang === 'JavaScript') {
247 let base: string | null = null
248 if (spec.startsWith('.')) base = normalize(dir === '' ? spec : `${dir}/${spec}`)
249 else if (spec.startsWith('@/') || spec.startsWith('~/')) base = normalize(`src/${spec.slice(2)}`)
250 if (base === null || base === '') return []
251 const stem = base.replace(/\.(js|jsx|mjs|cjs)$/, '')
252 // The file itself first, then a folder's index, as Node and bundlers do.
253 for (const b of [base, stem]) {
254 const exact = hit(b)
255 if (exact !== undefined && b === base) return [exact]
256 for (const x of JS_EXTS) {
257 const f = hit(b + x)
258 if (f !== undefined) return [f]
259 }
260 }
261 for (const x of JS_EXTS) {
262 const f = hit(`${stem}/index${x}`)
263 if (f !== undefined) return [f]
264 }
265 return []
266 }
267 if (lang === 'Python') {
268 let base: string | null
269 let leading = 0
270 if (spec.startsWith('.')) {
271 leading = spec.length - spec.replace(/^\.+/, '').length
272 let d: string | null = dir
273 for (let i = 1; i < leading && d !== null; i += 1) d = d === '' ? null : normalize(`${d}/..`)
274 base = d
275 } else base = ''
276 if (base === null) return []
277 const rest = spec.slice(leading).replace(/\./g, '/')
278 const mod = rest === '' ? base : base === '' ? rest : `${base}/${rest}`
279 const found: number[] = []
280 for (const p of pyCandidates(mod)) {
281 const f = hit(p)
282 if (f !== undefined) {
283 found.push(f)
284 break
285 }
286 }
287 // `from . import util` or `from pkg import mod`: each name may be a module.
288 for (const name of imp.names ?? []) {
289 for (const p of pyCandidates(mod === '' ? name : `${mod}/${name}`)) {
290 const f = hit(p)
291 if (f !== undefined) {
292 found.push(f)
293 break
294 }
295 }
296 }
297 return found
298 }
299 if (lang === 'Kotlin/Java') {
300 const parts = spec.split('.')
301 // `import static a.b.Bar.baz`: the class is the last capitalised segment.
302 while (parts.length > 1 && /^[a-z_]/.test(parts[parts.length - 1] as string)) parts.pop()
303 const cls = parts[parts.length - 1]
304 if (cls === undefined) return []
305 const suffix = parts.join('/')
306 for (const i of idx.byStem.get(cls) ?? []) {
307 const p = files[i] as string
308 if (JVM_EXTS.some(x => p === `${suffix}${x}` || p.endsWith(`/${suffix}${x}`))) return [i]
309 }
310 return []
311 }
312 return []
313}
314
315// Links found in one file's text, capped per file so a barrel file that
316// re-exports everything can't take the whole edge budget.
317export function linksOf(path: string, text: string, from: number, idx: FileIndex, files: string[], perFile = 24): number[] {
318 const out: number[] = []
319 const seen = new Set<number>()
320 for (const imp of importsOf(path, text)) {
321 for (const to of resolveImport(path, imp, idx, files)) {
322 if (to === from || seen.has(to)) continue
323 seen.add(to)
324 out.push(from, to)
325 if (out.length >= perFile * 2) return out
326 }
327 }
328 return out
329}
330
331// Assemble the graph from picked files and the text of the ones we parsed.
332export function buildGraph(root: string, picked: ScannedFile[], texts: Map<string, string>, total: number, truncated = false, rev = 1, maxEdges = 1600): Graph {
333 const files = picked.map(f => f.path)
334 const idx = indexFiles(files)
335 const dirs: string[] = []
336 const dirIndex = new Map<string, number>()
337 const cluster = files.map(p => {
338 const key = clusterKey(p)
339 let i = dirIndex.get(key)
340 if (i === undefined) {
341 i = dirs.length
342 dirs.push(key)
343 dirIndex.set(key, i)
344 }
345 return i
346 })
347 const maxSize = Math.max(1, ...picked.map(f => f.size))
348 const mass = picked.map(f => Math.round((Math.log1p(f.size) / Math.log1p(maxSize)) * 100) / 100)
349 // Round-robin one link per file per pass, so the budget spreads across files.
350 const perFile: number[][] = []
351 for (const [path, text] of texts) {
352 const from = idx.byPath.get(path)
353 if (from === undefined) continue
354 const links = linksOf(path, text, from, idx, files)
355 if (links.length > 0) perFile.push(links)
356 }
357 const edges: number[] = []
358 for (let k = 0; edges.length < maxEdges * 2; k += 2) {
359 let any = false
360 for (const links of perFile) {
361 if (k + 1 >= links.length) continue
362 any = true
363 edges.push(links[k] as number, links[k + 1] as number)
364 if (edges.length >= maxEdges * 2) break
365 }
366 if (!any) break
367 }
368 return { rev, root, files, lang: files.map(langOf), mass, cluster, dirs, edges, total, truncated }
369}
370
371// A file Claude touched that the scan left out becomes a star, its imports
372// becoming links when we have its text. Returns the graph unchanged when full.
373export function addStar(g: Graph, rel: string, text: string | null, cap: number): Graph {
374 if (g.files.includes(rel) || g.files.length >= cap || !inDrawnTree(rel)) return g
375 const key = clusterKey(rel)
376 let c = g.dirs.indexOf(key)
377 const dirs = c === -1 ? [...g.dirs, key] : g.dirs
378 if (c === -1) c = dirs.length - 1
379 const files = [...g.files, rel]
380 const at = files.length - 1
381 let edges = g.edges
382 if (text !== null && PARSED.has(langName(rel))) edges = [...g.edges, ...linksOf(rel, text, at, indexFiles(files), files, 12)]
383 return {
384 ...g,
385 rev: g.rev + 1,
386 files,
387 lang: [...g.lang, langOf(rel)],
388 mass: [...g.mass, 0.3],
389 cluster: [...g.cluster, c],
390 dirs,
391 edges,
392 total: g.total + 1,
393 }
394}
395
396// ---------------------------------------------------------------- layout
397
398export type Layout = {
399 x: Float32Array
400 y: Float32Array
401 vx: Float32Array
402 vy: Float32Array
403 alpha: number
404}
405
406// A stable 0..1 hash of a string, so a repo always opens in the same shape.
407export function hash01(s: string): number {
408 let h0 = 2166136261
409 for (let i = 0; i < s.length; i += 1) {
410 h0 ^= s.charCodeAt(i)
411 h0 = Math.imul(h0, 16777619)
412 }
413 return ((h0 >>> 0) % 100000) / 100000
414}
415
416// A system's place in the sky comes from its own name, never its index or how many systems there are, so a
417// new folder on a rescan doesn't pull every other system to a new spot.
418export function clusterCenter(key: string, count: number): [number, number] {
419 if (count <= 1) return [0, 0]
420 const a = hash01(key) * Math.PI * 2
421 const r = 40 + 110 * Math.sqrt(hash01(`${key}#orbit`))
422 return [Math.cos(a) * r, Math.sin(a) * r]
423}
424
425// Positions for a graph, carrying stars over by file name from `prev` (whose
426// files were `prevFiles`), so a rescan or a new star doesn't scramble the sky.
427export function seedLayout(g: Graph, prev?: Layout, prevFiles?: string[]): Layout {
428 const n = g.files.length
429 const l: Layout = { x: new Float32Array(n), y: new Float32Array(n), vx: new Float32Array(n), vy: new Float32Array(n), alpha: 1 }
430 const old = new Map<string, number>()
431 if (prev && prevFiles) prevFiles.forEach((p, i) => old.set(p, i))
432 let kept = 0
433 for (let i = 0; i < n; i += 1) {
434 const name = g.files[i] ?? ''
435 const j = old.get(name)
436 if (prev && j !== undefined && j < prev.x.length) {
437 l.x[i] = prev.x[j] as number
438 l.y[i] = prev.y[j] as number
439 kept += 1
440 continue
441 }
442 const [cx, cy] = clusterCenter(g.dirs[g.cluster[i] ?? 0] ?? '.', g.dirs.length)
443 const a = hash01(name) * Math.PI * 2
444 const r = 4 + hash01(`${name}#r`) * 14
445 l.x[i] = cx + Math.cos(a) * r
446 l.y[i] = cy + Math.sin(a) * r
447 }
448 // Mostly the same stars: a gentle reheat instead of a full re-cooling.
449 if (prev && kept > 0) l.alpha = kept === n ? prev.alpha : Math.max(prev.alpha, 0.3)
450 return l
451}
452
453const CUTOFF = 20
454const CELL = CUTOFF
455
456// One cooling step of the force layout: repulsion between nearby stars (on a
457// uniform grid, so it stays linear), springs along imports, and a pull toward
458// each system's centre. Returns whether anything moved.
459export function stepLayout(g: Graph, l: Layout): boolean {
460 if (l.alpha < 0.015) return false
461 const n = Math.min(g.files.length, l.x.length)
462 const { x, y, vx, vy } = l
463 const a = l.alpha
464 const grid = new Map<number, number[]>()
465 const keyOf = (gx: number, gy: number) => gx * 73856093 + gy * 19349663
466 for (let i = 0; i < n; i += 1) {
467 const k = keyOf(Math.floor((x[i] as number) / CELL), Math.floor((y[i] as number) / CELL))
468 const bucket = grid.get(k)
469 if (bucket) bucket.push(i)
470 else grid.set(k, [i])
471 }
472 for (let i = 0; i < n; i += 1) {
473 const xi = x[i] as number
474 const yi = y[i] as number
475 const gx = Math.floor(xi / CELL)
476 const gy = Math.floor(yi / CELL)
477 for (let ox = -1; ox <= 1; ox += 1) {
478 for (let oy = -1; oy <= 1; oy += 1) {
479 const bucket = grid.get(keyOf(gx + ox, gy + oy))
480 if (bucket === undefined) continue
481 for (const j of bucket) {
482 if (j <= i) continue
483 let dx = xi - (x[j] as number)
484 let dy = yi - (y[j] as number)
485 let d2 = dx * dx + dy * dy
486 if (d2 > CUTOFF * CUTOFF) continue
487 if (d2 < 0.01) {
488 dx = 0.1 * ((i % 3) - 1) + 0.05
489 dy = 0.1 * ((j % 3) - 1) + 0.05
490 d2 = dx * dx + dy * dy
491 }
492 const f = ((g.cluster[i] === g.cluster[j] ? 10 : 30) * a) / d2
493 vx[i] = (vx[i] as number) + dx * f
494 vy[i] = (vy[i] as number) + dy * f
495 vx[j] = (vx[j] as number) - dx * f
496 vy[j] = (vy[j] as number) - dy * f
497 }
498 }
499 }
500 }
501 const e = g.edges
502 for (let k = 0; k + 1 < e.length; k += 2) {
503 const i = e[k] as number
504 const j = e[k + 1] as number
505 if (i >= n || j >= n) continue
506 const dx = (x[j] as number) - (x[i] as number)
507 const dy = (y[j] as number) - (y[i] as number)
508 const d = Math.sqrt(dx * dx + dy * dy) || 0.01
509 const f = ((d - 10) / d) * 0.02 * a
510 vx[i] = (vx[i] as number) + dx * f
511 vy[i] = (vy[i] as number) + dy * f
512 vx[j] = (vx[j] as number) - dx * f
513 vy[j] = (vy[j] as number) - dy * f
514 }
515 let moved = 0
516 for (let i = 0; i < n; i += 1) {
517 const [cx, cy] = clusterCenter(g.dirs[g.cluster[i] ?? 0] ?? '.', g.dirs.length)
518 vx[i] = ((vx[i] as number) + (cx - (x[i] as number)) * 0.04 * a) * 0.82
519 vy[i] = ((vy[i] as number) + (cy - (y[i] as number)) * 0.04 * a) * 0.82
520 x[i] = (x[i] as number) + (vx[i] as number)
521 y[i] = (y[i] as number) + (vy[i] as number)
522 moved += Math.abs(vx[i] as number) + Math.abs(vy[i] as number)
523 }
524 l.alpha *= 0.985
525 return moved > 0.001
526}
527
528export function bounds(l: Layout): [number, number, number, number] {
529 let x0 = Infinity
530 let y0 = Infinity
531 let x1 = -Infinity
532 let y1 = -Infinity
533 for (let i = 0; i < l.x.length; i += 1) {
534 const px = l.x[i] as number
535 const py = l.y[i] as number
536 if (px < x0) x0 = px
537 if (px > x1) x1 = px
538 if (py < y0) y0 = py
539 if (py > y1) y1 = py
540 }
541 if (!Number.isFinite(x0)) return [-1, -1, 1, 1]
542 return [x0, y0, x1, y1]
543}
544
545// ---------------------------------------------------------------- braille canvas
546
547// Bits of a braille cell, indexed [row 0..3][col 0..1].
548const DOTS = [
549 [0x01, 0x08],
550 [0x02, 0x10],
551 [0x04, 0x20],
552 [0x40, 0x80],
553]
554
555export type Canvas = {
556 cols: number
557 rows: number
558 // Per pixel (2 per cell across, 4 down): packed colour and brightness 0..1.
559 color: Uint32Array
560 light: Float32Array
561}
562
563export function makeCanvas(cols: number, rows: number): Canvas {
564 const n = Math.max(0, cols * 2) * Math.max(0, rows * 4)
565 return { cols, rows, color: new Uint32Array(n), light: new Float32Array(n) }
566}
567
568export function clearCanvas(c: Canvas): void {
569 c.color.fill(0)
570 c.light.fill(0)
571}
572
573export function plot(c: Canvas, px: number, py: number, color: number, light: number): void {
574 const x = Math.round(px)
575 const y = Math.round(py)
576 const w = c.cols * 2
577 if (!(x >= 0 && y >= 0 && x < w && y < c.rows * 4)) return
578 const i = y * w + x
579 if (light > (c.light[i] as number)) {
580 c.light[i] = light
581 c.color[i] = color
582 }
583}
584
585// Clip a segment to the box [0,w]x[0,h] (Liang–Barsky); null when it misses.
586export function clip(x0: number, y0: number, x1: number, y1: number, w: number, h: number): [number, number, number, number] | null {
587 if (![x0, y0, x1, y1].every(Number.isFinite)) return null
588 const dx = x1 - x0
589 const dy = y1 - y0
590 let t0 = 0
591 let t1 = 1
592 const p = [-dx, dx, -dy, dy]
593 const q = [x0, w - x0, y0, h - y0]
594 for (let k = 0; k < 4; k += 1) {
595 const pk = p[k] as number
596 const qk = q[k] as number
597 if (pk === 0) {
598 if (qk < 0) return null
599 continue
600 }
601 const r = qk / pk
602 if (pk < 0) {
603 if (r > t1) return null
604 if (r > t0) t0 = r
605 } else {
606 if (r < t0) return null
607 if (r < t1) t1 = r
608 }
609 }
610 return [x0 + t0 * dx, y0 + t0 * dy, x0 + t1 * dx, y0 + t1 * dy]
611}
612
613export function line(c: Canvas, x0: number, y0: number, x1: number, y1: number, color: number, light: number): void {
614 const w = c.cols * 2
615 const hgt = c.rows * 4
616 const seg = clip(x0, y0, x1, y1, w - 1, hgt - 1)
617 if (seg === null) return
618 let ax = Math.round(seg[0])
619 let ay = Math.round(seg[1])
620 const bx = Math.round(seg[2])
621 const by = Math.round(seg[3])
622 const dx = Math.abs(bx - ax)
623 const dy = -Math.abs(by - ay)
624 const sx = ax < bx ? 1 : -1
625 const sy = ay < by ? 1 : -1
626 let err = dx + dy
627 // A clipped segment is at most the canvas diagonal long.
628 for (let guard = w + hgt + 4; guard > 0; guard -= 1) {
629 // Every other pixel, so links read as faint dotted threads.
630 if ((ax + ay) % 2 === 0) plot(c, ax, ay, color, light)
631 if (ax === bx && ay === by) break
632 const e2 = 2 * err
633 if (e2 >= dy) {
634 err += dy
635 ax += sx
636 }
637 if (e2 <= dx) {
638 err += dx
639 ay += sy
640 }
641 }
642}
643
644// Braille pixels are square (a cell is twice as tall as wide, with four rows
645// of dots to two columns), so a circle needs no aspect correction.
646export function ring(c: Canvas, cx: number, cy: number, r: number, color: number, light: number): void {
647 const steps = Math.max(8, Math.round(r * 6))
648 for (let k = 0; k < steps; k += 1) {
649 const t = (k / steps) * Math.PI * 2
650 plot(c, cx + Math.cos(t) * r, cy + Math.sin(t) * r, color, light)
651 }
652}
653
654export function scale(color: number, k: number): number {
655 const f = Math.max(0, Math.min(1, k))
656 const r = Math.round(((color >> 16) & 0xff) * f)
657 const g = Math.round(((color >> 8) & 0xff) * f)
658 const b = Math.round((color & 0xff) * f)
659 return (r << 16) | (g << 8) | b
660}
661
662export type Run = { text: string; color: string | null }
663
664function hex(color: number): string {
665 return `#${color.toString(16).padStart(6, '0')}`
666}
667
668// Four brightness levels, so neighbouring cells share a colour and merge into
669// one run instead of one element per cell.
670const LEVELS = [0.4, 0.6, 0.8, 1]
671function level(light: number): number {
672 return LEVELS[Math.min(LEVELS.length - 1, Math.floor(light * LEVELS.length))] as number
673}
674
675// Collapse the pixel canvas to braille cells, one row at a time, as runs of
676// same-coloured text.
677export function toRuns(c: Canvas): Run[][] {
678 const w = c.cols * 2
679 const out: Run[][] = []
680 for (let row = 0; row < c.rows; row += 1) {
681 const runs: Run[] = []
682 let text = ''
683 let color: string | null = null
684 for (let col = 0; col < c.cols; col += 1) {
685 let bits = 0
686 let best = 0
687 let bestColor = 0
688 for (let dy = 0; dy < 4; dy += 1) {
689 for (let dx = 0; dx < 2; dx += 1) {
690 const i = (row * 4 + dy) * w + col * 2 + dx
691 const l = c.light[i] as number
692 if (l <= 0.02) continue
693 bits |= (DOTS[dy] as number[])[dx] as number
694 if (l > best) {
695 best = l
696 bestColor = c.color[i] as number
697 }
698 }
699 }
700 const ch = bits === 0 ? ' ' : String.fromCharCode(0x2800 + bits)
701 const cc: string | null = bits === 0 ? color : hex(scale(bestColor, level(best)))
702 if (cc !== color && text !== '') {
703 runs.push({ text, color })
704 text = ''
705 }
706 color = cc
707 text += ch
708 }
709 if (text !== '') runs.push({ text, color })
710 out.push(runs)
711 }
712 return out
713}
714hooks/view.tsx 318 lines1// The galaxy itself: a Client surface module that lays the repo out as stars,
2// animates it on the surface's frame clock and takes keys and the pointer.
3// It runs on the drawing thread with no `$`; it posts to the hooks module.
4
5import type { ClientModule, ClientPointerEvent, ClientSurface, RenderElement } from 'claude-code'
6
7import type { Attention, Graph } from '../types'
8import { bounds, clearCanvas, colorOf, hash01, line, makeCanvas, plot, ring, scale, seedLayout, stepLayout, toRuns } from './galaxy'
9import type { Canvas, Layout } from './galaxy'
10
11export type ViewProps = { graph: Graph | null; events: Attention[] }
12
13type Flare = { kind: Attention['kind']; age: number }
14
15type View = {
16 graph: Graph | null
17 layout: Layout | null
18 canvas: Canvas | null
19 cam: { cx: number; cy: number; zoom: number; auto: boolean; follow: boolean }
20 comet: { x: number; y: number; target: number; trail: number[] }
21 flares: Map<number, Flare>
22 lastSeq: number
23 t: number
24 hover: number
25 drag: { x: number; y: number; moved: boolean } | null
26 stop: (() => void) | null
27}
28
29const FRAME_MS = 33
30const MIN_ZOOM = 0.05
31const MAX_ZOOM = 40
32const FLARE_FRAMES = 45
33const TRAIL = 28
34const KIND_COLOR: Record<Attention['kind'], number> = { edit: 0xffb84d, read: 0x5ef1ff, run: 0x6bff8f }
35
36// Canvas pixel for a world point under the current camera.
37function toPixel(v: View, c: Canvas, wx: number, wy: number): [number, number] {
38 return [(wx - v.cam.cx) * v.cam.zoom + c.cols, (wy - v.cam.cy) * v.cam.zoom + c.rows * 2]
39}
40
41function nearestStar(v: View, c: Canvas, px: number, py: number, radius: number): number {
42 if (v.layout === null) return -1
43 let best = -1
44 let bestD = radius * radius
45 for (let i = 0; i < v.layout.x.length; i += 1) {
46 const [sx, sy] = toPixel(v, c, v.layout.x[i] as number, v.layout.y[i] as number)
47 const d = (sx - px) * (sx - px) + (sy - py) * (sy - py)
48 if (d <= bestD) {
49 bestD = d
50 best = i
51 }
52 }
53 return best
54}
55
56function absorb(v: View, props: ViewProps): void {
57 const next = props.graph
58 if ((next?.rev ?? -1) !== (v.graph?.rev ?? -1) || (next?.root ?? '') !== (v.graph?.root ?? '')) {
59 const sameRoot = next !== null && v.graph !== null && next.root === v.graph.root
60 v.layout = next === null ? null : seedLayout(next, sameRoot ? v.layout ?? undefined : undefined, sameRoot ? v.graph?.files : undefined)
61 // Indices mean different files now: keep the comet on the same file by name.
62 const cometFile = v.graph !== null && v.comet.target >= 0 ? v.graph.files[v.comet.target] : undefined
63 v.comet.target = cometFile !== undefined && next !== null ? next.files.indexOf(cometFile) : -1
64 if (v.comet.target < 0) v.comet.trail = []
65 v.flares.clear()
66 v.hover = -1
67 v.lastSeq = 0
68 v.graph = next
69 }
70 const g = v.graph
71 if (g === null) return
72 for (const ev of props.events ?? []) {
73 // An event about another revision may point at a different file: skip it without moving the cursor,
74 // so a later event of this revision is never mistaken for an old one.
75 if (ev.rev !== g.rev || ev.file < 0 || ev.file >= g.files.length) continue
76 if (ev.seq <= v.lastSeq) continue
77 v.lastSeq = ev.seq
78 v.flares.set(ev.file, { kind: ev.kind, age: 0 })
79 v.comet.target = ev.file
80 }
81}
82
83function tick(v: View): void {
84 v.t += 1
85 const g = v.graph
86 const l = v.layout
87 if (g === null || l === null) return
88 stepLayout(g, l)
89 // The comet eases toward the file Claude last touched, leaving a trail.
90 if (v.comet.target >= 0 && v.comet.target < l.x.length) {
91 const tx = l.x[v.comet.target] as number
92 const ty = l.y[v.comet.target] as number
93 if (v.comet.trail.length === 0) {
94 v.comet.x = tx
95 v.comet.y = ty
96 }
97 v.comet.x += (tx - v.comet.x) * 0.16
98 v.comet.y += (ty - v.comet.y) * 0.16
99 v.comet.trail.push(v.comet.x, v.comet.y)
100 if (v.comet.trail.length > TRAIL * 2) v.comet.trail.splice(0, v.comet.trail.length - TRAIL * 2)
101 }
102 for (const [i, f] of v.flares) {
103 f.age += 1
104 if (f.age > FLARE_FRAMES) v.flares.delete(i)
105 }
106}
107
108function frameCamera(v: View, c: Canvas): void {
109 if (v.layout === null) return
110 if (v.cam.follow && v.comet.trail.length > 0) {
111 v.cam.cx += (v.comet.x - v.cam.cx) * 0.12
112 v.cam.cy += (v.comet.y - v.cam.cy) * 0.12
113 return
114 }
115 if (!v.cam.auto) return
116 const [x0, y0, x1, y1] = bounds(v.layout)
117 const w = Math.max(10, x1 - x0) + 16
118 const hgt = Math.max(10, y1 - y0) + 16
119 const zoom = Math.min((c.cols * 2) / w, (c.rows * 4) / hgt)
120 v.cam.cx += ((x0 + x1) / 2 - v.cam.cx) * 0.2
121 v.cam.cy += ((y0 + y1) / 2 - v.cam.cy) * 0.2
122 v.cam.zoom += (Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom)) - v.cam.zoom) * 0.2
123}
124
125function paint(v: View, c: Canvas): void {
126 clearCanvas(c)
127 const g = v.graph
128 const l = v.layout
129 const w = c.cols * 2
130 const hgt = c.rows * 4
131 // Dust: a faint, slowly drifting field behind everything.
132 const dust = Math.floor((w * hgt) / 140)
133 for (let k = 0; k < dust; k += 1) {
134 const dx = (hash01(`d${k}x`) * w + v.t * 0.05 * (1 + (k % 3))) % w
135 const dy = hash01(`d${k}y`) * hgt
136 const tw = 0.08 + 0.06 * Math.sin(v.t * 0.05 + k)
137 plot(c, dx, dy, 0x6f7fa8, tw)
138 }
139 if (g === null || l === null) return
140 const n = l.x.length
141 const px = new Float32Array(n)
142 const py = new Float32Array(n)
143 for (let i = 0; i < n; i += 1) {
144 const [sx, sy] = toPixel(v, c, l.x[i] as number, l.y[i] as number)
145 px[i] = sx
146 py[i] = sy
147 }
148 // Links first, so stars sit on top of them.
149 const lit = v.hover >= 0 ? v.hover : v.comet.target
150 for (let k = 0; k + 1 < g.edges.length; k += 2) {
151 const i = g.edges[k] as number
152 const j = g.edges[k + 1] as number
153 if (i >= n || j >= n) continue
154 const hot = i === lit || j === lit
155 const col = scale(colorOf(g.lang[i] ?? 0), hot ? 0.9 : 0.4)
156 line(c, px[i] as number, py[i] as number, px[j] as number, py[j] as number, col, hot ? 0.55 : 0.16)
157 }
158 for (let i = 0; i < n; i += 1) {
159 const m = g.mass[i] ?? 0.3
160 const twinkle = 0.85 + 0.15 * Math.sin(v.t * 0.07 + i * 1.7)
161 const flare = v.flares.get(i)
162 const boost = flare ? 1 - flare.age / FLARE_FRAMES : 0
163 const col = boost > 0.5 ? 0xffffff : colorOf(g.lang[i] ?? 0)
164 const light = Math.min(1, (0.45 + 0.55 * m) * twinkle + boost)
165 const x = px[i] as number
166 const y = py[i] as number
167 plot(c, x, y, col, light)
168 if (m > 0.7 || boost > 0) {
169 plot(c, x + 1, y, col, light * 0.6)
170 plot(c, x - 1, y, col, light * 0.6)
171 plot(c, x, y + 1, col, light * 0.6)
172 plot(c, x, y - 1, col, light * 0.6)
173 }
174 if (flare) ring(c, x, y, 2 + (flare.age / FLARE_FRAMES) * 9, KIND_COLOR[flare.kind], 0.9 * (1 - flare.age / FLARE_FRAMES))
175 }
176 // The comet and its fading tail.
177 const tr = v.comet.trail
178 for (let k = 0; k + 1 < tr.length; k += 2) {
179 const [sx, sy] = toPixel(v, c, tr[k] as number, tr[k + 1] as number)
180 plot(c, sx, sy, 0xbfefff, 0.1 + 0.8 * (k / tr.length))
181 }
182 if (tr.length > 0) {
183 const [hx, hy] = toPixel(v, c, v.comet.x, v.comet.y)
184 plot(c, hx, hy, 0xffffff, 1)
185 plot(c, hx + 1, hy, 0xffffff, 0.8)
186 plot(c, hx, hy + 1, 0xffffff, 0.8)
187 }
188 if (v.hover >= 0 && v.hover < n) ring(c, px[v.hover] as number, py[v.hover] as number, 3, 0xffffff, 0.7)
189}
190
191// Terminal text can't carry control characters (a newline or ESC in a file name would refuse the tree).
192const printable = (s: string) => s.replace(/[\u0000-\u001f\u007f-\u009f]/g, '?')
193
194function status(v: View): string {
195 const g = v.graph
196 if (g === null) return ' no galaxy yet: /galaxy scans the repo'
197 const links = g.edges.length / 2
198 const capped = g.total > g.files.length ? ` of ${g.total}` : ''
199 const focus = v.hover >= 0 ? g.files[v.hover] : v.comet.target >= 0 ? `→ ${g.files[v.comet.target]}` : ''
200 const mode = v.cam.follow ? 'follow' : v.cam.auto ? 'fit' : 'free'
201 return printable(` ★ ${g.files.length}${capped} stars · ${links} links · ${g.dirs.length} systems · ${v.cam.zoom.toFixed(1)}x ${mode} ${focus ?? ''}`)
202}
203
204function onPointer(v: View, s: ClientSurface<View>, e: ClientPointerEvent): void {
205 const c = v.canvas
206 if (c === null || v.graph === null) return
207 const fx = (e.fine?.x ?? e.x + 0.5) * 2
208 const fy = (e.fine?.y ?? e.y + 0.5) * 4
209 if (e.type === 'down' && e.button === 'left') {
210 v.drag = { x: fx, y: fy, moved: false }
211 } else if (e.type === 'move' && v.drag !== null) {
212 const dx = fx - v.drag.x
213 const dy = fy - v.drag.y
214 if (Math.abs(dx) + Math.abs(dy) > 1) {
215 v.cam.cx -= dx / v.cam.zoom
216 v.cam.cy -= dy / v.cam.zoom
217 v.cam.auto = false
218 v.cam.follow = false
219 v.drag = { x: fx, y: fy, moved: true }
220 }
221 } else if (e.type === 'move') {
222 v.hover = nearestStar(v, c, fx, fy, 4)
223 } else if (e.type === 'up') {
224 if (v.drag !== null && !v.drag.moved) {
225 const hit = nearestStar(v, c, fx, fy, 4)
226 v.hover = hit
227 const path = hit >= 0 ? v.graph.files[hit] : undefined
228 if (path !== undefined) s.post({ copy: path })
229 }
230 v.drag = null
231 } else if (e.type === 'leave') {
232 // A release outside the region may never arrive: end the drag here.
233 v.hover = -1
234 v.drag = null
235 }
236 s.setState(v)
237}
238
239function onKey(v: View, s: ClientSurface<View>, key: string): void {
240 const span = (v.canvas ? v.canvas.cols * 2 : 80) / Math.max(0.01, v.cam.zoom)
241 const pan = span * 0.15
242 if (key === 'left') v.cam.cx -= pan
243 else if (key === 'right') v.cam.cx += pan
244 else if (key === 'up') v.cam.cy -= pan
245 else if (key === 'down') v.cam.cy += pan
246 else if (key === '+' || key === '=') v.cam.zoom = Math.min(MAX_ZOOM, v.cam.zoom * 1.25)
247 else if (key === '-' || key === '_') v.cam.zoom = Math.max(MIN_ZOOM, v.cam.zoom / 1.25)
248 else if (key === '0') {
249 v.cam.auto = true
250 v.cam.follow = false
251 } else if (key === 'f') v.cam.follow = !v.cam.follow
252 else if (key === 'r' && v.layout !== null) v.layout.alpha = 1
253 else return
254 if (key !== '0' && key !== 'f' && key !== 'r') {
255 v.cam.auto = false
256 if (key.length > 1) v.cam.follow = false
257 }
258 s.setState(v)
259}
260
261const Galaxy: ClientModule<ViewProps, View> = (props, s) => {
262 const { Box, Text } = s.elements
263 let v = s.state
264 if (v === undefined) {
265 v = {
266 graph: null,
267 layout: null,
268 canvas: null,
269 cam: { cx: 0, cy: 0, zoom: 1, auto: true, follow: false },
270 comet: { x: 0, y: 0, target: -1, trail: [] },
271 flares: new Map(),
272 lastSeq: 0,
273 t: 0,
274 hover: -1,
275 drag: null,
276 stop: null,
277 }
278 const view = v
279 view.stop = s.every(FRAME_MS, () => {
280 tick(view)
281 s.setState(view)
282 })
283 s.onPointer(e => onPointer(view, s, e))
284 s.onKey(e => onKey(view, s, e.key))
285 }
286 absorb(v, props)
287 const cols = Math.max(0, s.columns)
288 const rows = Math.max(0, s.rows - 1)
289 if (cols < 4 || rows < 2) {
290 return Text({ color: '#5ef1ff', children: status(v) }) as RenderElement
291 }
292 if (v.canvas === null || v.canvas.cols !== cols || v.canvas.rows !== rows) v.canvas = makeCanvas(cols, rows)
293 frameCamera(v, v.canvas)
294 paint(v, v.canvas)
295 const lines = toRuns(v.canvas).map((runs, r) =>
296 Box({
297 key: `r${r}`,
298 flexDirection: 'row',
299 children: runs.map(run =>
300 run.color === null ? Text({ children: run.text }) : Text({ color: run.color, children: run.text }),
301 ),
302 }),
303 )
304 lines.push(
305 Box({
306 key: 'status',
307 flexDirection: 'row',
308 children: [
309 Text({ color: '#5ef1ff', wrap: 'truncate-end', children: status(v) }),
310 Text({ dimColor: true, wrap: 'truncate-end', children: ' ←↑↓→ pan · +/- zoom · 0 fit · f follow · click copies path' }),
311 ],
312 }),
313 )
314 return Box({ flexDirection: 'column', children: lines }) as RenderElement
315}
316
317export default Galaxy
318types/index.d.ts 37 lines1// The repo as the galaxy draws it. Plain data, so it can travel as a Client's props.
2export type Graph = {
3 // Bumped on every change (a rescan, a new star), so the view notices it.
4 rev: number
5 root: string
6 // Paths relative to root, one per star.
7 files: string[]
8 // Language index per file (see LANGS in hooks/galaxy.ts).
9 lang: number[]
10 // Rough size per file, 0..1, which sets a star's brightness.
11 mass: number[]
12 // Cluster index per file: the system it gravitates to.
13 cluster: number[]
14 dirs: string[]
15 // Import links as flat pairs [from, to, from, to, ...].
16 edges: number[]
17 // Drawable files the scan saw, before the cap.
18 total: number
19 // True when the scan stopped at a budget, so `total` is a lower bound.
20 truncated: boolean
21}
22
23// One moment of Claude's attention on a file of graph revision `rev`.
24export type Attention = { seq: number; rev: number; file: number; kind: 'read' | 'edit' | 'run' }
25
26declare module 'claude-code' {
27 interface PluginState {
28 'codebase-galaxy': {
29 graph: Graph | null
30 events: Attention[]
31 // The last attention sequence number handed out: it only grows, even when a rescan clears the events.
32 seq: number
33 status: string
34 }
35 }
36}
37