SLOPSHOPPER

transit-map

Your git history as a Vignelli-style subway map: branches are coloured lines, commits are stations, merges are interchanges, and your working tree is a train…

newpanebandguardcommandprocess
v0.2.0MITupdated 2026-10-02ccdwyer/transit-map
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · transit-map
│ ┃ Transit Map · app ✕ › fix the failing auth test and add an audit log call │ ┃ Ⓜ TRANSIT MAP app feat/auth-refresh → │ ┃ a1b2c3d4e5f6a7b8c9d0e1f2a3b ⏺ Read(src/auth.ts) │ ┃ car on the train ⎿ Read 6 lines │ ┃ ▣ client module ./map.tsx ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /metro │ ⎿ transit-map: Transit Map: 0 stations on 0 lines (newest only). │ │ Ⓜ feat/auth-refresh ━━○━━○━━● ▶▶▶▮ 1 uncommitted · /metro ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Ⓜ feat/auth-refresh ━━○━━○━━● ▶▶▶▮ 1 uncommitted · /metro ⟨Claude Code's own drawing⟩
Pane · Transit Map · app
Ⓜ TRANSIT MAP app feat/auth-refresh → a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 · train ▣ client module ./map.tsx
README

Transit Map

Transit Map demo

/metro draws the repo as a subway map with tags and four lines; after Claude commits, a new station appears and the train runs empty. Watch the MP4

A Claude Code mod that draws your git history as a Vignelli-style subway map.

  • Lines are branches. Each branch is a bold, flat-coloured line. Trunk (main, master) is always red.
  • Stations are commits. Commits are ○ stations, merges are ◎ interchanges, and tags are ▣ termini with their name above.
  • Dashed lines are remote-only. Branches that exist only on the remote are drawn dashed ╍.
  • The train is your working tree. It sits ▶▶▶ past HEAD on your current line, with one car ▮ per uncommitted file (up to 60). Cars light up yellow when Claude edits that file.
  • It's live. When Claude commits, new stations pop in and the train slides forward. A branch switch slides the train onto another line.

/metro opens the map in a pane:

KeyAction
← →, or dragpan
↑ ↓walk the stations
PgUp PgDnscroll lines into view
click or Entershow a commit's hash, author, subject and files
ffollow the train

Hover over a station to see its subject.

After you open the map once, a one-row strip above the prompt shows the line you're on, your train and ahead/behind counts. /metro strip off hides it.

The map shows the newest 120 commits across all branches, plus HEAD's own history if you've checked out something older. A branch that shares another's tip (one you just created, or a merged remote) has no line of its own; its name rides beside the station. Past 10 lines, the rest share a grey +N more lines lane. The map refreshes after each turn, so commits a subagent made appear too. Surfaces without interactive regions, such as mobile, get a plain timetable instead.

Install

/plugin marketplace add ccdwyer/claude-mods
/plugin install transit-map@ccdwyer-mods
/reload-plugins

What it hooks

Events this mod hooks, as claude plugin validate reads the module:

  • session.start
  • command.run{command=metro}
  • tool.call
  • turn.complete
  • ui.message
  • ui.render{component=Pane, requestId=transit-map}
  • ui.render{component=AbovePrompt}

Engine calls it makes: $.command.register, $.process.run (via git), $.session.cwd (via repoRoot), $.state.get, $.state.set, $.ui.open, $.ui.resolve.

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 observes the result, to redraw the map after git commands and edits.

Privacy

It runs entirely on your machine, using local git only. It sends nothing over the network. Full policy: PRIVACY.md.

License

MIT

Source 4 files
hooks/register.tsx 318 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Detail, Graph } from '../types'
5import { LOG_FORMAT, MAX_COMMITS, paneRows, parseLog, parseStatus } from './metro'
6
7const PANE = 'transit-map'
8const graphAtom = atom({ plugin: 'transit-map', key: 'graph' } as const, null)
9const detailAtom = atom({ plugin: 'transit-map', key: 'detail' } as const, null)
10const stripAtom = atom({ plugin: 'transit-map', key: 'strip' } as const, false)
11const litAtom = atom({ plugin: 'transit-map', key: 'lit' } as const, [])
12const errorAtom = atom({ plugin: 'transit-map', key: 'error' } as const, null)
13
14const GIT_TIMEOUT = 5000
15// Git commands that move the map: new stations, a moved train, other lines.
16const MOVES_MAP = /\bgit\b[^|;&]*\b(commit|checkout|switch|merge|rebase|reset|pull|fetch|push|branch|tag|cherry-pick|revert|stash|am|restore)\b|\bgh\s+(pr\s+(merge|checkout)|stack)\b/
17const EDITS = new Set(['Edit', 'Write', 'NotebookEdit'])
18// The cars handed to the map as props: enough for any train on screen, bounded so a huge dirty tree stays small.
19const MAX_CARS = 60
20
21let seq = 0
22// While the pane is open inline, the body rows it last asked for: a map that gains lanes asks again.
23let askedRows: number | null = null
24// The height of the tree the pane drew last: an inline host reports min(granted, measured), so only a report
25// smaller than what was drawn is a real, smaller window.
26let lastPaneHeight = 0
27
28type Run = { exitCode: number; stdout: string }
29
30async function git($: EngineInterface, cwd: string, args: string[]): Promise<Run | null> {
31  try {
32    const out = await $.process.run(['git', ...args], { cwd, timeoutMs: GIT_TIMEOUT })
33    return { exitCode: out.exitCode, stdout: out.stdout }
34  } catch {
35    return null
36  }
37}
38
39async function repoRoot($: EngineInterface): Promise<string | null> {
40  const cwd = await $.session.cwd()
41  const top = await git($, cwd, ['rev-parse', '--show-toplevel'])
42  if (top === null || top.exitCode !== 0) return null
43  return top.stdout.trim() || null
44}
45
46// The whole map: history, HEAD, upstream and the working tree.
47async function refresh($: EngineInterface): Promise<Graph | null> {
48  const root = await repoRoot($)
49  if (root === null) {
50    await update($, errorAtom, () => 'Not inside a git repository.')
51    return null
52  }
53  const head = await git($, root, ['rev-parse', '--verify', '-q', 'HEAD'])
54  // A repository with no commits yet (HEAD does not resolve) is still a map: an empty one.
55  const isEmpty = head !== null && head.exitCode !== 0
56  const log = isEmpty ? null : await git($, root, ['log', '--all', '--topo-order', '--decorate=full', '-n', String(MAX_COMMITS + 1), `--format=${LOG_FORMAT}`])
57  if (!isEmpty && (log === null || log.exitCode !== 0)) {
58    await update($, errorAtom, () => 'git log failed.')
59    return null
60  }
61  const parsed = parseLog(log?.exitCode === 0 ? log.stdout : '')
62  const headSha = head !== null && head.exitCode === 0 ? head.stdout.trim() : ''
63  if (headSha !== '' && !parsed.commits.some(c => c.sha === headSha)) {
64    // HEAD is older than the newest stations (a checkout of an old commit): bring its own history in too.
65    const own = await git($, root, ['log', '--topo-order', '--decorate=full', '-n', '30', `--format=${LOG_FORMAT}`, 'HEAD'])
66    if (own !== null && own.exitCode === 0) {
67      const have = new Set(parsed.commits.map(c => c.sha))
68      const extra = parseLog(own.stdout).commits.filter(c => !have.has(c.sha))
69      parsed.commits = [...parsed.commits.slice(0, Math.max(0, MAX_COMMITS - extra.length)), ...extra]
70      parsed.truncated = true
71    }
72  }
73  const branch = await git($, root, ['symbolic-ref', '--short', '-q', 'HEAD'])
74  const upstream = await git($, root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])
75  let ahead = 0
76  let behind = 0
77  const hasUpstream = upstream !== null && upstream.exitCode === 0
78  if (hasUpstream) {
79    const counts = await git($, root, ['rev-list', '--left-right', '--count', 'HEAD...@{u}'])
80    const [a, b] = (counts?.stdout ?? '').trim().split(/\s+/)
81    ahead = Number(a) || 0
82    behind = Number(b) || 0
83  }
84  const status = await git($, root, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])
85  const lit = await read($, litAtom)
86  seq += 1
87  const graph: Graph = {
88    seq: Date.now() * 10 + (seq % 10),
89    repo: root.slice(root.lastIndexOf('/') + 1),
90    root,
91    commits: parsed.commits,
92    truncated: parsed.truncated,
93    head: head !== null && head.exitCode === 0 ? head.stdout.trim() || null : null,
94    branch: branch !== null && branch.exitCode === 0 ? branch.stdout.trim() || null : null,
95    upstream: hasUpstream ? (upstream?.stdout.trim() ?? null) : null,
96    ahead,
97    behind,
98    cars: status !== null && status.exitCode === 0 ? parseStatus(status.stdout, lit, root).slice(0, MAX_CARS) : [],
99  }
100  await update($, graphAtom, () => graph)
101  await update($, errorAtom, () => null)
102  await growPane($, graph)
103  return graph
104}
105
106/** The pane is open and the map gained lanes: ask for the rows they need. A closed pane stays closed. */
107async function growPane($: EngineInterface, graph: Graph): Promise<void> {
108  try {
109    const want = paneRows(graph)
110    if (askedRows === null) {
111      // After a reload the module forgot what it asked for; a pane still on screen is still open.
112      const panes = await $.ui.panes()
113      const open = panes.some(p => p.id === PANE && p.isPlaced)
114      if (!open) return
115      askedRows = want
116      return
117    }
118    if (want <= askedRows) return
119    const opened = await $.ui.open({ id: PANE, title: `Transit Map · ${graph.repo}`, rows: want })
120    if (opened.isPlaced) askedRows = want
121  } catch {
122    // The map keeps its size; lower lanes still page into view.
123  }
124}
125
126// Only the train: the working tree changed, history did not.
127async function refreshCars($: EngineInterface): Promise<void> {
128  const current = await read($, graphAtom)
129  if (current === null) return
130  const root = await repoRoot($)
131  if (root === null) return
132  // Another checkout now: this history is not its history, so redraw the whole map.
133  if (root !== current.root) {
134    await refresh($)
135    return
136  }
137  const status = await git($, root, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])
138  if (status === null || status.exitCode !== 0) return
139  const lit = await read($, litAtom)
140  const cars = parseStatus(status.stdout, lit, root).slice(0, MAX_CARS)
141  seq += 1
142  await update($, graphAtom, g => (g === null ? g : { ...g, cars, seq: Date.now() * 10 + (seq % 10) }))
143}
144
145async function showDetail($: EngineInterface, sha: string): Promise<void> {
146  const current = await read($, graphAtom)
147  if (current === null || !current.commits.some(c => c.sha === sha)) return
148  const root = await repoRoot($)
149  if (root === null) return
150  const out = await git($, root, ['show', '--no-color', '--name-only', '--format=%an%x1f%ci%x1f%s', sha])
151  if (out === null || out.exitCode !== 0) return
152  const [first, ...rest] = out.stdout.split('\n')
153  const [author, date, subject] = (first ?? '').split('\x1f')
154  const detail: Detail = {
155    sha,
156    author: author ?? '',
157    date: date ?? '',
158    subject: subject ?? '',
159    files: rest.map(f => f.trim()).filter(f => f !== '').slice(0, 40),
160  }
161  await update($, detailAtom, () => detail)
162}
163
164function editedPath(e: { tool: string; [k: string]: unknown }): string | null {
165  const path = e.file_path ?? e.notebook_path
166  return typeof path === 'string' ? path : null
167}
168
169export const register: Register = on => {
170  on('session.start', async ($, e, next) => {
171    await $.command.register({
172      name: 'metro',
173      description: 'Transit Map: your git history as a subway map (/metro strip on|off for the line above the prompt)',
174      argumentHint: '[strip on|off]',
175      immediate: true,
176    })
177    return next(e)
178  })
179
180  on('command.run', { command: 'metro' }, async ($, e) => {
181    const args = String(e.args ?? '').trim()
182    if (/^strip\s+(on|off)$/.test(args)) {
183      const enabled = args.endsWith('on')
184      await update($, stripAtom, () => enabled)
185      return { text: `Transit Map: the strip above the prompt is ${enabled ? 'on' : 'off'}.` }
186    }
187    const graph = await refresh($)
188    if (graph === null) {
189      const why = await read($, errorAtom)
190      return { text: `Transit Map: ${why ?? 'could not read git history.'}` }
191    }
192    await update($, stripAtom, () => true)
193    // Inline, ask for every lane at once: left to a third of the screen, side lines clip to stubs.
194    const want = paneRows(graph)
195    askedRows = null
196    const opened = await $.ui.open({ id: PANE, title: `Transit Map · ${graph.repo}`, rows: want })
197    askedRows = opened.isPlaced ? want : null
198    const lines = new Set(graph.commits.flatMap(c => [...c.refs, ...c.remotes])).size
199    return { text: `Transit Map: ${graph.commits.length} stations on ${lines} line${lines === 1 ? '' : 's'}${graph.truncated ? ' (newest only)' : ''}.` }
200  })
201
202  on('tool.call', async ($, e, next) => {
203    const ran = await next(e)
204    if (e.agentId !== undefined || ran.deny !== undefined) return ran
205    try {
206      if ((await read($, graphAtom)) === null) return ran
207      if (e.tool === 'Bash' && MOVES_MAP.test(String(e.command))) {
208        await refresh($)
209      } else if (EDITS.has(e.tool) && ran.isError !== true) {
210        const path = editedPath(e as { tool: string })
211        if (path !== null) await update($, litAtom, list => [...list.filter(p => p !== path), path].slice(-50))
212        await refreshCars($)
213      } else if (e.tool === 'Bash' && ran.isError !== true && ran.isReadOnly !== true) {
214        // Shell edits (sed -i, codegen) change the train's cars too; read-only commands (ls, rg) do not.
215        await refreshCars($)
216      }
217    } catch {
218      // The map is decoration: never fail a tool call over it.
219    }
220    return ran
221  })
222
223  on('turn.complete', async ($, e, next) => {
224    try {
225      // Once per main-loop turn, the whole map: commits a subagent or an unrecognized command made appear here.
226      if (e.agentId === undefined && (await read($, graphAtom)) !== null) await refresh($)
227    } catch {
228      // ignore
229    }
230    return next(e)
231  })
232
233  on('ui.close', { id: PANE }, async ($, e, next) => {
234    askedRows = null
235    lastPaneHeight = 0
236    return next(e)
237  })
238
239  on('ui.message', async ($, e, next) => {
240    const data = e.data as { select?: unknown } | null
241    if (e.element === 'metro' && data !== null && typeof data === 'object' && typeof data.select === 'string') {
242      await showDetail($, data.select)
243    }
244    return next(e)
245  })
246
247  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
248    const { Box, Text } = $.ui.resolve(e)
249    const graph = await read($, graphAtom)
250    const detail = await read($, detailAtom)
251    const error = await read($, errorAtom)
252    const where = graph === null
253      ? ''
254      : `${graph.branch ?? 'detached HEAD'}${graph.upstream === null ? '' : ` → ${graph.upstream}`}${graph.ahead > 0 ? ` ↑${graph.ahead}` : ''}${graph.behind > 0 ? ` ↓${graph.behind}` : ''}`
255    const header = (
256      <Box key="hdr" flexDirection="row" overflow="hidden">
257        <Text color="#EE352E" bold>{'Ⓜ TRANSIT MAP '}</Text>
258        <Text color="#FFFFFF" bold>{graph?.repo ?? ''}</Text>
259        <Text dimColor>{graph === null ? ` ${error ?? 'loading…'}` : `  ${where} · ${graph.cars.length} car${graph.cars.length === 1 ? '' : 's'} on the train`}</Text>
260      </Box>
261    )
262    if (e.surface !== 'terminal' && e.surface !== 'desktop') {
263      // No Client here: a timetable of the newest stations instead.
264      return (
265        <Box flexDirection="column">
266          {header}
267          {(graph?.commits ?? []).slice(0, 15).map(c => (
268            <Text key={`t${c.sha}`}>{`${c.sha === graph?.head ? '●' : c.parents.length > 1 ? '◎' : '○'} ${c.sha.slice(0, 7)} ${[...c.refs, ...c.tags.map(t => `⚑${t}`)].join(' ')} ${c.subject}`}</Text>
269          ))}
270        </Box>
271      )
272    }
273    const { Client } = $.ui.resolve(e)
274    const given = e.props.scroll?.bodyRows ?? 0
275    // Inline, lay out to the height asked for (the host may report the body it measured, not the rows it granted);
276    // a smaller real window (the person dragged the block down) wins. Docked, the body is the real allocation.
277    const asked = askedRows ?? (graph === null ? 9 : paneRows(graph))
278    const body = e.props.placement === 'inline'
279      ? (given > 0 && given < lastPaneHeight ? given : asked)
280      : given > 0 ? given : e.viewport?.rows ?? 24
281    // The header takes one row; the Client gets the rest, never more than the body.
282    const rows = Math.max(1, body - 1)
283    lastPaneHeight = 1 + rows
284    return (
285      <Box flexDirection="column">
286        {header}
287        <Client key="metro" module="./map.tsx" props={{ graph, detail }} width="100%" height={rows} />
288      </Box>
289    )
290  })
291
292  // One row above the prompt, once the map has been opened: the line you are on.
293  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
294    const strip = await read($, stripAtom)
295    const graph = await read($, graphAtom)
296    if (!strip || graph === null || e.props.hasSurvey) return next(e)
297    const below = await next(e)
298    const { Box, Text } = $.ui.resolve(e)
299    const lit = graph.cars.filter(c => c.lit).length
300    const shown = graph.cars.slice(0, 8)
301    return (
302      <Box flexDirection="column">
303        <Box flexDirection="row">
304          <Text color="#EE352E" bold>{'Ⓜ '}</Text>
305          <Text bold>{graph.branch ?? 'detached'}</Text>
306          <Text dimColor>{' ━━○━━○━━● '}</Text>
307          <Text color="#FFFFFF" bold>{'▶▶▶'}</Text>
308          {shown.map((car, k) => (
309            <Text key={`car${k}`} color={car.lit ? '#FCCC0A' : '#A7A9AC'}>{'▮'}</Text>
310          ))}
311          <Text dimColor>{`  ${graph.cars.length} uncommitted${lit > 0 ? `, ${lit} edited by Claude` : ''}${graph.ahead > 0 ? ` · ↑${graph.ahead}` : ''}${graph.behind > 0 ? ` · ↓${graph.behind}` : ''} · /metro`}</Text>
312        </Box>
313        {below}
314      </Box>
315    )
316  })
317}
318
hooks/metro.ts 335 lines
1// Pure parsing and layout for the transit map. No `$` here: the hooks module
2// runs git, this file turns its output into a map both the hooks module and the
3// Client surface module draw from.
4
5import type { Car, Commit, Graph } from '../types'
6
7export const FIELD = '\x1f'
8export const LOG_FORMAT = '%H%x1f%P%x1f%an%x1f%ct%x1f%D%x1f%s'
9export const MAX_COMMITS = 120
10export const MAX_LANES = 10
11// Pane rows around the map itself: the header line, the Client's footer (station info, files, legend) and one spare.
12export const PANE_CHROME_ROWS = 5
13// The tallest inline pane the map asks for; a taller map scrolls with PgUp/PgDn.
14export const MAX_PANE_ROWS = 32
15const SPACING = 3
16const SUBJECT_ROOM = 72
17
18// Vignelli-era subway colours. Trunk branches are always red.
19const TRUNK_COLOR = '#EE352E'
20const PALETTE = ['#0039A6', '#FF6319', '#00933C', '#FCCC0A', '#B933AD', '#00ADD0', '#6CBE45', '#996633', '#F4A6C8']
21const REMOTE_COLOR = '#A7A9AC'
22const MERGED_COLOR = '#808183'
23const TRUNKS = ['main', 'master', 'trunk', 'develop']
24
25export function parseLog(stdout: string): { commits: Commit[]; truncated: boolean } {
26  const commits: Commit[] = []
27  for (const raw of stdout.split('\n')) {
28    if (raw.trim() === '') continue
29    const parts = raw.split(FIELD)
30    if (parts.length < 6) continue
31    const [sha, parentText, author, time, decorations] = parts
32    const subject = parts.slice(5).join(FIELD)
33    const commit: Commit = {
34      sha: sha ?? '',
35      parents: (parentText ?? '').split(' ').filter(p => p !== ''),
36      author: (author ?? '').slice(0, 40),
37      time: Number(time) || 0,
38      subject: subject.length > SUBJECT_ROOM ? `${subject.slice(0, SUBJECT_ROOM - 1)}…` : subject,
39      refs: [],
40      remotes: [],
41      tags: [],
42    }
43    for (const piece of (decorations ?? '').split(', ')) {
44      const ref = piece.startsWith('HEAD -> ') ? piece.slice(8) : piece
45      if (ref.startsWith('refs/heads/')) commit.refs.push(ref.slice(11))
46      else if (ref.startsWith('tag: refs/tags/')) commit.tags.push(ref.slice(15))
47      else if (ref.startsWith('refs/tags/')) commit.tags.push(ref.slice(10))
48      else if (ref.startsWith('refs/remotes/') && !ref.endsWith('/HEAD')) commit.remotes.push(ref.slice(13))
49    }
50    if (commit.sha !== '') commits.push(commit)
51  }
52  return { commits: commits.slice(0, MAX_COMMITS), truncated: commits.length > MAX_COMMITS }
53}
54
55// `git status --porcelain=v1 -z`: "XY path\0", a rename or copy followed by its old path.
56export function parseStatus(stdout: string, lit: readonly string[], root = ''): Car[] {
57  const cars: Car[] = []
58  const entries = stdout.split('\0')
59  // Edited paths as the tools named them (usually absolute), made relative to the repo root.
60  const prefix = root === '' ? '' : `${root.replace(/\/+$/, '')}/`
61  const edited = new Set(lit.map(p => (prefix !== '' && p.startsWith(prefix) ? p.slice(prefix.length) : p)))
62  for (let i = 0; i < entries.length; i += 1) {
63    const entry = entries[i] ?? ''
64    if (entry.length < 4) continue
65    const code = entry.slice(0, 2)
66    // A rename or copy (in either column) is followed by its old path, which is not a change of its own.
67    if (code[0] === 'R' || code[0] === 'C' || code[1] === 'R' || code[1] === 'C') i += 1
68    const path = entry.slice(3)
69    cars.push({ path, code: code.trim() || code, lit: edited.has(path) })
70  }
71  return cars
72}
73
74export type Lane = { name: string; color: string; dashed: boolean }
75export type Station = { sha: string; idx: number; x: number; y: number; kind: 'commit' | 'merge' | 'tag' | 'head' }
76export type Label = { x: number; y: number; text: string; color: string }
77
78export type MapLayout = {
79  cols: number
80  rows: number
81  ch: string[]
82  fg: (string | null)[]
83  bold: boolean[]
84  stations: Station[]
85  labels: Label[]
86  lanes: Lane[]
87  // Where the train's engine sits: one cell past the HEAD station, on its line.
88  train: { x: number; y: number; color: string } | null
89}
90
91const isTrunk = (name: string) => TRUNKS.includes(name.replace(/^[^/]+\//, ''))
92
93// Which branch each commit belongs to: walk first parents from each branch tip,
94// trunk first, then the checked-out branch, then the rest by recency, then
95// remote-only branches. Commits no tip reaches (merged, deleted branches) get
96// one grey line per first-parent chain.
97function assignBranches(graph: Graph): { owner: Map<string, string>; order: string[]; dashed: Set<string> } {
98  const bySha = new Map(graph.commits.map(c => [c.sha, c]))
99  const tips: { name: string; sha: string; rank: number; time: number; remote: boolean }[] = []
100  const locals = new Set(graph.commits.flatMap(c => c.refs))
101  for (const c of graph.commits) {
102    for (const name of c.refs) {
103      const rank = isTrunk(name) ? 0 : name === graph.branch ? 1 : 2
104      tips.push({ name, sha: c.sha, rank, time: c.time, remote: false })
105    }
106    for (const name of c.remotes) {
107      // A remote branch with a local counterpart shares its line (dashed past it).
108      const local = name.replace(/^[^/]+\//, '')
109      const line = locals.has(local) ? local : name
110      tips.push({ name: line, sha: c.sha, rank: locals.has(local) ? (isTrunk(local) ? 0 : local === graph.branch ? 1 : 2) : 3, time: c.time, remote: true })
111    }
112  }
113  tips.sort((a, b) => a.rank - b.rank || Number(a.remote) - Number(b.remote) || b.time - a.time)
114  const owner = new Map<string, string>()
115  const order: string[] = []
116  const dashed = new Set<string>()
117  const localReach = new Set<string>()
118  // Everything a local branch reaches is solid; what only remotes reach is dashed.
119  for (const tip of tips.filter(t => !t.remote)) {
120    const stack = [tip.sha]
121    while (stack.length > 0) {
122      const sha = stack.pop() as string
123      if (localReach.has(sha)) continue
124      localReach.add(sha)
125      for (const p of bySha.get(sha)?.parents ?? []) if (bySha.has(p)) stack.push(p)
126    }
127  }
128  for (const tip of tips) {
129    let sha: string | undefined = tip.sha
130    let claimed = 0
131    while (sha !== undefined && bySha.has(sha) && !owner.has(sha)) {
132      owner.set(sha, tip.name)
133      claimed += 1
134      if (!localReach.has(sha)) dashed.add(sha)
135      sha = bySha.get(sha)?.parents[0]
136    }
137    // A tip that owns nothing (a branch sharing another's tip, a merged remote) gets no lane of its own;
138    // its name is drawn beside the station it points at instead.
139    if (claimed > 0 && !order.includes(tip.name)) order.push(tip.name)
140  }
141  let merged = 0
142  for (const c of graph.commits) {
143    if (owner.has(c.sha)) continue
144    const name = `·merged-${(merged += 1)}`
145    let sha: string | undefined = c.sha
146    while (sha !== undefined && bySha.has(sha) && !owner.has(sha)) {
147      owner.set(sha, name)
148      sha = bySha.get(sha)?.parents[0]
149    }
150    order.push(name)
151  }
152  return { owner, order, dashed }
153}
154
155function colorFor(name: string, used: number): string {
156  if (name.startsWith('·merged')) return MERGED_COLOR
157  if (isTrunk(name)) return TRUNK_COLOR
158  return PALETTE[used % PALETTE.length] ?? REMOTE_COLOR
159}
160
161export function layoutGraph(graph: Graph): MapLayout {
162  const n = graph.commits.length
163  const { owner, order, dashed } = assignBranches(graph)
164  const lanes: Lane[] = []
165  const laneOf = new Map<string, number>()
166  let colors = 0
167  const remoteOnly = new Set(graph.commits.flatMap(c => c.remotes))
168  const distinct = order.filter((name, i) => order.indexOf(name) === i)
169  const overflow = Math.max(0, distinct.length - MAX_LANES)
170  for (const name of distinct) {
171    // Past the cap, the remaining lines share one grey "+N more lines" lane rather than another line's colour.
172    if (overflow > 0 && lanes.length === MAX_LANES - 1) {
173      lanes.push({ name: `+${overflow + 1} more lines`, color: MERGED_COLOR, dashed: true })
174    }
175    if (lanes.length >= MAX_LANES) {
176      laneOf.set(name, MAX_LANES - 1)
177      continue
178    }
179    const isRemote = remoteOnly.has(name) && !graph.commits.some(c => c.refs.includes(name))
180    const color = isRemote ? REMOTE_COLOR : colorFor(name, isTrunk(name) || name.startsWith('·') ? colors : colors++)
181    laneOf.set(name, lanes.length)
182    lanes.push({ name, color, dashed: isRemote })
183  }
184
185  const cols = Math.max(1, n * SPACING + 2)
186  const rows = Math.max(1, lanes.length * 2 + 1)
187  const ch: string[] = new Array(cols * rows).fill(' ')
188  const fg: (string | null)[] = new Array(cols * rows).fill(null)
189  const bold: boolean[] = new Array(cols * rows).fill(false)
190  const rank = new Array(cols * rows).fill(0)
191  const put = (x: number, y: number, c: string, color: string, level: number, strong = false) => {
192    if (x < 0 || y < 0 || x >= cols || y >= rows) return
193    const i = y * cols + x
194    if (level < rank[i]) return
195    ch[i] = c
196    fg[i] = color
197    bold[i] = strong
198    rank[i] = level
199  }
200
201  const index = new Map(graph.commits.map((c, i) => [c.sha, i]))
202  const xOf = (i: number) => (n - 1 - i) * SPACING + 1
203  const yOf = (sha: string) => 1 + (laneOf.get(owner.get(sha) ?? '') ?? 0) * 2
204  const laneColor = (sha: string) => lanes[laneOf.get(owner.get(sha) ?? '') ?? 0]?.color ?? MERGED_COLOR
205
206  // Track lines: parent (left, older) to child (right, newer).
207  graph.commits.forEach((c, ci) => {
208    const xc = xOf(ci)
209    const yc = yOf(c.sha)
210    c.parents.forEach((p, pi) => {
211      const pIndex = index.get(p)
212      if (pIndex === undefined) {
213        // The parent is past the cap: run the line off the left edge.
214        for (let x = 0; x < xc; x += 1) put(x, yc, dashed.has(c.sha) ? '╍' : '━', laneColor(c.sha), 1)
215        return
216      }
217      const xp = xOf(pIndex)
218      const yp = yOf(p)
219      const firstParent = pi === 0
220      const color = firstParent ? laneColor(c.sha) : laneColor(p)
221      const isDashed = firstParent ? dashed.has(c.sha) : dashed.has(p)
222      const flat = isDashed ? '╍' : '━'
223      if (yc === yp) {
224        for (let x = xp + 1; x < xc; x += 1) put(x, yc, flat, color, 1)
225        return
226      }
227      // Going rightwards the line climbs (╱) or drops (╲): 45° steps while there
228      // is room, a straight drop (┃) when there is not.
229      const step = yc < yp ? -1 : 1
230      const diag = yc < yp ? '╱' : '╲'
231      if (firstParent) {
232        // Branch-off: leave the parent's line at once, then run along the child's.
233        let x = xp
234        let y = yp
235        while (y !== yc) {
236          const moved = x + 1 < xc
237          if (moved) x += 1
238          y += step
239          // A step that also moved right is a diagonal; out of room, the line drops straight.
240          if (y !== yc) put(x, y, moved ? diag : '┃', color, 2)
241        }
242        // The landing cell joins the diagonal to the child's line.
243        for (let k = Math.max(x, xp + 1); k < xc; k += 1) put(k, yc, flat, color, 1)
244      } else {
245        // Merge: run along the parent's line, then arrive at the last moment.
246        let x = xc
247        let y = yc
248        while (y !== yp) {
249          const moved = x - 1 > xp
250          if (moved) x -= 1
251          y -= step
252          if (y !== yp) put(x, y, moved ? diag : '┃', color, 2)
253        }
254        for (let k = xp + 1; k <= Math.min(x, xc - 1); k += 1) put(k, yp, flat, color, 1)
255      }
256    })
257  })
258
259  const stations: Station[] = []
260  graph.commits.forEach((c, ci) => {
261    const x = xOf(ci)
262    const y = yOf(c.sha)
263    const isHead = c.sha === graph.head
264    const kind: Station['kind'] = isHead ? 'head' : c.tags.length > 0 ? 'tag' : c.parents.length > 1 ? 'merge' : 'commit'
265    const glyph = kind === 'head' ? '●' : kind === 'tag' ? '▣' : kind === 'merge' ? '◎' : dashed.has(c.sha) ? '◌' : '○'
266    put(x, y, glyph, kind === 'head' ? '#FFFFFF' : laneColor(c.sha), 3, kind !== 'commit')
267    stations.push({ sha: c.sha, idx: ci, x, y, kind })
268  })
269
270  // Line names at each line's newest station; tags above their station.
271  const labels: Label[] = []
272  const seen = new Set<string>()
273  graph.commits.forEach((c, ci) => {
274    const name = owner.get(c.sha) ?? ''
275    if (seen.has(name)) return
276    seen.add(name)
277    if (name.startsWith('·')) return
278    const lane = lanes[laneOf.get(name) ?? 0]
279    labels.push({ x: xOf(ci) + 2, y: yOf(c.sha), text: name, color: lane?.color ?? MERGED_COLOR })
280  })
281  graph.commits.forEach((c, ci) => {
282    if (c.tags.length > 0) labels.push({ x: xOf(ci) - 1, y: yOf(c.sha) - 1, text: `⚑${c.tags[0]}`, color: '#FCCC0A' })
283    // Branches that share this station without a line of their own (just created, or fast-forwarded).
284    const sharing = c.refs.filter(r => !laneOf.has(r))
285    if (sharing.length > 0) labels.push({ x: xOf(ci) + 2, y: yOf(c.sha) + 1, text: `↳ ${sharing.join(' · ')}`, color: '#FFFFFF' })
286  })
287
288  const headIndex = graph.head === null ? undefined : index.get(graph.head)
289  const train = headIndex === undefined || graph.head === null
290    ? null
291    : { x: xOf(headIndex) + 2, y: yOf(graph.head), color: laneColor(graph.head) }
292
293  return { cols, rows, ch, fg, bold, stations, labels, lanes, train }
294}
295
296export type Run = { text: string; color: string | null; bold: boolean; inverse: boolean }
297
298// One visible row of the map as runs of same-styled text, `from` the pan offset.
299export function rowRuns(
300  layout: MapLayout,
301  y: number,
302  from: number,
303  width: number,
304  overlay: Map<number, { c: string; color: string; bold?: boolean; inverse?: boolean }>,
305): Run[] {
306  const runs: Run[] = []
307  for (let x = from; x < from + width; x += 1) {
308    const inside = x >= 0 && x < layout.cols && y >= 0 && y < layout.rows
309    const i = y * layout.cols + x
310    const top = overlay.get(x)
311    const c = top?.c ?? (inside ? layout.ch[i] ?? ' ' : ' ')
312    const color = top?.color ?? (inside ? layout.fg[i] ?? null : null)
313    const strong = top?.bold ?? (inside ? layout.bold[i] ?? false : false)
314    const inverse = top?.inverse ?? false
315    const last = runs[runs.length - 1]
316    if (last !== undefined && last.color === color && last.bold === strong && last.inverse === inverse) last.text += c
317    else runs.push({ text: c, color, bold: strong, inverse })
318  }
319  return runs
320}
321
322export function ago(seconds: number, now: number): string {
323  const s = Math.max(0, Math.round(now / 1000 - seconds))
324  if (s < 90) return `${s}s ago`
325  if (s < 5400) return `${Math.round(s / 60)}m ago`
326  if (s < 129600) return `${Math.round(s / 3600)}h ago`
327  return `${Math.round(s / 86400)}d ago`
328}
329
330/** The inline pane height a graph wants: every lane in view (layout rows plus the last), plus the header and footer. */
331export function paneRows(graph: Graph): number {
332  const layout = layoutGraph(graph)
333  return Math.min(MAX_PANE_ROWS, Math.max(8, layout.rows + 1 + PANE_CHROME_ROWS))
334}
335
hooks/map.tsx 334 lines
1// The map itself: a Client surface module. It lays the graph out, animates the
2// train and new stations on the surface's frame clock, pans with keys or a drag,
3// and posts the selected station to the hooks module. No `$` here.
4
5import type { ClientKeyEvent, ClientModule, ClientPointerEvent, ClientSurface, RenderElement } from 'claude-code'
6
7import type { Detail, Graph } from '../types'
8import { ago, layoutGraph, rowRuns } from './metro'
9import type { MapLayout, Station } from './metro'
10
11export type MapProps = { graph: Graph | null; detail: Detail | null }
12
13type View = {
14  seq: number
15  layout: MapLayout | null
16  known: Set<string>
17  pops: Map<string, number>
18  offX: number
19  offY: number
20  follow: boolean
21  // Selection and hover by commit, so a refresh that shifts the stations keeps the same commits.
22  selSha: string | null
23  hoverSha: string | null
24  sel: number
25  hover: number
26  train: { x: number; y: number }
27  target: { x: number; y: number } | null
28  drag: { x: number; offX: number; moved: boolean } | null
29  ready: boolean
30  // The real size the view last followed the train at; 0×0 is the call before layout.
31  followedAt: string
32}
33
34const FRAME_MS = 33
35const POP_FRAMES = 10
36const PAN_STEP = 8
37const FOOTER_ROWS = 3
38
39function fresh(): View {
40  return {
41    seq: -1,
42    layout: null,
43    followedAt: '',
44    known: new Set(),
45    pops: new Map(),
46    offX: 0,
47    offY: 0,
48    follow: true,
49    selSha: null,
50    hoverSha: null,
51    sel: -1,
52    hover: -1,
53    train: { x: 0, y: 0 },
54    target: null,
55    drag: null,
56    ready: false,
57  }
58}
59
60/** How far right anything is drawn: tracks, labels, and the train with its cars. */
61function contentWidth(v: View, cars: number): number {
62  if (v.layout === null) return 0
63  let right = v.layout.cols
64  for (const l of v.layout.labels) right = Math.max(right, l.x + l.text.length + 1)
65  if (v.layout.train !== null) right = Math.max(right, v.layout.train.x + 4 + cars)
66  return right
67}
68
69function clampX(v: View, width: number, cars: number): void {
70  v.offX = Math.max(0, Math.min(Math.max(0, contentWidth(v, cars) - width + 2), v.offX))
71}
72
73/** Keeps row `y` inside the visible rows. */
74function showRow(v: View, y: number, rows: number): void {
75  if (y < v.offY) v.offY = Math.max(0, y - 1)
76  else if (y >= v.offY + rows) v.offY = Math.max(0, y - rows + 2)
77}
78
79function followHead(v: View, width: number, rows: number, cars: number): void {
80  if (v.layout === null) return
81  const anchor = v.target?.x ?? v.layout.cols
82  v.offX = anchor - Math.floor(width * 0.7)
83  clampX(v, width, cars)
84  if (v.target !== null) showRow(v, Math.round(v.target.y), rows)
85}
86
87function nearest(v: View, x: number, y: number): number {
88  if (v.layout === null) return -1
89  let best = -1
90  let bestDistance = 3
91  v.layout.stations.forEach((s, i) => {
92    const d = Math.abs(s.x - x) + Math.abs(s.y - y) * 2
93    if (d < bestDistance) {
94      best = i
95      bestDistance = d
96    }
97  })
98  return best
99}
100
101// Stations left to right, so up and down walk time.
102function stepSelection(v: View, by: number): number {
103  if (v.layout === null || v.layout.stations.length === 0) return -1
104  const order = [...v.layout.stations.keys()].sort((a, b) => (v.layout?.stations[a]?.x ?? 0) - (v.layout?.stations[b]?.x ?? 0))
105  const at = order.indexOf(v.sel)
106  const next = at === -1 ? order.length - 1 : Math.max(0, Math.min(order.length - 1, at + by))
107  return order[next] ?? -1
108}
109
110function stationOf(v: View, i: number): Station | undefined {
111  return i < 0 ? undefined : v.layout?.stations[i]
112}
113
114const TransitMap: ClientModule<MapProps, View> = (props, surface: ClientSurface<View>) => {
115  const { Box, Text } = surface.elements
116  const isFirst = surface.state === undefined
117  const v = surface.state ?? fresh()
118  // The current size, read when it is needed: handlers outlive the call that set them up.
119  const widthNow = () => Math.max(10, surface.columns)
120  const rowsNow = () => Math.max(1, surface.rows - FOOTER_ROWS)
121  const carsNow = () => props.graph?.cars.length ?? 0
122  const width = widthNow()
123  const mapRows = rowsNow()
124
125  // Set up once per instance, while there is no state yet; the state object then carries the view.
126  if (isFirst) {
127    v.ready = true
128    surface.every(FRAME_MS, () => {
129      let moving = false
130      if (v.target !== null) {
131        const dx = v.target.x - v.train.x
132        const dy = v.target.y - v.train.y
133        if (Math.abs(dx) > 0.05 || Math.abs(dy) > 0.05) {
134          v.train = { x: v.train.x + dx * 0.22, y: v.train.y + dy * 0.22 }
135          moving = true
136        } else if (v.train.x !== v.target.x || v.train.y !== v.target.y) {
137          v.train = { ...v.target }
138          moving = true
139        }
140      }
141      for (const [sha, left] of v.pops) {
142        if (left <= 1) v.pops.delete(sha)
143        else v.pops.set(sha, left - 1)
144        moving = true
145      }
146      if (moving) surface.setState(v)
147    })
148    surface.onKey((e: ClientKeyEvent) => {
149      const w = widthNow()
150      if (e.key === 'left') {
151        v.follow = false
152        v.offX = Math.max(0, v.offX - PAN_STEP)
153      } else if (e.key === 'right') {
154        v.follow = false
155        v.offX += PAN_STEP
156        clampX(v, w, carsNow())
157      } else if (e.key === 'pageup') {
158        v.follow = false
159        v.offY = Math.max(0, v.offY - 2)
160      } else if (e.key === 'pagedown') {
161        v.follow = false
162        // The same limit the painter uses: the sharing-label row under the last lane can be paged into view.
163        v.offY = Math.min(Math.max(0, (v.layout?.rows ?? 0) + 1 - rowsNow()), v.offY + 2)
164      } else if (e.key === 'home') {
165        v.follow = false
166        v.offX = 0
167      } else if (e.key === 'end' || e.key === 'f') {
168        v.follow = true
169        followHead(v, w, rowsNow(), carsNow())
170      } else if (e.key === 'up' || e.key === 'down') {
171        v.sel = stepSelection(v, e.key === 'down' ? -1 : 1)
172        const s = stationOf(v, v.sel)
173        if (s !== undefined) {
174          v.selSha = s.sha
175          if (s.x < v.offX + 2 || s.x > v.offX + w - 4) v.offX = Math.max(0, s.x - Math.floor(w / 2))
176          showRow(v, s.y, rowsNow())
177          v.follow = false
178          surface.post({ select: s.sha })
179        }
180      } else if (e.key === 'return') {
181        const s = stationOf(v, v.sel)
182        if (s !== undefined) surface.post({ select: s.sha })
183      } else {
184        return
185      }
186      surface.setState(v)
187    })
188    surface.onPointer((e: ClientPointerEvent) => {
189      if (e.type === 'down') {
190        v.drag = { x: e.x, offX: v.offX, moved: false }
191      } else if (e.type === 'move' && e.button !== undefined && v.drag !== null) {
192        const shift = v.drag.x - e.x
193        if (shift !== 0) {
194          v.drag.moved = true
195          v.follow = false
196          v.offX = v.drag.offX + shift
197          clampX(v, widthNow(), carsNow())
198        }
199      } else if (e.type === 'up') {
200        const clicked = v.drag !== null && !v.drag.moved
201        v.drag = null
202        if (clicked) {
203          v.sel = nearest(v, e.x + v.offX, e.y + v.offY)
204          const s = stationOf(v, v.sel)
205          v.selSha = s?.sha ?? null
206          if (s !== undefined) surface.post({ select: s.sha })
207        }
208      } else if (e.type === 'move') {
209        const over = nearest(v, e.x + v.offX, e.y + v.offY)
210        if (over === v.hover) return
211        v.hover = over
212        v.hoverSha = stationOf(v, over)?.sha ?? null
213      } else {
214        return
215      }
216      surface.setState(v)
217    })
218  }
219
220  // A new refresh: lay out again, pop in new stations, move the train.
221  const graph = props.graph
222  if (graph !== null && graph.seq !== v.seq) {
223    const first = v.seq === -1
224    v.seq = graph.seq
225    v.layout = layoutGraph(graph)
226    for (const c of graph.commits) {
227      if (!first && !v.known.has(c.sha)) v.pops.set(c.sha, POP_FRAMES)
228      v.known.add(c.sha)
229    }
230    const t = v.layout.train
231    v.target = t === null ? null : { x: t.x, y: t.y }
232    if (first && t !== null) v.train = { x: t.x, y: t.y }
233    if (v.follow) followHead(v, width, mapRows, graph.cars.length)
234    if (surface.columns > 0 && surface.rows > 0) v.followedAt = `${surface.columns}x${surface.rows}`
235    // The same commits stay selected and hovered; ones that left the map are let go.
236    const indexOf = (sha: string | null) => (sha === null ? -1 : v.layout?.stations.findIndex(s => s.sha === sha) ?? -1)
237    v.sel = indexOf(v.selSha)
238    if (v.sel < 0) v.selSha = null
239    v.hover = indexOf(v.hoverSha)
240    if (v.hover < 0) v.hoverSha = null
241  }
242  // The first call comes before layout (0×0): once a real size arrives, or the pane is resized, follow again.
243  const size = `${surface.columns}x${surface.rows}`
244  if (v.follow && v.layout !== null && surface.columns > 0 && surface.rows > 0 && size !== v.followedAt) {
245    followHead(v, width, mapRows, props.graph?.cars.length ?? 0)
246    v.followedAt = size
247  }
248  // Record the view once, on the first call: later calls reuse this same state object.
249  if (isFirst) surface.setState(v)
250
251  if (graph === null || v.layout === null) {
252    return <Box><Text dimColor>No map yet. Press Enter in the prompt with /metro to load it.</Text></Box>
253  }
254  const layout = v.layout
255
256  // Overlays per row: new stations popping in, the train, the selection.
257  const overlays = new Map<number, Map<number, { c: string; color: string; bold?: boolean; inverse?: boolean }>>()
258  const at = (y: number) => {
259    let row = overlays.get(y)
260    if (row === undefined) {
261      row = new Map()
262      overlays.set(y, row)
263    }
264    return row
265  }
266  for (const s of layout.stations) {
267    const left = v.pops.get(s.sha)
268    if (left !== undefined) at(s.y).set(s.x, { c: left > POP_FRAMES / 2 ? '·' : '∘', color: '#FFFFFF', bold: true })
269  }
270  const train = layout.train
271  if (train !== null) {
272    const tx = Math.round(v.train.x)
273    const ty = Math.round(v.train.y)
274    const row = at(ty)
275    for (let k = 0; k < 3; k += 1) row.set(tx + k, { c: '▶', color: '#FFFFFF', bold: true })
276    // One car per uncommitted file (the props carry up to 60); pan right to see a long train.
277    graph.cars.forEach((car, k) => row.set(tx + 3 + k, { c: '▮', color: car.lit ? '#FCCC0A' : train.color, bold: car.lit }))
278  }
279  for (const label of layout.labels) {
280    // The current line's name rides behind the train.
281    const shift = train !== null && label.y === train.y && label.x === train.x ? 4 + graph.cars.length : 0
282    const row = at(label.y)
283    for (let k = 0; k < label.text.length; k += 1) {
284      const x = label.x + shift + k
285      if (!row.has(x)) row.set(x, { c: label.text[k] ?? ' ', color: label.color, bold: true })
286    }
287  }
288  const selected = stationOf(v, v.sel)
289  if (selected !== undefined) {
290    const row = at(selected.y)
291    const cell = selected.y * layout.cols + selected.x
292    row.set(selected.x, { c: layout.ch[cell] ?? '○', color: layout.fg[cell] ?? '#FFFFFF', bold: true, inverse: true })
293  }
294
295  const body: RenderElement[] = []
296  // A window of rows from offY: lower lines scroll into view (PgUp/PgDn, or by selecting a station).
297  v.offY = Math.max(0, Math.min(v.offY, Math.max(0, layout.rows + 1 - mapRows)))
298  for (let y = v.offY; y < Math.min(v.offY + mapRows, layout.rows + 1); y += 1) {
299    const runs = rowRuns(layout, y, v.offX, width, overlays.get(y) ?? new Map())
300    body.push(
301      <Box key={`r${y}`} flexDirection="row">
302        {runs.map((run, k) => (
303          <Text key={`c${k}`} color={run.color ?? undefined} bold={run.bold} inverse={run.inverse}>{run.text}</Text>
304        ))}
305      </Box>,
306    )
307  }
308
309  // Footer: the station under the pointer or selected, then the line legend.
310  const focus = stationOf(v, v.hover) ?? selected
311  const commit = focus === undefined ? undefined : graph.commits[focus.idx]
312  const detail = props.detail !== null && commit !== undefined && props.detail.sha === commit.sha ? props.detail : null
313  const info = commit === undefined
314    ? '↑↓ stations · ←→ pan · PgUp/PgDn lines · drag to pan · click a station · f follow the train'
315    : `${commit.sha.slice(0, 8)} ${commit.subject} — ${commit.author}, ${ago(commit.time, Date.now())}`
316  const files = detail === null ? '' : detail.files.length === 0 ? 'no files' : `files: ${detail.files.slice(0, 8).join(', ')}${detail.files.length > 8 ? ` +${detail.files.length - 8}` : ''}`
317  const legend = layout.lanes.filter(l => !l.name.startsWith('·')).slice(0, 8)
318
319  return (
320    <Box flexDirection="column">
321      {body}
322      <Text wrap="truncate-end" bold={commit !== undefined}>{info}</Text>
323      <Text wrap="truncate-end" dimColor>{files === '' ? ' ' : files}</Text>
324      <Box flexDirection="row" overflow="hidden">
325        {legend.map(lane => (
326          <Text key={`l${lane.name}`} color={lane.color}>{`${lane.dashed ? '╍╍' : '━━'} ${lane.name}  `}</Text>
327        ))}
328      </Box>
329    </Box>
330  )
331}
332
333export default TransitMap
334
types/index.d.ts 48 lines
1// One commit as the map needs it. `refs` are local branch names, `remotes`
2// remote-tracking names (origin/main), `tags` tag names.
3export type Commit = {
4  sha: string
5  parents: string[]
6  author: string
7  time: number
8  subject: string
9  refs: string[]
10  remotes: string[]
11  tags: string[]
12}
13
14// One uncommitted path: its porcelain status code and whether Claude edited it
15// this session (its train car lights up).
16export type Car = { path: string; code: string; lit: boolean }
17
18export type Graph = {
19  // Bumped on every refresh, so the map knows when to lay out again.
20  seq: number
21  repo: string
22  // The repository's top-level folder, so a refresh in another checkout redraws the whole map.
23  root: string
24  // Newest first, topological order, capped.
25  commits: Commit[]
26  truncated: boolean
27  head: string | null
28  branch: string | null
29  upstream: string | null
30  ahead: number
31  behind: number
32  cars: Car[]
33}
34
35export type Detail = { sha: string; author: string; date: string; subject: string; files: string[] }
36
37declare module 'claude-code' {
38  interface PluginState {
39    'transit-map': {
40      graph: Graph | null
41      detail: Detail | null
42      strip: boolean
43      lit: string[]
44      error: string | null
45    }
46  }
47}
48