A Claude Code mod: walk a Git repository worktree by worktree and commit by commit in a pane, each changed file as a diff or as the whole file before and…

Commit Walk is a Claude Code mod, a plugin that runs inside Claude Code and draws its own pane, for walking a Git repository worktree by worktree and commit by commit. Pick a worktree, a commit and one of its changed files, then read the file as a diff, or as the whole file after or before the commit.
It is built for watching an agent's work as it lands. The pane follows new commits while Claude edits, shows what is not committed yet, staged and unstaged, one step after the newest commit, and it can step back through history without leaving the session.
Everything comes from git and is only read. The plugin gives the model no tool, writes nothing to the repository and makes no network requests.
/plugin install commit-walk --marketplace mahdava/commit-walk
Answer y when it asks to add the marketplace. There is no need to clone this repository: the command fetches Commit Walk from GitHub into Claude Code's own plugins folder. Cloning is only for working on the plugin itself, as docs/DEVELOP.md describes.
You need Claude Code 2.1.292 or later, which runs mods, and Git 2.36 or later. Commit Walk works in Claude Code in the terminal, the desktop app, VS Code and the mobile app. Installing from a shell or the desktop app, and removing it, are in docs/INSTALL.md.
From a shell:
claude plugin marketplace update commit-walk
claude plugin update commit-walk@commit-walk
Then run /reload-plugins in any open session, or start a new one.
Run /commit-walk in a Claude Code session started inside a Git repository. It opens the pane, or closes it when it is open. The pane takes the keyboard when it opens; later, click it, or press ctrl+x then Tab, to give the keyboard back to it.
Every row and button can also be clicked: a list unfolds when you click its row, and folds again when you pick an entry.
| Key | Action |
|---|---|
o / n | Older commit / newer commit |
h | Commit range: all, not in main, since the base branch, in turn |
j / k | Next file / previous file |
d / a / b | File diff / file after / file before |
r | Refresh |
x | Debug: shows or hides the debug section |
Esc | Close the pane |
Tab | Next control |
Enter | Press the focused row or button |
| Arrows | Scroll the pane |
Found a problem, or have an idea? Open an issue.
The code and documentation are under the MIT License. The logos, the keyboard picture and the sunflower drawings are © Lucia Nazzaro, all rights reserved; ARTWORK-LICENSE.md says what that covers and allows. Third-party work keeps its own licence, as THIRD-PARTY-NOTICES.md lists.
hooks/register.tsx 392 lines1// commit-walk: a pane for reading a repository worktree by worktree and
2// commit by commit, each changed file as a diff or as the whole file before
3// and after. Everything shown comes from git; nothing is written anywhere.
4//
5// This file holds everything that uses `$`: the hooks, the state, loading and
6// stepping. Claude Code before 2.1.300 follows `$` only into functions of the
7// file that holds it, so this part stays in one file. The rest lives in:
8// pane/ the pane, one part per file, put together in pane/index.tsx;
9// it draws from the values read here and calls the actions built here
10// state.ts constants and the first values of the state
11// git.ts the git commands and their parsers
12// labels.ts worktrees, commits and files as one line of text
13// welcome.ts the welcome art; window.ts the rows a long list shows
14//
15// Shape: the pane draws from two values the host keeps for the session
16// (`selection`, where the person is; `snapshot`, what was loaded for it), so
17// a hot reload keeps the place. Loading happens in handlers and events, never
18// while drawing; a load that a newer one overtook is dropped.
19
20import { atom, read, update } from 'claude-code'
21import type { EngineInterface, Register } from 'claude-code'
22
23import type { About, ChangedFile, Commit, Content, Expandable, GitTrace, RangeMode, Ranges, Selection, ViewMode, Worktree } from '../types'
24import { createRepo, type GitRun, type Repo } from './git'
25import { drawFailure, drawPane } from './pane'
26import { frameOf, type Actions, type PaneValues } from './pane/frame'
27import {
28 EMPTY_SNAPSHOT, GIT_TIMEOUT_MS, INITIAL_SELECTION, PANE, PANE_COLUMNS, REFRESH_DELAY_MS, TITLE, TRACE_LENGTH, UNCOMMITTED, WRITING_TOOLS,
29 type Depth,
30} from './state'
31import type { ListScroll } from './window'
32
33const selection = atom({ plugin: 'commit-walk', key: 'selection' } as const, INITIAL_SELECTION)
34const snapshot = atom({ plugin: 'commit-walk', key: 'snapshot' } as const, EMPTY_SNAPSHOT)
35const expanded = atom({ plugin: 'commit-walk', key: 'expanded' } as const, null as Expandable | null)
36const scroll = atom({ plugin: 'commit-walk', key: 'scroll' } as const, {} as Record<string, ListScroll>)
37const debugOn = atom({ plugin: 'commit-walk', key: 'debug' } as const, false)
38const trace = atom({ plugin: 'commit-walk', key: 'trace' } as const, [] as GitTrace[])
39const about = atom({ plugin: 'commit-walk', key: 'about' } as const, { pluginVersion: null, engineVersion: null, cwd: '' } as About)
40
41/** The session's working directory: where the repository is looked for. */
42let sessionCwd = ''
43/** Bumped by every load; a load whose ticket is no longer current writes nothing. */
44let generation = 0
45let refreshTimer: { cancel(): void } | null = null
46/** How deep the pending refresh reads, while one is pending. */
47let refreshDepth: 'repo' | 'changes' | null = null
48
49// ── The hooks ────────────────────────────────────────────────────────────
50
51export const register: Register = on => {
52 on('session.start', async ($, e, next) => {
53 sessionCwd = e.cwd
54 await update($, about, () => ({ pluginVersion: null, engineVersion: null, cwd: e.cwd }))
55 inBackground($, aboutHere($, e.cwd).then(found => update($, about, () => found)))
56 // Named after the plugin. (/review is refused: it is an alias of the built-in /code-review.)
57 try {
58 await $.command.register({
59 name: 'commit-walk',
60 description: 'Open or close the commit walk pane: worktrees, commits and files of this repository',
61 argumentHint: '[refresh|close|debug]',
62 immediate: true,
63 })
64 } catch (cause) {
65 $.ui.log(`commit-walk: /commit-walk is unavailable: ${messageOf(cause)}`)
66 }
67 return next(e)
68 })
69
70 on('command.run', { command: 'commit-walk' }, async ($, e) => {
71 const arg = e.args.trim()
72 const isOpen = await isPaneOpen($)
73 if (arg === 'close' || (arg === '' && isOpen)) {
74 cancelRefresh()
75 await $.ui.close({ id: PANE })
76 return { text: 'Commit walk closed.' }
77 }
78 if (arg === 'debug') {
79 const isOn = !(await read($, debugOn))
80 await update($, debugOn, () => isOn)
81 if (!isOpen) {
82 await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true, columns: PANE_COLUMNS })
83 await load($, 'repo')
84 }
85 return { text: `Commit walk debug section ${isOn ? 'shown' : 'hidden'}.` }
86 }
87 if (arg !== '' && arg !== 'refresh') return { text: 'Usage: /commit-walk [refresh|close|debug]' }
88
89 // Open first so the pane shows its loading line, then answer once it has loaded.
90 const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true, columns: PANE_COLUMNS })
91 await load($, 'repo')
92 if (!opened.isPlaced) return { text: `Commit walk is open but not drawn yet: ${opened.reason}` }
93 return { text: arg === 'refresh' ? 'Commit walk refreshed.' : 'Commit walk opened.' }
94 })
95
96 on('ui.close', { id: PANE }, async ($, e, next) => {
97 cancelRefresh()
98 return next(e)
99 })
100
101 on('tool.call', { tool: WRITING_TOOLS }, async ($, e, next) => {
102 const result = await next(e)
103 if (!result.deny && result.isError !== true && result.isReadOnly !== true) scheduleRefresh($, 'changes')
104 return result
105 })
106
107 on('turn.complete', async ($, e, next) => {
108 const result = await next(e)
109 scheduleRefresh($, 'repo')
110 return result
111 })
112
113 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
114 const place = { bodyColumns: e.props.bodyColumns, bodyRows: e.props.scroll.bodyRows, placement: e.props.placement, surface: e.surface }
115 const ui = $.ui.resolve(e)
116 try {
117 return drawPane(frameOf(await paneValues($), actionsFor($), ui, place))
118 } catch (cause) {
119 return drawFailure(ui, messageOf(cause))
120 }
121 })
122}
123
124// ── What the pane draws from, and what its buttons do ────────────────────
125
126async function paneValues($: EngineInterface): Promise<PaneValues> {
127 const isDebug = await read($, debugOn)
128 return {
129 sel: await read($, selection),
130 snap: await read($, snapshot),
131 open: await read($, expanded),
132 scrolled: await read($, scroll),
133 isDebug,
134 gitRuns: isDebug ? await read($, trace) : [],
135 where: isDebug ? await read($, about) : null,
136 }
137}
138
139function actionsFor($: EngineInterface): Actions {
140 return {
141 load: (depth, change) => inBackground($, load($, depth, change)),
142 stepCommit: delta => inBackground($, stepCommit($, delta)),
143 stepFile: delta => inBackground($, stepFile($, delta)),
144 toggleDebug: () => inBackground($, update($, debugOn, now => !now)),
145 toggleList: which => inBackground($, update($, expanded, now => (now === which ? null : which))),
146 foldLists: () => inBackground($, update($, expanded, () => null)),
147 scrollList: (prefix, at) => inBackground($, update($, scroll, all => ({ ...all, [prefix]: at }))),
148 }
149}
150
151// ── Git through the host ─────────────────────────────────────────────────
152
153function repoFor($: EngineInterface): Repo {
154 return createRepo(async (cwd, args): Promise<GitRun> => {
155 const started = Date.now()
156 // No optional locks: reading must never contend with an agent's index writes.
157 const result = await $.process.run(['git', '-c', 'core.quotepath=off', '--no-pager', ...args], {
158 cwd,
159 timeoutMs: GIT_TIMEOUT_MS,
160 env: { GIT_OPTIONAL_LOCKS: '0', GIT_PAGER: 'cat', LC_ALL: 'C' },
161 })
162 const entry: GitTrace = {
163 args: args.join(' '),
164 cwd,
165 exitCode: result.exitCode,
166 ms: Date.now() - started,
167 error: result.exitCode === 0 ? null : result.stderr.trim().split('\n')[0] ?? '',
168 }
169 await update($, trace, list => [...list, entry].slice(-TRACE_LENGTH))
170 return result
171 })
172}
173
174/** The plugin's version from its manifest and the engine's, for the debug section; null where unknown. */
175async function aboutHere($: EngineInterface, cwd: string): Promise<About> {
176 let pluginVersion: string | null = null
177 let engineVersion: string | null = null
178 try {
179 const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: unknown }
180 pluginVersion = typeof manifest.version === 'string' ? manifest.version : null
181 } catch {
182 pluginVersion = null
183 }
184 try {
185 // Absent before Claude Code 2.1.300: the call rejects there and the version reads unknown.
186 engineVersion = (await $.session.version()).version
187 } catch {
188 engineVersion = null
189 }
190 return { pluginVersion, engineVersion, cwd }
191}
192
193function messageOf(cause: unknown): string {
194 return cause instanceof Error ? cause.message : String(cause)
195}
196
197/**
198 * Starts `work` without waiting for it. A failure shows in the pane's error
199 * line instead of vanishing, or in the transcript when even that fails.
200 */
201function inBackground($: EngineInterface, work: Promise<unknown>) {
202 void work.catch(async cause => {
203 try {
204 await update($, snapshot, snap => ({ ...snap, isLoading: false, error: messageOf(cause) }))
205 } catch {
206 try {
207 $.ui.log(`commit-walk: ${messageOf(cause)}`)
208 } catch {
209 // Nowhere left to say it.
210 }
211 }
212 })
213}
214
215// ── Loading ──────────────────────────────────────────────────────────────
216
217async function contentFor(repo: Repo, cwd: string, commit: Commit, file: ChangedFile, mode: ViewMode): Promise<Content> {
218 if (mode === 'after') return repo.after(cwd, commit, file)
219 if (mode === 'before') return repo.before(cwd, commit, file)
220 return repo.diff(cwd, commit, file)
221}
222
223async function uncommittedContentFor(repo: Repo, cwd: string, file: ChangedFile, mode: ViewMode, head: string | null): Promise<Content> {
224 if (mode === 'after') return repo.uncommittedAfter(cwd, file)
225 if (mode === 'before') return repo.uncommittedBefore(cwd, file, head)
226 return repo.uncommittedDiff(cwd, file)
227}
228
229function chooseWorktree(worktrees: Worktree[], wanted: string | null, root: string): Worktree | undefined {
230 return worktrees.find(wt => wt.path === wanted) ?? worktrees.find(wt => wt.path === root) ?? worktrees[0]
231}
232
233async function load($: EngineInterface, depth: Depth, change: Partial<Selection> = {}) {
234 const ticket = ++generation
235 const was = await read($, selection)
236 const before = await read($, snapshot)
237 const want: Selection = { ...was, ...change }
238 await update($, snapshot, snap => ({ ...snap, isLoading: true }))
239
240 // Kept outside the try: a failure part way keeps what did load and says what failed.
241 let { repoRoot, worktrees, commits, ranges, files, uncommitted, head } = before
242 try {
243 const repo = repoFor($)
244
245 // A refresh after an edit reads the commits again only when HEAD moved.
246 let level = depth
247 if (level === 'changes') {
248 const shown = repoRoot === null ? undefined : chooseWorktree(worktrees, want.worktree, repoRoot)
249 if (!shown || (await repo.head(shown.path)) !== before.head) level = 'repo'
250 }
251
252 if (level === 'repo' || repoRoot === null) {
253 repoRoot = await repo.toplevel(sessionCwd || '.')
254 worktrees = await repo.worktrees(repoRoot)
255 }
256 const worktree = chooseWorktree(worktrees, want.worktree, repoRoot)
257 if (!worktree) throw new Error('No worktree to show.')
258 const isNewWorktree = worktree.path !== was.worktree
259
260 const isNewRange = want.range !== was.range
261 const isRepo = level === 'repo' || repoRoot !== before.repoRoot || isNewWorktree
262 if (isRepo) {
263 ranges = await repo.ranges(worktree.path)
264 }
265 // A range the worktree does not offer reads as its whole history.
266 const range: RangeMode = rangeRef(ranges, want.range) === null ? 'all' : want.range
267 if (isRepo || isNewRange) {
268 commits = await repo.log(worktree.path, rangeRef(ranges, range))
269 }
270 if (isRepo || level === 'changes') {
271 head = await repo.head(worktree.path)
272 uncommitted = await repo.uncommitted(worktree.path)
273 }
274
275 // An explicit pick wins; otherwise keep the place, or follow the newest commit. The
276 // uncommitted changes are kept while there are any, and shown when there is no commit.
277 const keepCommit = 'commit' in change ? want.commit : isNewWorktree || was.isFollowing ? null : was.commit
278 const isUncommitted = uncommitted.length > 0 && (keepCommit === UNCOMMITTED || commits.length === 0)
279 const commit = isUncommitted ? undefined : (commits.find(one => one.sha === keepCommit) ?? commits[0])
280 const wasHere = isUncommitted ? was.commit === UNCOMMITTED : commit !== undefined && commit.sha === was.commit
281
282 if (isUncommitted) {
283 files = uncommitted
284 } else if (commit && (!wasHere || isNewWorktree || level === 'repo' || level === 'commit')) {
285 files = await repo.changes(worktree.path, commit)
286 } else if (!commit) {
287 files = []
288 }
289 const keepFile = 'file' in change ? want.file : wasHere ? was.file : null
290 const file = files.find(one => one.path === keepFile) ?? files[0]
291
292 // A refresh that left a commit's file and view as they were keeps what they show.
293 const isSameView = level === 'changes' && !isUncommitted && wasHere && file?.path === was.file && want.mode === was.mode
294 let content: Content | null = null
295 if (isSameView) content = before.content
296 else if (isUncommitted && file) content = await uncommittedContentFor(repo, worktree.path, file, want.mode, head)
297 else if (commit && file) content = await contentFor(repo, worktree.path, commit, file, want.mode)
298 else if (commit) content = { kind: 'note', text: 'This commit changes no files (an empty or merge-only commit).' }
299 else content = { kind: 'note', text: 'This worktree has no commits yet.' }
300
301 if (ticket !== generation) return
302 await update($, selection, () => ({
303 worktree: worktree.path,
304 commit: isUncommitted ? UNCOMMITTED : (commit?.sha ?? null),
305 file: file?.path ?? null,
306 mode: want.mode,
307 isFollowing: commit !== undefined && commit.sha === commits[0]?.sha,
308 // The preference, not the fallback: a worktree without the range does not reset it.
309 range: want.range,
310 }))
311 await update($, snapshot, () => ({
312 repoRoot,
313 worktrees,
314 commits,
315 ranges,
316 range,
317 files,
318 uncommitted,
319 head,
320 content,
321 isLoading: false,
322 error: null,
323 loadedAt: Date.now(),
324 }))
325 } catch (cause) {
326 if (ticket !== generation) return
327 const reached = { repoRoot, worktrees, commits, ranges, uncommitted, head }
328 await update($, snapshot, snap => ({ ...snap, ...reached, isLoading: false, error: messageOf(cause) }))
329 }
330}
331
332/** The ref a range starts after, or null for the whole history or a range the worktree does not offer. */
333function rangeRef(ranges: Ranges, mode: RangeMode): string | null {
334 if (mode === 'notInDefault') return ranges.notInDefault?.ref ?? null
335 if (mode === 'base') return ranges.base?.ref ?? null
336 return null
337}
338
339// ── Refreshing ───────────────────────────────────────────────────────────
340
341async function isPaneOpen($: EngineInterface): Promise<boolean> {
342 return (await $.ui.panes()).some(pane => pane.id === PANE)
343}
344
345/**
346 * A refresh shortly after an edit (`changes`) or a turn's end (`repo`, which
347 * also sees new worktrees and branches). Refreshes that come close together
348 * run once, as deep as the deepest of them.
349 */
350function scheduleRefresh($: EngineInterface, depth: 'repo' | 'changes') {
351 const deepest = refreshDepth === 'repo' ? 'repo' : depth
352 cancelRefresh()
353 refreshDepth = deepest
354 refreshTimer = $.clock.after(REFRESH_DELAY_MS, () => {
355 refreshTimer = null
356 refreshDepth = null
357 inBackground($, refreshIfOpen($, deepest))
358 })
359}
360
361function cancelRefresh() {
362 refreshTimer?.cancel()
363 refreshTimer = null
364 refreshDepth = null
365}
366
367async function refreshIfOpen($: EngineInterface, depth: 'repo' | 'changes') {
368 if (await isPaneOpen($)) await load($, depth)
369}
370
371// ── Stepping (handlers: they read the current values, never drawn ones) ──
372
373/** One step older (1) or newer (-1); the uncommitted changes, when there are any, come after the newest commit. */
374async function stepCommit($: EngineInterface, delta: 1 | -1) {
375 const sel = await read($, selection)
376 const { commits, uncommitted } = await read($, snapshot)
377 const order = [...(uncommitted.length > 0 ? [UNCOMMITTED] : []), ...commits.map(one => one.sha)]
378 const at = order.indexOf(sel.commit ?? '')
379 const target = order[at + delta]
380 if (at < 0 || target === undefined) return
381 await load($, 'commit', { commit: target, file: null })
382}
383
384async function stepFile($: EngineInterface, delta: 1 | -1) {
385 const sel = await read($, selection)
386 const { files } = await read($, snapshot)
387 const at = files.findIndex(one => one.path === sel.file)
388 const target = files[at + delta]
389 if (at < 0 || !target) return
390 await load($, 'content', { file: target.path })
391}
392hooks/git.ts 522 lines1// Git for the commit-walk pane: the commands it runs and the parsers for
2// their output. No engine calls here: `createRepo` takes the runner, so the
3// tests drive it with canned git output.
4
5import type { ChangedFile, Commit, Content, FileStatus, Ranges, Stage, Worktree } from '../types'
6
7/** What one git run answers (the shape of `$.process.run`'s result). */
8export type GitRun = { exitCode: number; stdout: string; stderr: string; isStdoutTruncated?: boolean }
9
10/** Runs `git <args>` in `cwd`. */
11export type GitRunner = (cwd: string, args: readonly string[]) => Promise<GitRun>
12
13/** The most characters the code area is handed: the drawing holds 100,000 in all. */
14export const SOURCE_BUDGET = 60_000
15/** Commits listed per worktree. */
16export const MAX_COMMITS = 100
17/** Context lines when a full-file diff does not fit the budget. */
18const NEAR_CONTEXT = 20
19/** A blob larger than this is not read into the pane. */
20const MAX_BLOB_BYTES = 4_000_000
21/** Untracked files whose lines are counted, one git run each; the rest read as "new file". */
22const MAX_UNTRACKED_COUNTED = 50
23/** Context that takes in a whole file: the diff then holds every line of both sides. */
24const WHOLE_FILE = 1_000_000
25
26const FIELD = '\x1f'
27const LOG_FORMAT = ['%H', '%h', '%P', '%an', '%ar', '%s'].join('%x1f')
28const STATUSES = new Set<FileStatus>(['A', 'M', 'D', 'R', 'C', 'T', 'U'])
29
30export class GitError extends Error {
31 constructor(args: readonly string[], run: GitRun) {
32 const why = run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`
33 super(`git ${args[0] ?? ''}: ${why}`)
34 this.name = 'GitError'
35 }
36}
37
38// ── Parsers ──────────────────────────────────────────────────────────────
39
40/** `git worktree list --porcelain -z`: NUL-ended lines, an empty one between records. */
41export function parseWorktrees(text: string): Worktree[] {
42 const out: Worktree[] = []
43 let record: string[] = []
44 const flush = () => {
45 const path = record.find(line => line.startsWith('worktree '))?.slice(9)
46 const isBare = record.includes('bare')
47 const isPrunable = record.some(line => line === 'prunable' || line.startsWith('prunable '))
48 if (path && !isBare && !isPrunable) {
49 const ref = record.find(line => line.startsWith('branch '))?.slice(7)
50 out.push({
51 path,
52 head: record.find(line => line.startsWith('HEAD '))?.slice(5) ?? '',
53 branch: ref ? ref.replace(/^refs\/heads\//, '') : null,
54 isLocked: record.some(line => line === 'locked' || line.startsWith('locked ')),
55 })
56 }
57 record = []
58 }
59 for (const line of text.split('\0')) {
60 if (line === '') flush()
61 else record.push(line)
62 }
63 flush()
64 return out
65}
66
67/** `git log -z --format=LOG_FORMAT`: NUL between commits, 0x1f between fields. */
68export function parseLog(text: string): Commit[] {
69 return text
70 .split('\0')
71 .map(record => record.replace(/^\n+|\n+$/g, ''))
72 .filter(record => record !== '')
73 .map(record => {
74 const [sha = '', shortSha = '', parents = '', author = '', when = '', ...subject] = record.split(FIELD)
75 return {
76 sha,
77 shortSha,
78 parents: parents.split(' ').filter(Boolean),
79 author,
80 when,
81 subject: subject.join(FIELD),
82 }
83 })
84 .filter(commit => /^[0-9a-f]{7,64}$/.test(commit.sha))
85}
86
87type NameStatus = { status: FileStatus; path: string; oldPath: string | null }
88
89/** `git diff-tree -z --name-status`: a status token, then one path (two for R and C). */
90export function parseNameStatus(text: string): NameStatus[] {
91 const tokens = text.split('\0')
92 const out: NameStatus[] = []
93 for (let i = 0; i < tokens.length; i++) {
94 const token = tokens[i] ?? ''
95 const letter = token.charAt(0) as FileStatus
96 if (!STATUSES.has(letter)) continue
97 if (letter === 'R' || letter === 'C') {
98 const from = tokens[i + 1] ?? ''
99 const to = tokens[i + 2] ?? ''
100 i += 2
101 if (to !== '') out.push({ status: letter, path: to, oldPath: from })
102 } else {
103 const path = tokens[i + 1] ?? ''
104 i += 1
105 if (path !== '') out.push({ status: letter, path, oldPath: null })
106 }
107 }
108 return out
109}
110
111type NumStat = { path: string; added: number; deleted: number; isBinary: boolean }
112
113/** `git diff-tree -z --numstat`: `a\td\tpath`, or `a\td\t` then old and new paths for a rename. */
114export function parseNumstat(text: string): NumStat[] {
115 const tokens = text.split('\0')
116 const out: NumStat[] = []
117 for (let i = 0; i < tokens.length; i++) {
118 const match = /^(-|\d+)\t(-|\d+)\t(.*)$/s.exec(tokens[i] ?? '')
119 if (!match) continue
120 const [, added = '', deleted = '', inline = ''] = match
121 let path = inline
122 if (inline === '') {
123 path = tokens[i + 2] ?? ''
124 i += 2
125 }
126 const isBinary = added === '-' && deleted === '-'
127 out.push({ path, added: isBinary ? 0 : Number(added), deleted: isBinary ? 0 : Number(deleted), isBinary })
128 }
129 return out
130}
131
132type StatusEntry = { xy: string; path: string; oldPath: string | null }
133
134/**
135 * `git status --porcelain=v1 -z`: `XY path`, NUL-ended; a rename or copy is
136 * followed by its old path. X is the staged change, Y the unstaged one.
137 */
138export function parseStatus(text: string): StatusEntry[] {
139 const tokens = text.split('\0')
140 const out: StatusEntry[] = []
141 for (let i = 0; i < tokens.length; i++) {
142 const token = tokens[i] ?? ''
143 if (token.length < 4 || token.charAt(2) !== ' ') continue
144 const xy = token.slice(0, 2)
145 const path = token.slice(3)
146 const isMoved = /[RC]/.test(xy)
147 const oldPath = isMoved ? (tokens[i + 1] ?? null) : null
148 if (isMoved) i += 1
149 out.push({ xy, path, oldPath })
150 }
151 return out
152}
153
154/** The one letter a file row shows for an uncommitted file's status: the staged change's, else the unstaged one's. */
155export function statusOf(xy: string): FileStatus {
156 if (xy === '??') return 'A'
157 const letter = (xy.charAt(0) === ' ' ? xy.charAt(1) : xy.charAt(0)) as FileStatus
158 return STATUSES.has(letter) ? letter : 'M'
159}
160
161/**
162 * The new side of a whole-file diff (`--unified` past the file's length):
163 * its unchanged and added lines, in order. How the pane shows a file as it
164 * is on disk using git alone.
165 */
166export function afterFromHunks(hunks: string): string {
167 const kept: string[] = []
168 for (const line of hunks.split('\n')) {
169 if (line.startsWith(' ') || line.startsWith('+')) kept.push(line.slice(1))
170 }
171 return kept.join('\n')
172}
173
174/** Joins name-status and numstat rows by path. */
175export function joinChanges(names: NameStatus[], stats: NumStat[]): ChangedFile[] {
176 const byPath = new Map(stats.map(stat => [stat.path, stat]))
177 return names.map(name => {
178 const stat = byPath.get(name.path)
179 return {
180 ...name,
181 isBinary: stat?.isBinary ?? false,
182 added: stat?.added ?? 0,
183 deleted: stat?.deleted ?? 0,
184 }
185 })
186}
187
188// ── Fitting text into the pane ───────────────────────────────────────────
189
190/** The hunks of a one-file patch: everything from the first `@@` line on. */
191export function hunksOf(patch: string): string {
192 const lines = patch.split('\n')
193 const first = lines.findIndex(line => line.startsWith('@@'))
194 return first < 0 ? '' : lines.slice(first).join('\n').replace(/\n+$/, '')
195}
196
197const HUNK = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/
198
199/**
200 * Keeps whole lines of `hunks` up to `budget` characters. A hunk cut short
201 * gets a header counting the lines kept, so it still parses as a hunk.
202 */
203export function fitHunks(hunks: string, budget = SOURCE_BUDGET): { source: string; isTruncated: boolean } {
204 if (hunks.length <= budget) return { source: hunks, isTruncated: false }
205 const lines = hunks.split('\n')
206 const kept: string[] = []
207 let used = 0
208 let header = -1
209 let oldCount = 0
210 let newCount = 0
211 for (const line of lines) {
212 if (used + line.length + 1 > budget) break
213 if (line.startsWith('@@')) {
214 header = kept.length
215 oldCount = 0
216 newCount = 0
217 } else if (line.startsWith('-')) oldCount++
218 else if (line.startsWith('+')) newCount++
219 else if (line.startsWith(' ')) {
220 oldCount++
221 newCount++
222 }
223 kept.push(line)
224 used += line.length + 1
225 }
226 if (header >= 0) {
227 const match = HUNK.exec(kept[header] ?? '')
228 if (match) {
229 const [, oldStart = '1', newStart = '1', rest = ''] = match
230 kept[header] = `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@${rest}`
231 }
232 }
233 while (kept.length > 0 && (kept[kept.length - 1] ?? '').startsWith('@@')) kept.pop()
234 return { source: kept.join('\n'), isTruncated: true }
235}
236
237/** Keeps whole lines of `text` up to `budget` characters. */
238export function fitText(text: string, budget = SOURCE_BUDGET): { source: string; isTruncated: boolean } {
239 const trimmed = text.replace(/\n+$/, '')
240 if (trimmed.length <= budget) return { source: trimmed, isTruncated: false }
241 const cut = trimmed.lastIndexOf('\n', budget)
242 return { source: trimmed.slice(0, cut > 0 ? cut : budget), isTruncated: true }
243}
244
245// ── The repository ───────────────────────────────────────────────────────
246
247export function createRepo(run: GitRunner) {
248 const emptyTrees = new Map<string, string>()
249
250 async function git(cwd: string, args: readonly string[]): Promise<GitRun> {
251 const result = await run(cwd, args)
252 if (result.exitCode !== 0) throw new GitError(args, result)
253 return result
254 }
255
256 /** The tree a root commit is compared with: git's empty tree, in this repo's hash. */
257 async function emptyTree(cwd: string): Promise<string> {
258 const known = emptyTrees.get(cwd)
259 if (known) return known
260 const sha = (await git(cwd, ['hash-object', '-t', 'tree', '/dev/null'])).stdout.trim()
261 emptyTrees.set(cwd, sha)
262 return sha
263 }
264
265 async function baseOf(cwd: string, commit: Commit): Promise<string> {
266 return commit.parents[0] ?? (await emptyTree(cwd))
267 }
268
269 /** A diff command's output. `--no-index` exits 1 when the two sides differ, which is not a failure. */
270 async function gitDiff(cwd: string, args: readonly string[]): Promise<GitRun> {
271 const result = await run(cwd, args)
272 if (result.exitCode === 0 || (result.exitCode === 1 && args.includes('--no-index'))) return result
273 throw new GitError(args, result)
274 }
275
276 /**
277 * A one-file diff fitted to `budget`: the whole file as context when it
278 * fits, else NEAR_CONTEXT lines around each change. Null when there are no
279 * textual changes, `binary` for a binary file.
280 */
281 async function fitPatch(cwd: string, argsFor: (context: number) => string[], budget = SOURCE_BUDGET) {
282 const patch = async (context: number) => {
283 const out = (await gitDiff(cwd, argsFor(context))).stdout
284 return { hunks: hunksOf(out), isBinary: /^Binary files /m.test(out) }
285 }
286 let { hunks, isBinary } = await patch(WHOLE_FILE)
287 if (isBinary) return 'binary' as const
288 let context: 'full' | 'near' = 'full'
289 if (hunks.length > budget) {
290 hunks = (await patch(NEAR_CONTEXT)).hunks
291 context = 'near'
292 }
293 if (hunks === '') return null
294 return { context, ...fitHunks(hunks, budget) }
295 }
296
297 /** HEAD's SHA, or null before the first commit. */
298 async function head(cwd: string): Promise<string | null> {
299 const found = await run(cwd, ['rev-parse', '--verify', '--quiet', 'HEAD'])
300 return found.exitCode === 0 ? found.stdout.trim() || null : null
301 }
302
303 /** An untracked file's line counts, from git's diff of it against nothing. */
304 async function untrackedStat(cwd: string, path: string): Promise<{ added: number; deleted: number; isBinary: boolean }> {
305 const out = (await gitDiff(cwd, ['diff', '--no-index', '--numstat', '--', '/dev/null', path])).stdout
306 const match = /^(-|\d+)\t(-|\d+)\t/.exec(out)
307 if (!match || match[1] === '-') return { added: 0, deleted: 0, isBinary: match !== null }
308 return { added: Number(match[1]), deleted: Number(match[2]), isBinary: false }
309 }
310
311 /** Reads one blob as text, or says why it is not shown. */
312 async function blob(cwd: string, rev: string, path: string): Promise<Content> {
313 const spec = `${rev}:${path}`
314 const size = Number((await git(cwd, ['cat-file', '-s', spec])).stdout.trim())
315 if (size > MAX_BLOB_BYTES) {
316 return { kind: 'note', text: `${path} is ${Math.round(size / 1_000_000)} MB; too large to show here.` }
317 }
318 const result = await git(cwd, ['cat-file', 'blob', spec])
319 if (result.stdout.slice(0, 8000).includes('\0')) return { kind: 'note', text: 'Binary file: no text to show.' }
320 const fitted = fitText(result.stdout)
321 return { kind: 'file', path, source: fitted.source, isTruncated: fitted.isTruncated || result.isStdoutTruncated === true }
322 }
323
324 return {
325 async toplevel(cwd: string): Promise<string> {
326 return (await git(cwd, ['rev-parse', '--show-toplevel'])).stdout.trim()
327 },
328
329 async worktrees(root: string): Promise<Worktree[]> {
330 return parseWorktrees((await git(root, ['worktree', 'list', '--porcelain', '-z'])).stdout)
331 },
332
333 /**
334 * The ranges a worktree can start its commit list from: the default branch
335 * (origin's HEAD, else main or master, local before origin's) and the base branch, the
336 * nearest other local branch HEAD grew from. Each with the number of
337 * HEAD's commits it does not have; null where there is none.
338 */
339 async ranges(cwd: string): Promise<Ranges> {
340 const branch = (await run(cwd, ['branch', '--show-current'])).stdout.trim() || null
341 const exists = async (ref: string) => (await run(cwd, ['rev-parse', '--verify', '--quiet', ref])).exitCode === 0
342 let defaultRef: string | null = null
343 const originHead = await run(cwd, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
344 if (originHead.exitCode === 0 && originHead.stdout.trim() !== '') defaultRef = originHead.stdout.trim()
345 else {
346 // No origin/HEAD (a clone that never set it): main or master, local before remote.
347 for (const [ref, name] of [['refs/heads/main', 'main'], ['refs/heads/master', 'master'], ['refs/remotes/origin/main', 'origin/main'], ['refs/remotes/origin/master', 'origin/master']] as const) {
348 if (await exists(ref)) {
349 defaultRef = name
350 break
351 }
352 }
353 }
354
355 const countSince = async (ref: string) => {
356 const counted = await run(cwd, ['rev-list', '--count', `${ref}..HEAD`])
357 return counted.exitCode === 0 ? Number(counted.stdout.trim()) : null
358 }
359 const defaultAhead = defaultRef ? await countSince(defaultRef) : null
360 const defaultName = defaultRef?.replace(/^origin\//, '') ?? null
361 // Offered wherever there is a default branch: on it, or on a branch level with it, the
362 // range is empty, and the pane shows its welcome screen until a commit lands.
363 const notInDefault = defaultRef && defaultAhead !== null ? { ref: defaultRef, count: defaultAhead } : null
364 const isOnDefault = defaultName !== null && branch === defaultName
365
366 // One command for every branch's distance (git 2.41+); older git offers no base.
367 const listed = await run(cwd, ['for-each-ref', '--format=%(refname:short)%09%(ahead-behind:HEAD)', 'refs/heads'])
368 let base: { ref: string; count: number } | null = null
369 if (listed.exitCode === 0) {
370 let best: { ref: string; mine: number; theirs: number } | null = null
371 for (const line of listed.stdout.split('\n')) {
372 const [ref = '', counts = ''] = line.split('\t')
373 const [theirs, mine] = counts.split(' ').map(Number)
374 // `mine`: HEAD's commits the branch lacks. 0 means it already holds HEAD (this branch, or one built on it).
375 if (ref === '' || ref === branch || mine === undefined || theirs === undefined || !(mine > 0)) continue
376 if (!best || mine < best.mine || (mine === best.mine && theirs < best.theirs)) best = { ref, mine, theirs }
377 }
378 // A base that is the default branch, or as far back as it, adds nothing to "not in default".
379 if (best && best.ref !== defaultName && (defaultAhead === null || best.mine < defaultAhead)) {
380 base = { ref: best.ref, count: best.mine }
381 }
382 }
383 return { branch, isOnDefault, notInDefault, base }
384 },
385
386 /** The commits of HEAD, newest first; with `since`, only those not reachable from it (`since..HEAD`). */
387 async log(cwd: string, since: string | null = null): Promise<Commit[]> {
388 const args = ['log', '-z', `--max-count=${MAX_COMMITS}`, `--format=${LOG_FORMAT}`, since ? `${since}..HEAD` : 'HEAD', '--']
389 const result = await run(cwd, args)
390 // A branch with no commits yet has nothing to list; anything else is an error.
391 if (result.exitCode !== 0 && /does not have any commits|unknown revision/.test(result.stderr)) return []
392 if (result.exitCode !== 0) throw new GitError(args, result)
393 return parseLog(result.stdout)
394 },
395
396 async changes(cwd: string, commit: Commit): Promise<ChangedFile[]> {
397 const base = await baseOf(cwd, commit)
398 const common = ['diff-tree', '-r', '-M', '-z', '--no-ext-diff', base, commit.sha]
399 const [names, stats] = await Promise.all([
400 git(cwd, [...common, '--name-status']),
401 git(cwd, [...common, '--numstat']),
402 ])
403 return joinChanges(parseNameStatus(names.stdout), parseNumstat(stats.stdout))
404 },
405
406 async diff(cwd: string, commit: Commit, file: ChangedFile): Promise<Content> {
407 if (file.isBinary) return { kind: 'note', text: 'Binary file: no text diff.' }
408 const base = await baseOf(cwd, commit)
409 const paths = file.oldPath && file.oldPath !== file.path ? [file.oldPath, file.path] : [file.path]
410 const fitted = await fitPatch(cwd, context => ['diff-tree', '-p', '-M', '--no-color', '--no-ext-diff', `--unified=${context}`, base, commit.sha, '--', ...paths])
411 if (fitted === 'binary') return { kind: 'note', text: 'Binary file: no text diff.' }
412 if (fitted === null) {
413 const what = file.status === 'R' || file.status === 'C' ? 'Renamed without changes.' : 'No textual changes (mode or type only).'
414 return { kind: 'note', text: what }
415 }
416 return { kind: 'diff', ...fitted }
417 },
418
419 async after(cwd: string, commit: Commit, file: ChangedFile): Promise<Content> {
420 if (file.status === 'D') return { kind: 'note', text: 'This commit deletes the file.' }
421 return blob(cwd, commit.sha, file.path)
422 },
423
424 async before(cwd: string, commit: Commit, file: ChangedFile): Promise<Content> {
425 if (file.status === 'A') return { kind: 'note', text: 'The file did not exist before this commit.' }
426 const parent = commit.parents[0]
427 if (!parent) return { kind: 'note', text: 'Root commit: there is no earlier version.' }
428 return blob(cwd, parent, file.oldPath ?? file.path)
429 },
430
431 // ── Uncommitted changes: staged, unstaged and untracked, against HEAD ──
432
433 head,
434
435 /**
436 * The worktree's uncommitted files, as `git status` lists them (so what
437 * .gitignore skips is left out), with line counts against HEAD, or
438 * against the empty tree before the first commit.
439 */
440 async uncommitted(cwd: string): Promise<ChangedFile[]> {
441 const listed = parseStatus((await git(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])).stdout)
442 if (listed.length === 0) return []
443 const base = (await head(cwd)) ?? (await emptyTree(cwd))
444 const stats = parseNumstat((await git(cwd, ['diff', '-M', '-z', '--numstat', '--no-ext-diff', base])).stdout)
445 const byPath = new Map(stats.map(stat => [stat.path, stat]))
446 const untracked = listed.filter(one => one.xy === '??').slice(0, MAX_UNTRACKED_COUNTED)
447 const counted = new Map(await Promise.all(untracked.map(async one => [one.path, await untrackedStat(cwd, one.path)] as const)))
448 return listed.map(one => {
449 const stat = byPath.get(one.path) ?? counted.get(one.path)
450 return {
451 status: statusOf(one.xy),
452 path: one.path,
453 oldPath: one.oldPath,
454 xy: one.xy,
455 isBinary: stat?.isBinary ?? false,
456 added: stat?.added ?? 0,
457 deleted: stat?.deleted ?? 0,
458 ...(one.xy === '??' && !stat ? { isUncounted: true } : {}),
459 }
460 })
461 },
462
463 /** An uncommitted file's diff: the staged part against HEAD, the unstaged part against what is staged. */
464 async uncommittedDiff(cwd: string, file: ChangedFile): Promise<Content> {
465 if (file.isBinary) return { kind: 'note', text: 'Binary file: no text diff.' }
466 const xy = file.xy ?? ' '
467 const paths = file.oldPath && file.oldPath !== file.path ? [file.oldPath, file.path] : [file.path]
468 const common = ['--no-color', '--no-ext-diff']
469 const wanted: { stage: Stage; argsFor: (context: number) => string[] }[] = []
470 if (xy === '??') {
471 wanted.push({ stage: 'unstaged', argsFor: context => ['diff', '--no-index', ...common, `--unified=${context}`, '--', '/dev/null', file.path] })
472 } else {
473 if (xy.charAt(0) !== ' ') wanted.push({ stage: 'staged', argsFor: context => ['diff', '--cached', '-M', ...common, `--unified=${context}`, '--', ...paths] })
474 if (xy.charAt(1) !== ' ') wanted.push({ stage: 'unstaged', argsFor: context => ['diff', ...common, `--unified=${context}`, '--', file.path] })
475 }
476 const budget = Math.floor(SOURCE_BUDGET / Math.max(1, wanted.length))
477 const parts: Extract<Content, { kind: 'parts' }>['parts'] = []
478 for (const one of wanted) {
479 const fitted = await fitPatch(cwd, one.argsFor, budget)
480 if (fitted === 'binary') return { kind: 'note', text: 'Binary file: no text diff.' }
481 if (fitted) parts.push({ stage: one.stage, ...fitted })
482 }
483 if (parts.length > 0) return { kind: 'parts', parts }
484 if (xy === '??') return { kind: 'note', text: 'A new, empty file.' }
485 return { kind: 'note', text: /[RC]/.test(xy) ? 'Renamed without changes.' : 'No textual changes (mode or type only).' }
486 },
487
488 /** An uncommitted file as it is now: rebuilt from git's whole-file diff when it differs from what is staged. */
489 async uncommittedAfter(cwd: string, file: ChangedFile): Promise<Content> {
490 const xy = file.xy ?? ' '
491 if (xy.charAt(1) === 'D' || xy === 'D ') return { kind: 'note', text: 'The file is deleted: there is nothing on disk to show.' }
492 if (file.isBinary) return { kind: 'note', text: 'Binary file: no text to show.' }
493 if (xy === '??' || xy.charAt(1) !== ' ') {
494 const args =
495 xy === '??'
496 ? ['diff', '--no-index', '--no-color', '--no-ext-diff', `--unified=${WHOLE_FILE}`, '--', '/dev/null', file.path]
497 : ['diff', '--no-color', '--no-ext-diff', `--unified=${WHOLE_FILE}`, '--', file.path]
498 const result = await gitDiff(cwd, args)
499 if (/^Binary files /m.test(result.stdout)) return { kind: 'note', text: 'Binary file: no text to show.' }
500 const hunks = hunksOf(result.stdout)
501 if (hunks !== '') {
502 const fitted = fitText(afterFromHunks(hunks))
503 return { kind: 'file', path: file.path, source: fitted.source, isTruncated: fitted.isTruncated || result.isStdoutTruncated === true }
504 }
505 if (xy === '??') return { kind: 'note', text: 'A new, empty file.' }
506 }
507 // What is on disk matches what is staged, so the staged copy shows it.
508 return blob(cwd, '', file.path)
509 },
510
511 /** An uncommitted file as the last commit has it. */
512 async uncommittedBefore(cwd: string, file: ChangedFile, headSha: string | null): Promise<Content> {
513 if (headSha === null) return { kind: 'note', text: 'No commits yet: there is no earlier version.' }
514 const xy = file.xy ?? ' '
515 if (xy === '??' || xy.charAt(0) === 'A') return { kind: 'note', text: 'A new file: the last commit does not have it.' }
516 return blob(cwd, headSha, file.oldPath ?? file.path)
517 },
518 }
519}
520
521export type Repo = ReturnType<typeof createRepo>
522hooks/pane/index.tsx 128 lines1// The pane, one screen at a time: no repository, still reading, nothing to
2// walk (the welcome screen), or the walk itself.
3
4import type { ElementTable } from 'claude-code'
5
6import { printable } from '../printable'
7import { commitSection, worktreePicker } from './commits'
8import { debugButton, debugSection } from './debug'
9import { fileSection } from './files'
10import type { Frame } from './frame'
11import { header } from './header'
12import { rangeRow } from './ranges'
13import { contentView, viewButtons } from './view'
14import { chooseArt, welcomeArt } from './welcome'
15
16export function drawPane(f: Frame) {
17 const { Box, Text, Button } = f.ui
18 const { snap } = f
19
20 if (snap.repoRoot === null) {
21 const art = chooseArt(f)
22 if (snap.error) {
23 return (
24 <Box flexDirection="column" gap={1}>
25 {welcomeArt(f, art.source)}
26 <Text bold>No repository to review</Text>
27 <Text color="red">{snap.error}</Text>
28 <Text dimColor>Start Claude Code inside a Git repository, then run /commit-walk.</Text>
29 <Box flexDirection="row" gap={1}>
30 <Button key="refresh" hotkey="r" onPress={() => void f.act.load('repo')}>Try again</Button>
31 {debugButton(f)}
32 </Box>
33 {debugSection(f, art.tier)}
34 </Box>
35 )
36 }
37 return (
38 <Box flexDirection="column" gap={1}>
39 {welcomeArt(f, art.source)}
40 <Text dimColor>Reading the repository…</Text>
41 {debugSection(f, art.tier)}
42 </Box>
43 )
44 }
45
46 // Uncommitted work is walked even where the range holds no commits (on the default branch, before the first commit).
47 if (snap.commits.length === 0 && snap.uncommitted.length === 0) return nothingToWalk(f, snap.repoRoot)
48
49 // The debug section sits under the header here: below the diff it would be off screen.
50 return (
51 <Box flexDirection="column" gap={1}>
52 {header(f, snap.repoRoot)}
53 {debugSection(f, null)}
54 {worktreePicker(f)}
55 {commitSection(f)}
56 {fileSection(f)}
57 {viewButtons(f)}
58 {contentView(f)}
59 <Text dimColor wrap="truncate-end">keys: o/n commit · k/j file · d/a/b view · h range · r refresh · x debug · Esc close</Text>
60 </Box>
61 )
62}
63
64/** When drawing the pane threw: what failed and what to do, instead of a blank pane. */
65export function drawFailure(ui: ElementTable, message: string) {
66 const { Box, Text } = ui
67 return (
68 <Box flexDirection="column" gap={1}>
69 <Text bold>Commit walk could not draw this view</Text>
70 <Text>
71 Something went wrong inside commit-walk, not in your repository. Run /commit-walk refresh to try again, or
72 press Esc to close the pane.
73 </Text>
74 <Text dimColor>If it keeps happening, please report it with the detail below: github.com/mahdava/commit-walk/issues</Text>
75 <Text color="red">{`Detail: ${printable(message)}`}</Text>
76 </Box>
77 )
78}
79
80/**
81 * Nothing to walk: the welcome art, until a commit lands. Under "not in
82 * default" that is the default branch itself (no work happens there) or a
83 * branch level with it; otherwise a worktree with no commits at all.
84 */
85function nothingToWalk(f: Frame, repoRoot: string) {
86 const { Box, Text, Button } = f.ui
87 const { snap, sel } = f
88 const worktree = snap.worktrees.find(wt => wt.path === sel.worktree)
89 const where = worktree?.branch ?? 'this worktree'
90 const defaultRef = snap.ranges.notInDefault?.ref ?? 'the default branch'
91 const isNotInDefault = snap.range === 'notInDefault'
92 const art = chooseArt(f)
93 const showAll = (
94 <Box>
95 <Button key="show-all" onPress={() => void f.act.load('commit', { range: 'all', commit: null, file: null })}>
96 Show all commits
97 </Button>
98 </Box>
99 )
100 return (
101 <Box flexDirection="column" gap={1}>
102 {header(f, repoRoot)}
103 {debugSection(f, art.tier)}
104 {worktreePicker(f)}
105 {rangeRow(f)}
106 {welcomeArt(f, art.source)}
107 {isNotInDefault && snap.ranges.isOnDefault ? (
108 <Box flexDirection="column" gap={1}>
109 <Text>You're on {where}. Work happens on branches, so there is nothing new to walk here.</Text>
110 <Text dimColor>Pick a worktree above, or show the whole history.</Text>
111 {showAll}
112 </Box>
113 ) : isNotInDefault ? (
114 <Box flexDirection="column" gap={1}>
115 <Text>No commits on {where} beyond {defaultRef} yet.</Text>
116 <Text dimColor>The pane follows the first one as soon as it lands. Press r to look again.</Text>
117 {showAll}
118 </Box>
119 ) : (
120 <Box flexDirection="column">
121 <Text>No commits on {where} yet.</Text>
122 <Text dimColor>The pane follows the first one as soon as it lands. Press r to look again.</Text>
123 </Box>
124 )}
125 </Box>
126 )
127}
128hooks/pane/frame.ts 91 lines1// What every part of the pane draws with: the values read for this draw, the
2// actions its buttons take, the surface's elements, and the room it has.
3//
4// The pane's parts never take `$`: Claude Code before 2.1.300 follows `$`
5// only into functions of the file that holds it. register.tsx reads the
6// values and builds the actions; the parts here only draw.
7
8import type { ElementTable } from 'claude-code'
9
10import type { About, Expandable, GitTrace, Selection, Snapshot } from '../../types'
11import { printable, printableLine } from '../printable'
12import type { Depth } from '../state'
13import type { ListScroll } from '../window'
14
15/** Everything the pane draws from, read once per draw. The debug values only while it is shown. */
16export type PaneValues = {
17 sel: Selection
18 snap: Snapshot
19 open: Expandable | null
20 scrolled: Record<string, ListScroll>
21 isDebug: boolean
22 gitRuns: GitTrace[]
23 where: About | null
24}
25
26/** What the pane's buttons do; each starts its work and returns at once. */
27export type Actions = {
28 load: (depth: Depth, change?: Partial<Selection>) => void
29 stepCommit: (delta: 1 | -1) => void
30 stepFile: (delta: 1 | -1) => void
31 toggleDebug: () => void
32 toggleList: (which: Expandable) => void
33 foldLists: () => void
34 scrollList: (prefix: string, at: ListScroll) => void
35}
36
37/** Where the pane is drawn, from the render event. */
38export type Place = { bodyColumns: number; bodyRows: number; placement: string; surface: string }
39
40export type Frame = PaneValues & {
41 act: Actions
42 /** The surface's elements: Box, Text, Button, Code, which draw control characters as symbols. */
43 ui: ElementTable
44 place: Place
45 /** Columns to draw in, never fewer than 24. */
46 width: number
47 /** A framed list's inner width: lists are whole-row buttons, so a row is that wide. */
48 inner: number
49}
50
51export function frameOf(values: PaneValues, act: Actions, ui: ElementTable, place: Place): Frame {
52 const width = Math.max(24, place.bodyColumns)
53 return { ...values, act, ui: drawable(ui), place, width, inner: Math.max(10, width - 2) }
54}
55
56type Props = Record<string, unknown> & { children?: unknown }
57type Element = (props: Props) => unknown
58
59/** `children` with each string in it cleaned: nested elements and their keys are left as they are. */
60function cleanChildren(children: unknown, clean: (text: string) => string): unknown {
61 if (typeof children === 'string') return clean(children)
62 if (Array.isArray(children)) return children.map(child => cleanChildren(child, clean))
63 return children
64}
65
66/**
67 * The surface's elements, with every string they draw made safe (see
68 * printable.ts): every part of the pane draws through these, so no text from
69 * git can blank it. Keys are not drawn and stay as they are, so a row still
70 * names its real path.
71 */
72function drawable(ui: ElementTable): ElementTable {
73 const real = ui as unknown as Record<'Text' | 'Button' | 'Code', Element>
74 return {
75 ...ui,
76 Text: (props: Props) => real.Text({ ...props, children: cleanChildren(props.children, printable) }),
77 Button: (props: Props) => real.Button({ ...props, children: cleanChildren(props.children, printableLine) }),
78 Code: (props: Props) =>
79 real.Code({
80 ...props,
81 source: typeof props.source === 'string' ? printable(props.source) : props.source,
82 path: typeof props.path === 'string' ? printableLine(props.path) : props.path,
83 }),
84 } as unknown as ElementTable
85}
86
87/** `text` padded or cut to exactly `room` columns, a framed row by default. */
88export function pad(f: Frame, text: string, room = f.inner): string {
89 return text.padEnd(room).slice(0, room)
90}
91hooks/state.ts 48 lines1// The pane's constants and the first values of what the host keeps for it.
2//
3// The atoms themselves are declared in register.tsx, the one file that reads
4// and writes them: Claude Code requires an atom read or written in a file to
5// be declared in that same file, so it can list what each module touches.
6
7import type { Selection, Snapshot } from '../types'
8
9/**
10 * How much to load again: `repo` re-reads worktrees and commits (open,
11 * refresh, another worktree); `changes` the uncommitted files, or all of
12 * `repo` when HEAD moved (a refresh after an edit); `commit` the changed
13 * files; `content` the code area alone (another file or view).
14 */
15export type Depth = 'repo' | 'changes' | 'commit' | 'content'
16
17export const PANE = 'commit-walk'
18/** What the selection names for the uncommitted changes, in place of a commit's SHA. */
19export const UNCOMMITTED = 'uncommitted'
20export const TITLE = 'Commit walk'
21/** Width the dock asks for beside a fullscreen transcript. */
22export const PANE_COLUMNS = 84
23/** Debounce for refreshes after edits and turns. */
24export const REFRESH_DELAY_MS = 400
25/** Rows a list shows at once; more page with its edge rows. */
26export const LIST_ROWS = 8
27export const GIT_TIMEOUT_MS = 20_000
28/** Git commands the debug section keeps. */
29export const TRACE_LENGTH = 10
30/** Tools whose successful, non-read-only calls may have changed the repository. */
31export const WRITING_TOOLS = ['Edit', 'Write', 'NotebookEdit', 'Bash'] as const
32
33export const INITIAL_SELECTION: Selection = { worktree: null, commit: null, file: null, mode: 'diff', isFollowing: true, range: 'notInDefault' }
34export const EMPTY_SNAPSHOT: Snapshot = {
35 repoRoot: null,
36 worktrees: [],
37 commits: [],
38 ranges: { branch: null, isOnDefault: false, notInDefault: null, base: null },
39 range: 'all',
40 files: [],
41 uncommitted: [],
42 head: null,
43 content: null,
44 isLoading: false,
45 error: null,
46 loadedAt: null,
47}
48hooks/window.ts 47 lines1// Long lists are drawn as a window of rows around the selected entry, with a
2// row at either end that steps past the edge, so a list of any length stays
3// a few rows tall in the pane.
4
5export type ListWindow<T> = {
6 /** The entries drawn. */
7 items: T[]
8 /** Index of the first drawn entry in the whole list. */
9 start: number
10 /** Entries left out before and after the window. */
11 before: number
12 after: number
13}
14
15/**
16 * The window of `items` around index `at` that fits `max` rows, the two edge
17 * rows included. A list that fits is drawn whole.
18 */
19export function windowAround<T>(items: readonly T[], at: number, max: number): ListWindow<T> {
20 if (items.length <= max) return { items: [...items], start: 0, before: 0, after: 0 }
21 const size = Math.max(1, max - 2)
22 const centre = Math.min(Math.max(at, 0), items.length - 1)
23 const start = Math.min(Math.max(centre - Math.floor(size / 2), 0), items.length - size)
24 const end = start + size
25 return { items: items.slice(start, end), start, before: start, after: items.length - end }
26}
27
28/** Where a list was scrolled to, and which entry was selected then. */
29export type ListScroll = { start: number; anchor: string | null }
30
31/**
32 * The first row a list of `length` entries draws, `size` rows at a time. A
33 * scroll the person made holds while the selection is the one it was made
34 * with, or while a new selection is in view; a selection out of view brings
35 * the list back around it.
36 */
37export function scrollStart(length: number, at: number, size: number, scrolled: ListScroll | undefined, selected: string | null): number {
38 if (length <= size) return 0
39 const last = length - size
40 const clamp = (n: number) => Math.min(Math.max(n, 0), last)
41 const centred = clamp(Math.max(at, 0) - Math.floor(size / 2))
42 if (scrolled === undefined) return centred
43 const start = clamp(scrolled.start)
44 if (scrolled.anchor === selected) return start
45 return at >= start && at < start + size ? start : centred
46}
47hooks/printable.ts 73 lines1// Text from git made safe to draw, and honest to read.
2//
3// Claude Code refuses a whole drawing when any of its text holds a control
4// character other than tab, line feed and carriage return, and the pane comes
5// out blank. A commit subject, an author, a path or a file can hold one (a
6// colour code, a form feed), so each is shown as its visible symbol instead:
7// ESC as ␛, form feed as ␌.
8//
9// Some other characters draw as nothing, or reorder the text around them, so
10// code can read differently from what it does ("Trojan Source"). Each is shown
11// as a short marker such as ⟨RLO⟩, so the pane shows the text in the order
12// git holds it. The zero-width joiner and non-joiner are left alone: emoji and
13// scripts such as Persian and Hindi need them.
14
15/** What Claude Code refuses to draw: C0 controls but tab, LF and CR; DEL; the C1 controls. */
16const UNDRAWABLE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/
17
18/** Characters that draw as nothing or reorder text, by the marker each is shown as. */
19const INVISIBLE: Record<string, string> = {
20 '': 'SHY',
21 '': 'ALM',
22 '': 'MVS',
23 '': 'ZWSP',
24 '': 'LRM',
25 '': 'RLM',
26 '
': 'LS',
27 '
': 'PS',
28 '': 'LRE',
29 '': 'RLE',
30 '': 'PDF',
31 '': 'LRO',
32 '': 'RLO',
33 '': 'WJ',
34 '': 'FA',
35 '': 'IT',
36 '': 'IS',
37 '': 'IP',
38 '': 'LRI',
39 '': 'RLI',
40 '': 'FSI',
41 '': 'PDI',
42 '': 'BOM',
43}
44
45const HIDDEN = new RegExp(`${UNDRAWABLE.source.slice(0, -1)}${Object.keys(INVISIBLE).join('')}]`)
46const HIDDEN_ALL = new RegExp(HIDDEN.source, 'g')
47const BREAKS = /[\t\n\r]/g
48
49/** A hidden character's symbol: its marker, its Control Pictures glyph, or � for the C1 range, which has none. */
50function symbolOf(char: string): string {
51 const marker = INVISIBLE[char]
52 if (marker) return `⟨${marker}⟩`
53 const code = char.charCodeAt(0)
54 if (code < 0x20) return String.fromCharCode(0x2400 + code)
55 if (code === 0x7f) return '␡'
56 return '�'
57}
58
59/** Whether `text` holds a character the pane shows as a symbol or marker. */
60export function hasHidden(text: string): boolean {
61 return HIDDEN.test(text)
62}
63
64/** Text for a block (a file, a diff, a message): tabs and line breaks stay. */
65export function printable(text: string): string {
66 return text.replace(HIDDEN_ALL, symbolOf)
67}
68
69/** Text for one line (a button's label): tabs and line breaks become spaces too. */
70export function printableLine(text: string): string {
71 return printable(text).replace(BREAKS, ' ')
72}
73hooks/pane/commits.tsx 78 lines1// The worktree and the commit: each a foldable list, the commit with its
2// author, date and position, and Older and Newer. The uncommitted changes
3// are the list's first row and the step after the newest commit.
4
5import type { Commit } from '../../types'
6import { commitLabel, fileCount, uncommittedLabel, worktreeLabel } from '../labels'
7import { UNCOMMITTED } from '../state'
8import type { Frame } from './frame'
9import { foldable } from './list'
10import { rangeRow } from './ranges'
11
12export function worktreePicker(f: Frame) {
13 const { snap, sel } = f
14 const worktree = snap.worktrees.find(wt => wt.path === sel.worktree)
15 return foldable(f, {
16 which: 'worktree',
17 prefix: 'worktree',
18 label: 'Worktree ',
19 current: worktree ? worktreeLabel(worktree) : 'none',
20 items: snap.worktrees,
21 at: snap.worktrees.findIndex(wt => wt.path === worktree?.path),
22 keyOf: wt => wt.path,
23 textOf: worktreeLabel,
24 noun: { one: 'worktree', many: 'worktrees' },
25 onPick: wt => void f.act.load('repo', { worktree: wt.path, commit: null, file: null }),
26 })
27}
28
29/** A row of the commit list: a commit, or the uncommitted changes. */
30type Row = Commit | typeof UNCOMMITTED
31
32export function commitSection(f: Frame) {
33 const { Box, Text, Button } = f.ui
34 const { snap, sel } = f
35 const isUncommitted = sel.commit === UNCOMMITTED
36 const pending = snap.uncommitted.length
37 // The rows: the uncommitted changes first while there are any, then the commits, newest first.
38 const rows: Row[] = [...(pending > 0 ? [UNCOMMITTED as Row] : []), ...snap.commits]
39 const at = snap.commits.findIndex(one => one.sha === sel.commit)
40 const commit = snap.commits[at]
41 const keyOf = (row: Row) => (row === UNCOMMITTED ? UNCOMMITTED : row.sha)
42 const textOf = (row: Row) => (row === UNCOMMITTED ? uncommittedLabel(pending) : commitLabel(row))
43 // From the newest commit, Newer steps to the uncommitted changes, and says so.
44 const isNewest = at === 0
45 const newerLabel = isNewest && pending > 0 ? 'Uncommitted' : 'Newer'
46 return (
47 <Box flexDirection="column">
48 {rangeRow(f)}
49 {foldable(f, {
50 which: 'commit',
51 prefix: 'commit',
52 label: 'Commit ',
53 current: isUncommitted ? textOf(UNCOMMITTED) : commit ? commitLabel(commit) : 'none',
54 items: rows,
55 at: rows.findIndex(row => keyOf(row) === sel.commit),
56 keyOf,
57 textOf,
58 noun: { one: 'commit', many: 'commits' },
59 onPick: row => void f.act.load('commit', { commit: keyOf(row), file: null }),
60 })}
61 {isUncommitted ? (
62 <Text dimColor wrap="truncate-end">{`Not committed yet · ${fileCount(pending)}`}</Text>
63 ) : commit ? (
64 <Text dimColor wrap="truncate-end">
65 {commit.author} · {commit.when}
66 {commit.parents.length > 1 ? ' · merge, against its first parent' : ''}
67 {commit.parents.length === 0 ? ' · root commit' : ''}
68 {` · ${at + 1} of ${snap.commits.length}`}
69 </Text>
70 ) : null}
71 <Box flexDirection="row" gap={1}>
72 <Button key="older" hotkey="o" dimColor={isUncommitted ? snap.commits.length === 0 : at >= snap.commits.length - 1} onPress={() => void f.act.stepCommit(1)}>Older</Button>
73 <Button key="newer" hotkey="n" dimColor={isUncommitted || (isNewest && pending === 0) || at < 0} onPress={() => void f.act.stepCommit(-1)}>{newerLabel}</Button>
74 </Box>
75 </Box>
76 )
77}
78hooks/pane/debug.tsx 44 lines1// The debug section: where the pane runs, what it holds, the last git
2// commands. Drawn on every screen while it is on.
3
4import type { Frame } from './frame'
5
6export function debugButton(f: Frame) {
7 const { Button } = f.ui
8 return (
9 <Button key="debug" hotkey="x" dimColor onPress={() => void f.act.toggleDebug()}>
10 {f.isDebug ? 'Hide debug' : 'Debug'}
11 </Button>
12 )
13}
14
15/** The section itself, or nothing while it is off. `artTier`: the welcome art on screen, if any. */
16export function debugSection(f: Frame, artTier: string | null) {
17 const { Box, Text } = f.ui
18 const { sel, snap, open, gitRuns, where } = f
19 if (!f.isDebug || where === null) return null
20 const line = (text: string, key: string) => <Text key={key} dimColor wrap="truncate-end">{text}</Text>
21 const counts = `${snap.worktrees.length} worktrees · ${snap.commits.length} commits · ${snap.files.length} files`
22 const age = snap.loadedAt === null ? 'never loaded' : `loaded ${Math.round((Date.now() - snap.loadedAt) / 1000)}s ago`
23 return (
24 <Box flexDirection="column" borderStyle="single" borderDimColor>
25 <Text bold dimColor>Debug</Text>
26 {line(`commit-walk ${where.pluginVersion ?? '?'} · Claude Code ${where.engineVersion ?? 'unknown (before 2.1.300)'} · ${f.place.surface}`, 'd-versions')}
27 {line(`pane ${f.place.bodyColumns}×${f.place.bodyRows} ${f.place.placement}${artTier ? ` · welcome art: ${artTier}` : ''}`, 'd-pane')}
28 {line(`cwd ${where.cwd}`, 'd-cwd')}
29 {line(`repo ${snap.repoRoot ?? 'none'}`, 'd-repo')}
30 {line(`worktree ${sel.worktree ?? '-'} · commit ${sel.commit?.slice(0, 7) ?? '-'} · file ${sel.file ?? '-'}`, 'd-sel')}
31 {line(`view ${sel.mode} · ${sel.isFollowing ? 'following' : 'pinned'} · open list ${open ?? 'none'}`, 'd-view')}
32 {line(`range shown ${snap.range} · preferred ${sel.range}`, 'd-range')}
33 {line(`${counts} · ${age}${snap.isLoading ? ' · loading' : ''}`, 'd-counts')}
34 {snap.error ? <Text key="d-error" color="red" wrap="truncate-end">error {snap.error}</Text> : null}
35 {line(gitRuns.length === 0 ? 'git: nothing run yet' : `git, last ${gitRuns.length}, newest last:`, 'd-git')}
36 {gitRuns.map((run, i) => (
37 <Text key={`d-git-${i}`} color={run.exitCode === 0 ? undefined : 'red'} dimColor={run.exitCode === 0} wrap="truncate-end">
38 {` ${String(run.exitCode).padStart(3)} ${String(run.ms).padStart(5)}ms ${run.args}${run.error ? ` · ${run.error}` : ''}`}
39 </Text>
40 ))}
41 </Box>
42 )
43}
44hooks/pane/files.tsx 46 lines1// The commit's changed files: Prev and Next, the list with each file's status
2// letter in colour (git's two letters for uncommitted files), and the
3// selected file's whole name when its row is cut.
4
5import { fileName, fileText } from '../labels'
6import { LIST_ROWS } from '../state'
7import type { Frame } from './frame'
8import { rowList } from './list'
9
10/** The colour of git's status letter in the file list. */
11const STATUS_COLOR: Partial<Record<string, string>> = { A: '#2EA043', D: '#EF4444', R: '#3B82F6', C: '#3B82F6', M: '#E08A00' }
12
13export function fileSection(f: Frame) {
14 const { Box, Text, Button } = f.ui
15 const { snap, sel } = f
16 const at = snap.files.findIndex(one => one.path === sel.file)
17 const file = snap.files[at]
18 return (
19 <Box flexDirection="column">
20 <Box flexDirection="row" gap={1}>
21 <Text>Files </Text>
22 <Button key="prev-file" hotkey="k" dimColor={at <= 0} onPress={() => void f.act.stepFile(-1)}>Prev</Button>
23 <Button key="next-file" hotkey="j" dimColor={at < 0 || at >= snap.files.length - 1} onPress={() => void f.act.stepFile(1)}>Next</Button>
24 <Text dimColor>{snap.files.length > 0 ? `${at + 1} of ${snap.files.length}` : 'none'}</Text>
25 </Box>
26 <Box flexDirection="column" borderStyle="round" borderDimColor>
27 {rowList(f, {
28 prefix: 'file',
29 items: snap.files,
30 at,
31 keyOf: one => one.path,
32 // An uncommitted file shows git's two letters, staged then unstaged (` M`, `MM`, `??`).
33 tagOf: one => ({ text: one.xy ?? one.status, color: STATUS_COLOR[one.status] }),
34 textOf: fileText,
35 noun: { one: 'file', many: 'files' },
36 onPick: one => void f.act.load('content', { file: one.path }),
37 })}
38 </Box>
39 {file && fileText(file).length > f.inner - (snap.files.length > LIST_ROWS ? 2 : 0) - 6 ? (
40 // The selected file's whole name when its row is cut: renames and deep paths, for every surface and the keyboard.
41 <Text dimColor wrap="wrap">{`▶ ${fileName(file)}`}</Text>
42 ) : null}
43 </Box>
44 )
45}
46hooks/pane/header.tsx 32 lines1// The top of the pane: the repository's name, Refresh and Debug, and whether
2// the pane follows the newest commit or shows the uncommitted changes.
3
4import { baseName, clip } from '../labels'
5import { UNCOMMITTED } from '../state'
6import { TONE_COLOR } from '../welcome'
7import { debugButton } from './debug'
8import type { Frame } from './frame'
9
10export function header(f: Frame, repoRoot: string) {
11 const { Box, Text, Button } = f.ui
12 const { sel, snap } = f
13 return (
14 <Box flexDirection="column">
15 <Box flexDirection="row" gap={1}>
16 {/* The name gives way to the buttons: in a narrow pane it is cut with … rather than wrapped mid-word. */}
17 <Box flexShrink={1} minWidth={0}>
18 <Text bold wrap="truncate-end">
19 <Text color={TONE_COLOR.petal}>✿</Text> {clip(baseName(repoRoot), f.width - 16)}
20 </Text>
21 </Box>
22 <Box flexShrink={0} flexDirection="row" gap={1}>
23 <Button key="refresh" hotkey="r" onPress={() => void f.act.load('repo')}>Refresh</Button>
24 {debugButton(f)}
25 </Box>
26 </Box>
27 <Text dimColor wrap="truncate-end">{snap.isLoading ? 'Loading…' : sel.commit === UNCOMMITTED ? 'Showing what is not committed yet' : sel.isFollowing ? 'Following the newest commit' : 'Pinned to a past commit'}</Text>
28 {snap.error ? <Text color="red">{snap.error}</Text> : null}
29 </Box>
30 )
31}
32hooks/pane/ranges.tsx 37 lines1// Which commits the list starts from: every option the worktree offers, one
2// button each, the chosen one primary. Hidden where only the whole history is
3// on offer.
4
5import type { RangeMode } from '../../types'
6import type { Frame } from './frame'
7
8export function rangeRow(f: Frame) {
9 const { Box, Text, Button } = f.ui
10 const { snap } = f
11 const options: { mode: RangeMode; label: string }[] = [
12 { mode: 'all', label: 'all' },
13 ...(snap.ranges.notInDefault ? [{ mode: 'notInDefault' as const, label: `not in ${snap.ranges.notInDefault.ref} · ${snap.ranges.notInDefault.count}` }] : []),
14 ...(snap.ranges.base ? [{ mode: 'base' as const, label: `since ${snap.ranges.base.ref} · ${snap.ranges.base.count}` }] : []),
15 ]
16 if (options.length < 2) return null
17 // `h` cycles through them: it sits on the option after the chosen one.
18 // Switching keeps the selected commit when the new range has it.
19 const next = options[(options.findIndex(option => option.mode === snap.range) + 1) % options.length]?.mode
20 return (
21 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
22 <Text dimColor>Commits </Text>
23 {options.map(option => (
24 <Button
25 key={`range-${option.mode}`}
26 {...(option.mode === next ? { hotkey: 'h' } : {})}
27 variant={snap.range === option.mode ? 'primary' : undefined}
28 dimColor={snap.range !== option.mode}
29 onPress={() => void f.act.load('commit', { range: option.mode })}
30 >
31 {option.label}
32 </Button>
33 ))}
34 </Box>
35 )
36}
37