A live map of your codebase that lights up where Claude is working: reads glow cyan, edits amber, errors red, each subagent in its own colour.

A live map of your codebase that lights up wherever Claude is working.

A Flightdeck-style pane in rounded, colour-bordered panels:
● scanning light, and how much of the repo this session touched.The map and the sweep line between panels animate on their own, without redrawing the pane, the same in the terminal and the desktop app. Docked beside a fullscreen transcript you get every panel; seated inline above the prompt it shrinks to a short summary.
Requires Claude Code 2.1.293 or later: mods are an early-access feature and gain events with each release. Check with claude --version; update with claude update.
/plugin marketplace add lakmadev/claude-mods
/plugin install repo-radar@claude-mods
/radar opens it; /radar reset clears the session's trail. With openOnStart (on by default), it docks beside the transcript at session start in terminals 144+ columns wide.
The file list (git ls-files, run once at start) and the path argument of each tool call. Nothing leaves your machine, nothing is written, and it never blocks or changes a tool call.
hooks/register.tsx 337 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import { COLORS, agentColor, addPath, buildTree, glowColor, hex, mix, treeData, zoneOf } from './layout'
5import type { Kind, Tree } from './layout'
6import type { MapProps } from './map'
7import type { SweepProps } from './sweep'
8
9const PANE = 'radar'
10const TITLE = 'Repo Radar'
11const READ_TOOLS = ['Read', 'Grep', 'Glob', 'LS', 'NotebookRead']
12const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
13const GLOW_WINDOW_MS = 60_000
14
15// Chrome follows the person's Claude Code theme; the map and agent swatches need fixed colours to mix.
16const C = { main: 'claude', map: 'suggestion', agents: 'merged', zones: 'warning', feed: 'subtle', dim: 'inactive', faint: 'subtle' } as const
17const VERB: Record<Kind, string> = { read: 'read', edit: 'edit', error: 'fail' }
18const SWEEP_TRAIL = [0xffffff, 0xd97757, 0xb8603f, 0x7a4430].map(hex)
19
20const version = atom({ plugin: 'repo-radar', key: 'version' } as const, 0)
21
22type Agent = { index: number; name: string; reads: number; edits: number; last: string }
23type Zone = { reads: number; edits: number; fails: number; at: number }
24
25// ponytail: the session's picture lives in module state; a hot reload starts the map fresh.
26let root = ''
27let repoName = ''
28let branch = ''
29let isGit = false
30let tree: Tree = new Map()
31let fileCount = 0
32let treeVersion = 0
33let isWorking = false
34let fails = 0
35let lastBump = 0
36const hits = new Map<string, { kind: Kind; agent: number; at: number }>()
37const touched = new Map<string, Kind>()
38const agents = new Map<string, Agent>()
39const zones = new Map<string, Zone>()
40const feed: { kind: Kind; path: string; agent: number; at: number }[] = []
41
42export function relative(path: string, base: string): string | undefined {
43 if (!path.startsWith('/')) return path.replace(/^\.\//, '').replace(/\/$/, '') || undefined
44 if (path === base) return undefined
45 return path.startsWith(base + '/') ? path.slice(base.length + 1).replace(/\/$/, '') : undefined
46}
47
48export const gauge = (fraction: number, width = 10) => {
49 const filled = Math.max(fraction > 0 ? 1 : 0, Math.min(width, Math.round(fraction * width)))
50 return '▰'.repeat(filled) + '▱'.repeat(width - filled)
51}
52
53const heat = (z: Zone) => z.edits * 2 + z.reads + z.fails * 3
54const clock = (ms: number) => new Date(ms).toTimeString().slice(0, 8)
55
56function agentFor(id: string | undefined, name?: string): Agent | undefined {
57 if (id === undefined) return undefined
58 const known = agents.get(id) ?? { index: agents.size, name: `agent ${agents.size + 1}`, reads: 0, edits: 0, last: '' }
59 if (name) known.name = name
60 agents.set(id, known)
61 return known
62}
63
64function clear() {
65 hits.clear()
66 touched.clear()
67 agents.clear()
68 zones.clear()
69 feed.length = 0
70 fails = 0
71}
72
73async function bump($: EngineInterface, force = false) {
74 const now = await $.clock.now()
75 if (!force && now - lastBump < 200) return
76 lastBump = now
77 await update($, version, v => v + 1)
78}
79
80async function loadRepo($: EngineInterface) {
81 const cwd = await $.session.cwd()
82 const git = (args: string[], at = cwd) => $.process.run(['git', '-C', at, ...args], { timeoutMs: 10_000 }).catch(() => undefined)
83 const top = await git(['rev-parse', '--show-toplevel'])
84 isGit = top?.exitCode === 0
85 root = isGit ? top!.stdout.trim() : cwd
86 repoName = root.split('/').filter(Boolean).pop() ?? root
87 const head = isGit ? await git(['rev-parse', '--abbrev-ref', 'HEAD'], root) : undefined
88 branch = head?.exitCode === 0 ? head.stdout.trim() : ''
89 const files = isGit ? await git(['ls-files'], root) : undefined
90 const paths = files?.exitCode === 0 ? files.stdout.split('\n').filter(Boolean) : []
91 tree = buildTree(paths)
92 fileCount = paths.length
93 treeVersion += 1
94}
95
96async function record($: EngineInterface, rawPath: string, kind: Kind, agentId: string | undefined) {
97 const path = relative(rawPath, root)
98 if (!path) return
99 const now = await $.clock.now()
100 // A folder the index doesn't know yet (a new file, or no git): give it a tile.
101 const [top, second] = path.split('/')
102 const known = tree.get(top!)
103 if (!known || (path.includes('/') && !known.children.has(second!))) {
104 addPath(tree, path)
105 fileCount += 1
106 treeVersion += 1
107 }
108 const agent = agentFor(agentId)
109 if (agent) {
110 if (kind === 'edit') agent.edits += 1
111 else if (kind === 'read') agent.reads += 1
112 agent.last = zoneOf(path)
113 }
114 const index = agent?.index ?? -1
115 hits.delete(path)
116 hits.set(path, { kind, agent: index, at: now })
117 if (hits.size > 600) hits.delete(hits.keys().next().value!)
118 if (kind === 'error') fails += 1
119 else if (touched.get(path) !== 'edit') touched.set(path, kind)
120 const zone = zones.get(zoneOf(path)) ?? { reads: 0, edits: 0, fails: 0, at: 0 }
121 zones.set(zoneOf(path), { reads: zone.reads + (kind === 'read' ? 1 : 0), edits: zone.edits + (kind === 'edit' ? 1 : 0), fails: zone.fails + (kind === 'error' ? 1 : 0), at: now })
122 feed.unshift({ kind, path, agent: index, at: now })
123 feed.length = Math.min(feed.length, 8)
124 await bump($)
125}
126
127// columns: the dock's width beside a fullscreen transcript; rows: the inline seat above the prompt.
128function openPane($: EngineInterface) {
129 return $.ui.open({ id: PANE, title: TITLE, columns: 64, rows: 8 })
130}
131
132export const register: Register = (on, options) => {
133 on('session.start', async ($, e, next) => {
134 await $.command.register({ name: 'radar', description: 'Repo Radar: a live map of where Claude is working', argumentHint: '[reset]' })
135 await loadRepo($).catch(() => undefined)
136 if (options.openOnStart !== false) openPane($).catch(() => undefined)
137 return next(e)
138 })
139
140 on('command.run', { command: 'radar' }, async ($, e) => {
141 if (e.args.trim() === 'reset') {
142 clear()
143 await loadRepo($).catch(() => undefined)
144 await bump($, true)
145 return { text: 'Radar cleared.' }
146 }
147 await openPane($)
148 return {}
149 })
150
151 on('turn.start', async ($, e, next) => {
152 isWorking = true
153 await bump($, true)
154 return next(e)
155 })
156
157 on('turn.complete', async ($, e, next) => {
158 const done = await next(e)
159 if (e.agentId === undefined) {
160 isWorking = false
161 await bump($, true)
162 }
163 return done
164 })
165
166 on('agent.spawn', async ($, e, next) => {
167 const started = await next(e)
168 if (started.deny === undefined && started.agentId) {
169 agentFor(started.agentId, e.subagentType.split(':').pop())
170 await bump($, true)
171 }
172 return started
173 })
174
175 on('tool.call', async ($, e, next) => {
176 const ran = await next(e)
177 if (ran.deny !== undefined) return ran
178 const tool = String(e.tool)
179 const args = e as unknown as Record<string, unknown>
180 const path = args.file_path ?? args.notebook_path ?? args.path
181 const kind: Kind | undefined = ran.isError ? 'error' : EDIT_TOOLS.includes(tool) ? 'edit' : READ_TOOLS.includes(tool) ? 'read' : undefined
182 if (kind && typeof path === 'string') await record($, path, kind, e.agentId).catch(() => undefined)
183 return ran
184 })
185
186 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
187 const stamp = await read($, version)
188 const { Box, Text } = $.ui.resolve(e)
189 const now = await $.clock.now()
190 const W = Math.max(36, e.props.bodyColumns ?? 64)
191 const inner = W - 4
192 const isCompact = (e.viewport?.rows ?? 40) < 18
193 const edits = [...touched.values()].filter(k => k === 'edit').length
194 const hot = [...zones].sort((a, b) => heat(b[1]) - heat(a[1])).slice(0, 5)
195 const hotMax = Math.max(1, ...hot.map(([, z]) => heat(z)))
196 const zoneColor = (z: Zone) => hex(z.fails ? COLORS.error : z.edits ? COLORS.edit : COLORS.read)
197 const agentName = (index: number) => [...agents.values()].find(a => a.index === index)?.name
198
199 const status = isWorking ? <Text color={C.main} bold>● scanning</Text> : <Text color={C.dim}>○ idle</Text>
200 const panel = (color: string, title: string, right: RenderChildren, body: RenderChildren) => (
201 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} width={W}>
202 <Box justifyContent="space-between">
203 <Text color={color} bold>{title}</Text>
204 {right}
205 </Box>
206 {body}
207 </Box>
208 )
209 const event = (ev: (typeof feed)[number], withTime: boolean) => (
210 <Text wrap="truncate">
211 {withTime && <Text color={C.dim}>{clock(ev.at)} </Text>}
212 <Text color={hex(glowColor(ev))} bold>{VERB[ev.kind].padEnd(5)}</Text>
213 <Text> {ev.path}</Text>
214 {ev.agent >= 0 && <Text color={hex(agentColor(ev.agent))}> · {agentName(ev.agent)}</Text>}
215 </Text>
216 )
217 const counts = (
218 <Text wrap="truncate">
219 <Text color={C.dim}>touched </Text>
220 <Text color={C.main}>{gauge(fileCount ? touched.size / fileCount : 0)} </Text>
221 <Text bold>{touched.size}</Text>
222 <Text color={C.dim}> · </Text>
223 <Text color={hex(COLORS.edit)} bold>{edits}</Text>
224 <Text color={C.dim}> edited · </Text>
225 <Text color={fails ? hex(COLORS.error) : C.dim} bold={fails > 0}>{fails}</Text>
226 <Text color={C.dim}> failed</Text>
227 </Text>
228 )
229
230 // Seated inline above the prompt: the summary, the hottest folders, the latest event.
231 if (isCompact) {
232 return panel(C.main, '◉ REPO RADAR', status, (
233 <Box flexDirection="column">
234 {counts}
235 <Text wrap="truncate">
236 {hot.length === 0 && <Text color={C.dim}>no activity yet</Text>}
237 {hot.slice(0, 3).map(([zone, z], i) => (
238 <Text>
239 {i > 0 && <Text> </Text>}
240 <Text>{zone} </Text>
241 <Text color={zoneColor(z)}>{gauge(heat(z) / hotMax, 4)}</Text>
242 </Text>
243 ))}
244 </Text>
245 {feed[0] ? event(feed[0], false) : <Text color={C.dim}>waiting for Claude to touch a file…</Text>}
246 </Box>
247 ))
248 }
249
250 const mapRows = Math.max(8, Math.min(28, (e.viewport?.rows ?? 40) - 24 - hot.length - (agents.size ? agents.size + 3 : 0)))
251 const mapProps: MapProps = {
252 tree: treeData(tree),
253 treeVersion,
254 hits: [...hits].filter(([, h]) => now - h.at < GLOW_WINDOW_MS).slice(-200).map(([path, h]) => [path, h.kind, h.agent, now - h.at]),
255 tints: [...touched].slice(-400),
256 columns: inner,
257 rows: mapRows,
258 stamp,
259 }
260 const sweepProps: SweepProps = { isActive: isWorking, width: W, trail: SWEEP_TRAIL, dim: hex(mix(COLORS.headerText, COLORS.dark, 0.55)) }
261
262 let map: RenderChildren
263 let sweep: (key: string) => RenderChildren
264 if (e.surface === 'terminal' || e.surface === 'desktop') {
265 const { Client } = $.ui.resolve(e)
266 map = <Client key="map" module="./map.tsx" width={inner} height={mapRows} props={mapProps} />
267 sweep = key => <Client key={key} module="./sweep.tsx" width={W} height={1} props={sweepProps} />
268 } else {
269 map = <Text color={C.dim}>The live map draws in the terminal and the desktop app.</Text>
270 sweep = () => <Text color={C.faint}>{'─'.repeat(W)}</Text>
271 }
272
273 const legend = (
274 <Text>
275 <Text color={hex(COLORS.read)}>■</Text>
276 <Text color={C.dim}> read </Text>
277 <Text color={hex(COLORS.edit)}>■</Text>
278 <Text color={C.dim}> edit </Text>
279 <Text color={hex(COLORS.error)}>■</Text>
280 <Text color={C.dim}> fail</Text>
281 </Text>
282 )
283 const zoneWidth = Math.max(12, inner - 26)
284
285 return (
286 <Box flexDirection="column">
287 {panel(C.main, '◉ REPO RADAR', status, (
288 <Box flexDirection="column">
289 <Text wrap="truncate">
290 <Text bold>{repoName}</Text>
291 {branch && <Text color={C.dim}> · {branch}</Text>}
292 <Text color={C.dim}> · {fileCount.toLocaleString('en-US')} files{isGit ? '' : ' · not a git repo'}</Text>
293 </Text>
294 {counts}
295 </Box>
296 ))}
297 {sweep('sweep-map')}
298 {panel(C.map, 'MAP', legend, map)}
299 {agents.size > 0 && sweep('sweep-agents')}
300 {agents.size > 0 && panel(C.agents, 'AGENTS', <Text color={C.dim}>{agents.size}</Text>, (
301 <Box flexDirection="column">
302 {[...agents.values()].map(a => (
303 <Text wrap="truncate">
304 <Text color={hex(agentColor(a.index))}>■ </Text>
305 <Text bold>{a.name.padEnd(14).slice(0, 14)}</Text>
306 <Text color={C.dim}> {a.reads} read · </Text>
307 <Text color={hex(agentColor(a.index))}>{a.edits} edit</Text>
308 {a.last && <Text color={C.dim}> · {a.last}</Text>}
309 </Text>
310 ))}
311 </Box>
312 ))}
313 {panel(C.zones, 'HOT ZONES', <Text color={C.dim}>{zones.size} folders</Text>, (
314 <Box flexDirection="column">
315 {hot.length === 0 && <Text color={C.dim}>Nothing yet. Folders Claude works in rank here.</Text>}
316 {hot.map(([zone, z]) => (
317 <Text wrap="truncate">
318 <Text color={zoneColor(z)}>{gauge(heat(z) / hotMax, 8)} </Text>
319 <Text>{zone.padEnd(zoneWidth).slice(0, zoneWidth)}</Text>
320 <Text color={C.dim}> {z.reads}r </Text>
321 <Text color={hex(COLORS.edit)}>{z.edits}e</Text>
322 {z.fails > 0 && <Text color={hex(COLORS.error)}> {z.fails}✗</Text>}
323 </Text>
324 ))}
325 </Box>
326 ))}
327 {panel(C.feed, 'FEED', <Text color={C.dim}>/radar reset</Text>, (
328 <Box flexDirection="column">
329 {feed.length === 0 && <Text color={C.dim}>Waiting for Claude to touch a file…</Text>}
330 {feed.slice(0, 6).map(ev => event(ev, true))}
331 </Box>
332 ))}
333 </Box>
334 )
335 })
336}
337hooks/layout.ts 201 lines1// Pure parts of the radar, shared by the hooks module and the map's surface module:
2// the repo tree, a squarified treemap in terminal cells, heat colors, and rows of colored runs.
3
4export type Tree = Map<string, { files: number; children: Map<string, number> }>
5export type TreeData = [name: string, files: number, children: [name: string, files: number][]][]
6export type Tile = { key: string; label: string; x0: number; y0: number; x1: number; y1: number; isHeader: boolean }
7export type Kind = 'read' | 'edit' | 'error'
8export type Hit = { kind: Kind; agent: number; at: number } // agent -1 is the main loop
9export type Run = { text: string; bg?: string; fg?: string; bold?: boolean }
10
11const MAX_PER_LEVEL = 12
12const MAX_SENT_CHILDREN = 30
13export const MORE = '…'
14
15export function addPath(tree: Tree, path: string): void {
16 const [top, second] = path.split('/')
17 if (!top) return
18 const group = tree.get(top) ?? { files: 0, children: new Map<string, number>() }
19 group.files += 1
20 if (second !== undefined && path.includes('/')) group.children.set(second, (group.children.get(second) ?? 0) + 1)
21 tree.set(top, group)
22}
23
24export function buildTree(paths: readonly string[]): Tree {
25 const tree: Tree = new Map()
26 for (const path of paths) if (path) addPath(tree, path)
27 return tree
28}
29
30// What crosses to the surface module: small folders folded into one `…` child, so props stay small.
31export function treeData(tree: Tree): TreeData {
32 return [...tree].map(([name, g]) => {
33 const kids = [...g.children].sort((a, b) => b[1] - a[1])
34 const rest = kids.slice(MAX_SENT_CHILDREN).reduce((sum, [, n]) => sum + n, 0)
35 return [name, g.files, rest ? [...kids.slice(0, MAX_SENT_CHILDREN), [MORE, rest]] : kids]
36 })
37}
38
39export const fromData = (data: TreeData): Tree => new Map(data.map(([name, files, kids]) => [name, { files, children: new Map(kids) }]))
40
41type Item = { key: string; label: string; weight: number }
42type Rect = { x: number; y: number; w: number; h: number }
43
44// Big counts would drown small folders; a softened weight keeps them visible.
45const weigh = (files: number) => Math.pow(files, 0.7)
46
47function topN(items: Item[]): Item[] {
48 const sorted = [...items].sort((a, b) => b.weight - a.weight)
49 if (sorted.length <= MAX_PER_LEVEL) return sorted
50 const rest = sorted.slice(MAX_PER_LEVEL - 1)
51 return [...sorted.slice(0, MAX_PER_LEVEL - 1), { key: rest[0]!.key.replace(/[^/]*$/, MORE), label: `+${rest.length} more`, weight: rest.reduce((s, i) => s + i.weight, 0) }]
52}
53
54// Bruls, Huizing & van Wijk's squarified treemap.
55export function squarify(items: readonly Item[], rect: Rect): (Item & Rect)[] {
56 const total = items.reduce((sum, item) => sum + item.weight, 0)
57 if (total <= 0 || rect.w <= 0 || rect.h <= 0) return []
58 const scale = (rect.w * rect.h) / total
59 const nodes = items.filter(i => i.weight > 0).map(i => ({ ...i, area: i.weight * scale }))
60 const out: (Item & Rect)[] = []
61 const worst = (row: typeof nodes, side: number) => {
62 const sum = row.reduce((s, n) => s + n.area, 0)
63 const areas = row.map(n => n.area)
64 return Math.max((side * side * Math.max(...areas)) / (sum * sum), (sum * sum) / (side * side * Math.min(...areas)))
65 }
66 const place = (row: typeof nodes, r: Rect): Rect => {
67 const sum = row.reduce((s, n) => s + n.area, 0)
68 if (r.w >= r.h) {
69 const w = sum / r.h
70 let y = r.y
71 for (const n of row) out.push({ key: n.key, label: n.label, weight: n.weight, x: r.x, y, w, h: n.area / w }), (y += n.area / w)
72 return { x: r.x + w, y: r.y, w: r.w - w, h: r.h }
73 }
74 const h = sum / r.w
75 let x = r.x
76 for (const n of row) out.push({ key: n.key, label: n.label, weight: n.weight, x, y: r.y, w: n.area / h, h }), (x += n.area / h)
77 return { x: r.x, y: r.y + h, w: r.w, h: r.h - h }
78 }
79 let r = rect
80 let row: typeof nodes = []
81 for (const node of nodes) {
82 const side = Math.min(r.w, r.h)
83 if (row.length === 0 || worst([...row, node], side) <= worst(row, side)) row.push(node)
84 else {
85 r = place(row, r)
86 row = [node]
87 }
88 }
89 if (row.length) place(row, r)
90 return out
91}
92
93// Layout in cells. Squarify runs in square units (a cell is about twice as tall as wide), then snaps to the grid.
94export function layout(tree: Tree, columns: number, rows: number): Tile[] {
95 const snap = (item: Item & Rect, rowOffset = 0) => ({
96 x0: Math.round(item.x), x1: Math.round(item.x + item.w),
97 y0: rowOffset + Math.round(item.y / 2), y1: rowOffset + Math.round((item.y + item.h) / 2),
98 })
99 const groups = topN([...tree].map(([key, g]) => ({ key, label: key, weight: weigh(g.files) })))
100 const tiles: Tile[] = []
101 for (const group of squarify(groups, { x: 0, y: 0, w: columns, h: rows * 2 })) {
102 const cell = snap(group)
103 if (cell.x1 <= cell.x0 || cell.y1 <= cell.y0) continue
104 const children = tree.get(group.key)?.children
105 const canSplit = children && children.size > 0 && cell.y1 - cell.y0 >= 3 && cell.x1 - cell.x0 >= 8
106 if (!canSplit) {
107 tiles.push({ key: group.key, label: group.label, ...cell, isHeader: false })
108 continue
109 }
110 tiles.push({ key: group.key, label: `${group.label}/`, ...cell, y1: cell.y0 + 1, isHeader: true })
111 const items = topN([...children].map(([name, files]) => ({ key: `${group.key}/${name}`, label: name, weight: weigh(files) })))
112 const area = { x: cell.x0, y: 0, w: cell.x1 - cell.x0, h: (cell.y1 - cell.y0 - 1) * 2 }
113 for (const child of squarify(items, area)) {
114 const c = snap(child, cell.y0 + 1)
115 if (c.x1 > c.x0 && c.y1 > c.y0) tiles.push({ key: child.key, label: child.label, ...c, isHeader: false })
116 }
117 }
118 return tiles
119}
120
121// Which tile lights up for a path: its second-level folder, else its top folder, else the overflow tile.
122export function tileFor(tiles: readonly Tile[], path: string): Tile | undefined {
123 const [top, second] = path.split('/')
124 const byKey = (key: string) => tiles.find(t => t.key === key && !t.isHeader) ?? tiles.find(t => t.key === key)
125 return (second !== undefined && path.includes('/') ? byKey(`${top}/${second}`) ?? byKey(`${top}/${MORE}`) : undefined) ?? byKey(top!) ?? byKey(MORE)
126}
127
128// The zone a path counts toward in the hot-zones list: its first two segments.
129export const zoneOf = (path: string) => (path.includes('/') ? path.split('/').slice(0, 2).join('/') : path)
130
131export const COLORS = {
132 header: 0x161b26,
133 headerText: 0x8b93a7,
134 tileA: 0x1c2533,
135 tileB: 0x222d3d,
136 text: 0xd1d5db,
137 dark: 0x0b0f17,
138 read: 0x38bdf8,
139 edit: 0xf59e0b,
140 error: 0xef4444,
141}
142export const AGENT_COLORS = [0xa78bfa, 0xf472b6, 0xa3e635, 0x2dd4bf, 0xfb923c, 0x60a5fa]
143export const DECAY_MS: Record<Kind, number> = { read: 5000, edit: 10000, error: 6000 }
144
145export const agentColor = (agent: number) => AGENT_COLORS[agent % AGENT_COLORS.length]!
146
147export function glowColor(hit: Pick<Hit, 'kind' | 'agent'>): number {
148 if (hit.kind === 'error') return COLORS.error
149 if (hit.agent < 0) return COLORS[hit.kind]
150 return hit.kind === 'read' ? mix(agentColor(hit.agent), COLORS.tileA, 0.35) : agentColor(hit.agent)
151}
152
153export const intensity = (hit: Hit, now: number) => Math.max(0, Math.exp(-(now - hit.at) / DECAY_MS[hit.kind]) - 0.02)
154
155export function mix(a: number, b: number, t: number): number {
156 const ch = (shift: number) => Math.round(((a >> shift) & 255) * (1 - t) + ((b >> shift) & 255) * t)
157 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
158}
159
160export const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
161
162// What a tile looks like now: resting shade, a faint tint once touched, the glow of its latest hit.
163export function tileColor(tile: Tile, index: number, hit: Hit | undefined, touched: Kind | undefined, now: number) {
164 const rest = tile.isHeader ? COLORS.header : index % 2 ? COLORS.tileA : COLORS.tileB
165 const tinted = touched ? mix(rest, COLORS[touched], 0.16) : rest
166 if (!hit) return { bg: tinted, glow: 0 }
167 const glow = intensity(hit, now)
168 return { bg: mix(tinted, glowColor(hit), Math.min(0.9, glow)), glow }
169}
170
171// The map as rows of runs: one run per tile per row, a blank gutter column between tiles,
172// the label on each tile's first row. Every surface draws it with Text alone.
173export function paint(tiles: readonly Tile[], columns: number, rows: number, look: (tile: Tile, index: number) => { bg: number; glow: number }): Run[][] {
174 const owner = Array.from({ length: rows }, () => Array<number>(columns).fill(-1))
175 tiles.forEach((t, i) => {
176 const right = t.x1 - t.x0 > 2 ? t.x1 - 1 : t.x1
177 for (let y = t.y0; y < Math.min(t.y1, rows); y++) for (let x = t.x0; x < Math.min(right, columns); x++) owner[y]![x] = i
178 })
179 const looks = tiles.map(look)
180 return owner.map((row, y) => {
181 const runs: Run[] = []
182 for (let x = 0; x < columns; ) {
183 const i = row[x]!
184 let end = x
185 while (end < columns && row[end] === i) end++
186 const width = end - x
187 const tile = tiles[i]
188 if (!tile) runs.push({ text: ' '.repeat(width) })
189 else {
190 const { bg, glow } = looks[i]!
191 const label = y === tile.y0 && width >= 4 ? ` ${tile.label}` : ''
192 const text = label.length > width ? `${label.slice(0, Math.max(0, width - 1))}…`.slice(0, width) : label.padEnd(width)
193 const fg = glow > 0.45 ? COLORS.dark : tile.isHeader ? COLORS.headerText : COLORS.text
194 runs.push({ text, bg: hex(bg), fg: hex(fg), bold: label !== '' && glow > 0.3 })
195 }
196 x = end
197 }
198 return runs
199 })
200}
201hooks/map.tsx 76 lines1// The radar's map, drawn on the surface's own frame clock: glows fade between the pane's redraws,
2// and nothing ticks once every glow has died down.
3import type { ClientModule } from 'claude-code'
4
5import { DECAY_MS, fromData, layout, paint, tileColor, tileFor } from './layout'
6import type { Hit, Kind, Tile, TreeData } from './layout'
7
8export type MapProps = {
9 tree: TreeData
10 treeVersion: number
11 // Each hit's age when the pane drew, so the two clocks never need to agree.
12 hits: [path: string, kind: Kind, agent: number, ageMs: number][]
13 tints: [path: string, kind: Kind][]
14 columns: number
15 rows: number
16 stamp: number
17}
18
19type Ref = { ticks: number; stamp: number; isActive: boolean; layoutKey: string; tiles: Tile[] }
20type State = { ref: Ref }
21
22const FRAME_MS = 160
23
24const RadarMap: ClientModule<MapProps, State> = (props, surface) => {
25 const { Box, Text } = surface.elements
26 const ref = surface.state?.ref ?? { ticks: 0, stamp: -1, isActive: false, layoutKey: '', tiles: [] }
27 if (props.stamp !== ref.stamp) {
28 ref.stamp = props.stamp
29 ref.ticks = 0
30 }
31 const elapsed = ref.ticks * FRAME_MS
32 ref.isActive = props.hits.some(([, kind, , age]) => age + elapsed < DECAY_MS[kind] * 4)
33 if (surface.state === undefined) {
34 surface.setState({ ref })
35 surface.every(FRAME_MS, () => {
36 if (!ref.isActive) return
37 ref.ticks += 1
38 surface.setState({ ref })
39 })
40 }
41
42 const columns = Math.max(10, surface.columns || props.columns)
43 const layoutKey = `${columns}x${props.rows}:${props.treeVersion}`
44 if (ref.layoutKey !== layoutKey) {
45 ref.layoutKey = layoutKey
46 ref.tiles = layout(fromData(props.tree), columns, props.rows)
47 }
48 const tiles = ref.tiles
49
50 // Now is 0; a hit happened (age + elapsed) ago.
51 const hitByTile = new Map<string, Hit>()
52 for (const [path, kind, agent, age] of props.hits) {
53 const key = tileFor(tiles, path)?.key
54 const at = -(age + elapsed)
55 if (key && (hitByTile.get(key)?.at ?? -Infinity) < at) hitByTile.set(key, { kind, agent, at })
56 }
57 const tintByTile = new Map<string, Kind>()
58 for (const [path, kind] of props.tints) {
59 const key = tileFor(tiles, path)?.key
60 if (key && tintByTile.get(key) !== 'edit') tintByTile.set(key, kind)
61 }
62
63 const rows = paint(tiles, columns, props.rows, (tile, i) => tileColor(tile, i, hitByTile.get(tile.key), tintByTile.get(tile.key), 0))
64 return (
65 <Box flexDirection="column">
66 {rows.map(runs => (
67 <Text wrap="truncate">
68 {runs.map(run => (run.bg ? <Text backgroundColor={run.bg} color={run.fg} bold={run.bold}>{run.text}</Text> : <Text>{run.text}</Text>))}
69 </Text>
70 ))}
71 </Box>
72 )
73}
74
75export default RadarMap
76hooks/sweep.tsx 52 lines1// A connector between panels: while Claude works, a radar blip sweeps along it with a fading trail;
2// idle, it rests as a dim line. Only this region redraws.
3import type { ClientModule } from 'claude-code'
4
5export type SweepProps = { isActive: boolean; width: number; trail: string[]; dim: string }
6
7type Ref = { phase: number; isActive: boolean }
8type State = { ref: Ref }
9
10const STEP_MS = 70
11
12const Sweep: ClientModule<SweepProps, State> = (props, surface) => {
13 const { Box, Text } = surface.elements
14 const ref = surface.state?.ref ?? { phase: 0, isActive: props.isActive }
15 ref.isActive = props.isActive
16 if (surface.state === undefined) {
17 surface.setState({ ref })
18 surface.every(STEP_MS, () => {
19 if (!ref.isActive) return
20 ref.phase += 1
21 surface.setState({ ref })
22 })
23 }
24
25 const width = Math.max(1, surface.columns || props.width)
26 if (!props.isActive) return <Text color={props.dim}>{'─'.repeat(width)}</Text>
27
28 // The head runs left to right and wraps; the trail behind it steps down through the colours.
29 const span = width + props.trail.length
30 const head = ref.phase % span
31 const runs: { text: string; color: string }[] = []
32 for (let x = 0; x < width; x++) {
33 const behind = head - x
34 const color = behind >= 0 && behind < props.trail.length ? props.trail[behind]! : props.dim
35 const glyph = behind === 0 ? '●' : behind > 0 && behind < props.trail.length ? '━' : '─'
36 const last = runs[runs.length - 1]
37 if (last && last.color === color) last.text += glyph
38 else runs.push({ text: glyph, color })
39 }
40 return (
41 <Box>
42 {runs.map(run => (
43 <Text color={run.color} bold={run.color !== props.dim}>
44 {run.text}
45 </Text>
46 ))}
47 </Box>
48 )
49}
50
51export default Sweep
52types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'repo-radar': { version: number }
4 }
5}
6