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…


/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.
main, master) is always red.○ stations, merges are ◎ interchanges, and tags are ▣ termini with their name above.╍.▶▶▶ past HEAD on your current line, with one car ▮ per uncommitted file (up to 60). Cars light up yellow when Claude edits that file./metro opens the map in a pane:
| Key | Action |
|---|---|
← →, or drag | pan |
↑ ↓ | walk the stations |
PgUp PgDn | scroll lines into view |
click or Enter | show a commit's hash, author, subject and files |
f | follow 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.
/plugin marketplace add ccdwyer/claude-mods
/plugin install transit-map@ccdwyer-mods
/reload-plugins
Events this mod hooks, as claude plugin validate reads the module:
session.startcommand.run{command=metro}tool.callturn.completeui.messageui.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.
It runs entirely on your machine, using local git only. It sends nothing over the network. Full policy: PRIVACY.md.
MIT
hooks/register.tsx 318 lines1import { 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}
318hooks/metro.ts 335 lines1// 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}
335hooks/map.tsx 334 lines1// 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
334types/index.d.ts 48 lines1// 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