SLOPSHOPPER

repo-radar

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.

newpaneguardcommandprocessagents
v0.1.0MITupdated 2026-10-09lakmadev/claude-mods/plugins/repo-radar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · repo-radar
│ ┃ Repo Radar ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ◉ REPO RADAR ⏺ Read(src/auth.ts) │ ┃ │ · 1 files ⎿ Read 6 lines │ ┃ │ touched ▰▰▰▰▰▰▰▰▰▰ 4 · 3 edited · 0 failed ⏺ Update(src/auth.ts) │ ┃ ╰─────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ ▣ client module ./sweep.tsx ⏺ Bash(bun test) │ ┃ ╭─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ │ MAP ■ read ■ e │ ┃ │ ▣ client module ./map.tsx ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ │ HOT ZONES │ ┃ │ ▰▰▰▰▰▰▰▰ work/app 2r 3e › /radar │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── │ ┃ │ FEED /r │ ┃ │ 08:53:20 edit work/app/src/cache.ts │ ┃ │ 08:53:20 edit work/app/src/audit.ts │ ┃ │ 08:53:20 edit work/app/src/auth.ts │ ┃ │ 08:53:20 read work/app/src │ ┃ │ 08:53:20 read work/app/src/auth.ts │ ┃ ╰─────────────────────────────────────────── │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Repo Radar
╭──────────────────────────────────────────────────────╮ │ ◉ REPO RADAR ○ idle │ │ · 1 files │ │ touched ▰▰▰▰▰▰▰▰▰▰ 4 · 3 edited · 0 failed │ ╰──────────────────────────────────────────────────────╯ ▣ client module ./sweep.tsx ╭──────────────────────────────────────────────────────╮ │ MAP ■ read ■ edit ■ fail │ │ ▣ client module ./map.tsx │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ HOT ZONES 1 folders │ │ ▰▰▰▰▰▰▰▰ work/app 2r 3e │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ FEED /radar reset │ │ 08:53:20 edit work/app/src/cache.ts │ │ 08:53:20 edit work/app/src/audit.ts │ │ 08:53:20 edit work/app/src/auth.ts │ │ 08:53:20 read work/app/src │ │ 08:53:20 read work/app/src/auth.ts │ ╰──────────────────────────────────────────────────────╯
README

repo-radar

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

Repo Radar (sample session)

A Flightdeck-style pane in rounded, colour-bordered panels:

  • Header: repo, branch and file count, a live ● scanning light, and how much of the repo this session touched.
  • Map: your repo as tiles sized by file count, one per folder. Reads glow cyan, edits amber, failures red, and each subagent glows in its own colour, so you can watch parallel agents spread across the codebase. Glows fade like a radar trace; files touched this session keep a faint tint.
  • Agents: each subagent by type (Explore, docs-writer…) with what it read and edited, and where.
  • Hot zones: the folders with the most activity, with gauges.
  • Feed: timestamped file events.

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.

Install

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.

What it reads

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.

Source 5 files
hooks/register.tsx 337 lines
1import { 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}
337
hooks/layout.ts 201 lines
1// 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}
201
hooks/map.tsx 76 lines
1// 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
76
hooks/sweep.tsx 52 lines
1// 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
52
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'repo-radar': { version: number }
4  }
5}
6