Review what each round changed: the files Claude touched, their diffs (shell commands included), and one key to open a change in VS Code.

See exactly what Claude changed, round by round, without leaving Claude Code.
Every file it touched, every diff (shell commands included), one key to open the change in VS Code.

Claude can change a dozen files in one reply, some with the Edit tool and some through a shell command like sed -i, git mv or a code generator. The transcript shows them as they scroll past, and git diff shows everything mixed together. Round Changes keeps each round (one prompt and Claude's reply) as its own change set, so you can review what that prompt did, step back to an earlier one, and jump straight to the line in your editor.
p and n.In Claude Code:
/plugin install round-changes --marketplace joaofatoretto/round-changes
Answer y to add the marketplace and choose a scope (user makes it available in every session). It starts working at once, with no restart and no configuration.
From a terminal:
claude plugin marketplace add joaofatoretto/round-changes
claude plugin install round-changes@round-changes --scope user
From a local clone, for one session:
git clone https://github.com/joaofatoretto/round-changes.git
claude --plugin-dir ./round-changes
Remove it with claude plugin uninstall round-changes@round-changes.
Work with Claude as usual. When a round changes files, three ways in appear:
| Where | What you see | What it opens |
|---|---|---|
| End of the round, on the "✻ Worked for…" line | 3 files +7 −6 [ view ] | That round |
| Prompt footer, right side | 3 files +7 −6 | The latest round |
| Anywhere | /changes | The latest round |

The viewer has two screens. The round lists every file it changed:
M, NEW or DEL, with green and red line counts.A file shows its diff, one section per edit:

Shell commands are diffed the same way, with the command that made the change shown above it:

The viewer takes the keyboard when it opens. Click it, or press ctrl+x then tab, to give it back the keys later.
| On the round | |
|---|---|
1–9 | Open that file's diff (or click its row) |
a | Audit the round in VS Code |
p / n | Older / newer round |
esc | Close |
| On a file | |
|---|---|
o | Open in VS Code at the changed line |
t | Find the change in the chat |
p / n | Previous / next file in the round |
g / e | Jump to the top / bottom of a long diff |
↑ ↓ | Scroll |
b or esc | Back to the round |

Claude Code docks panels on the right only in its fullscreen layout at 110 columns or more. A mod can't change that.
The Edit and Write tools report exactly what they changed. A shell command doesn't, so in a git repo Round Changes takes two snapshots of the work tree around each shell command and diffs them:
git add -A and git write-tree into a private index, .git/round-changes.index. It's seeded from your real index, so each snapshot only re-reads the files that changed, and it takes milliseconds.git diff between the two trees.Your files and git state are safe: your working files, staging area, branches and history are never touched.
What the snapshots see:
.gitignore..git/objects as unreferenced objects, which git gc removes in its normal cleanup.Outside a git repo, shell commands can't be diffed. The round says so in yellow ("no git"), so you know the list may be incomplete.
Round Changes makes no network requests and calls no model.
| When | Command |
|---|---|
| Shell commands, until a repo is found | git rev-parse to find the repo. cp seeds the private index, once. |
| Around each shell command | git add -A and git write-tree with the private index, then git diff between the two snapshots |
| When you press Open in VS Code | code -r -g <file>:<line> |
| When you press Audit in VS Code | code <project> -g <file>:<line> … (up to 25 files) |
Rounds live in the session's memory and end with it. When the mod loads in a session that's already running, it rebuilds earlier rounds from the transcript.
code command on your PATH, for Open in VS Code. In VS Code, run Shell Command: Install 'code' command in PATH. On WSL it works out of the box.o to see the rest in VS Code./changes still work.round-changes/
├── .claude-plugin/
│ ├── plugin.json # the mod's manifest
│ └── marketplace.json # makes this repo installable with /plugin install
├── hooks/
│ ├── hooks.json # points Claude Code at register.tsx
│ ├── register.tsx # the hooks: recording, snapshots, the chips and the viewer
│ ├── model.ts # pure logic: rounds, diff parsing, layout math
│ └── register.test.ts # tests, run inside Claude Code's own engine
├── types/index.d.ts # the mod's state, typed
└── screenshots/
claude plugin validate . # check the manifest and hooks the way Claude Code loads them
claude plugin test . # run the tests
claude --plugin-dir . # try your changes in a session
Claude Code writes the mod's TypeScript types to .claude-plugin/types/ when it loads the mod, so tsc -p . type-checks it from then on.
Issues and pull requests are welcome.
MIT © joaofatoretto
Built with Claude Code. Not an official Anthropic product. Visual Studio Code is a trademark of Microsoft Corporation, and Git is a trademark of Software Freedom Conservancy. Their names are used here only to describe what the mod works with.
hooks/register.tsx 898 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallInput, ToolCallResult } from 'claude-code'
3
4import type { Change, FileTouch, Live, Round, View } from '../types'
5import {
6 auditCommand,
7 auditFitsBeside,
8 auditLabel,
9 clock,
10 diffScreenRows,
11 diffSource,
12 displayPath,
13 duration,
14 fileRowLabel,
15 fileStats,
16 fitFor,
17 fromToolRecord,
18 makeChange,
19 parseGitDiff,
20 keepStart,
21 promptLine,
22 recordChange,
23 roundForDuration,
24 countShell,
25 roundStats,
26 roundsFromTranscript,
27 shortDir,
28 shownFile,
29 shownRound,
30 sizeBar,
31 splitPath,
32 summaryText,
33 stepFile,
34 stepRound,
35 shellWarning,
36 viewKey,
37 wantedRows,
38 wrapLines,
39} from './model'
40
41const PANE = 'round-changes'
42const TITLE = 'Changes'
43const EDITOR = 'code'
44/** The docked viewer's width: room for a diff, most of the row left to the chat. */
45const DOCK_COLUMNS = 52
46
47const rounds = atom({ plugin: 'round-changes', key: 'rounds' } as const, [])
48const live = atom({ plugin: 'round-changes', key: 'live' } as const, null)
49const turns = atom({ plugin: 'round-changes', key: 'turns' } as const, 0)
50const view = atom({ plugin: 'round-changes', key: 'view' } as const, { roundId: null, file: null, screen: 'round' })
51const cwd = atom({ plugin: 'round-changes', key: 'cwd' } as const, '')
52const home = atom({ plugin: 'round-changes', key: 'home' } as const, '')
53const scroll = atom({ plugin: 'round-changes', key: 'scroll' } as const, null)
54
55/** Git's colors for added and removed lines, as GitHub draws them. */
56const ADDED = '#3fb950'
57const REMOVED = '#f85149'
58
59const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? '' : 's'}`
60
61// ── Recording ─────────────────────────────────────────────────────────
62
63/** The round a change lands in: the running turn, else background work after the last one. */
64async function currentRound($: EngineInterface): Promise<Live> {
65 const running = await read($, live)
66 if (running) return running
67 const turn = await read($, turns)
68 return {
69 id: `after-${turn}`,
70 turn,
71 prompt: `(background work after turn ${turn})`,
72 startedAt: await $.clock.now(),
73 shellCommands: 0,
74 }
75}
76
77async function record($: EngineInterface, path: string, kind: FileTouch['kind'], change: Change) {
78 const into = await currentRound($)
79 await update($, rounds, list => recordChange(list, into, path, kind, change))
80}
81
82/** With nothing recorded yet (a fresh load mid-session), rebuilds the rounds from the transcript. */
83async function backfill($: EngineInterface) {
84 if ((await read($, rounds)).length > 0) return
85 const found = roundsFromTranscript(await $.session.messages())
86 if (found.rounds.length === 0) return
87 await update($, rounds, list => (list.length > 0 ? list : found.rounds))
88 await update($, turns, n => Math.max(n, found.turns))
89}
90
91async function recordFileTool<E extends ToolCallInput, R extends ToolCallResult>(
92 $: EngineInterface,
93 e: E,
94 next: (e: E) => Promise<R>,
95): Promise<R> {
96 const ran = await next(e)
97 if (ran.deny !== undefined || ran.isError) return ran
98 const found = fromToolRecord(e.tool, e.tool_use_id, ran.result, e.agentId)
99 if (found) await record($, found.path, found.kind, found.change)
100 return ran
101}
102
103// ── Shell commands, through git ─────────────────────────────────────
104
105/**
106 * Snapshots of a git work tree, to see what a shell command changed: the tree
107 * before and after, each written with `git add -A` into a private index inside
108 * `.git` (never the person's staging area), then diffed.
109 */
110type Repo = { top: string; index: string }
111
112const QUICK = { timeoutMs: 10_000 }
113
114/** The repo the session works in, or null outside one. */
115async function findRepo($: EngineInterface, cwd: string): Promise<Repo | null> {
116 const found = await $.process.run(['git', 'rev-parse', '--show-toplevel', '--absolute-git-dir'], { cwd, ...QUICK })
117 if (found.exitCode !== 0) return null
118 const [top, gitDir] = found.stdout.trim().split('\n')
119 if (!top || !gitDir) return null
120 const index = `${gitDir}/round-changes.index`
121 // Seeded from the real index, so its stat cache is warm and the first snapshot is quick.
122 await $.process.run(['sh', '-c', '[ -f "$1" ] || cp "$2/index" "$1" 2>/dev/null || true', 'sh', index, gitDir], QUICK)
123 return { top, index }
124}
125
126/** The work tree as a tree id, untracked files included, ignored ones not. */
127async function snapshot($: EngineInterface, repo: Repo): Promise<string | undefined> {
128 const ran = await $.process.run(['sh', '-c', 'git add -A . >/dev/null 2>&1; git write-tree'], {
129 cwd: repo.top,
130 env: { GIT_INDEX_FILE: repo.index },
131 ...QUICK,
132 })
133 const tree = ran.stdout.trim()
134 return ran.exitCode === 0 && /^[0-9a-f]{40,64}$/.test(tree) ? tree : undefined
135}
136
137/** The unified diff between two snapshots. */
138async function diffTrees($: EngineInterface, repo: Repo, before: string, after: string): Promise<string> {
139 const ran = await $.process.run(
140 ['git', '-c', 'core.quotepath=false', 'diff', '--no-color', '--no-ext-diff', '--no-renames', '-U3', before, after],
141 { cwd: repo.top, ...QUICK },
142 )
143 return ran.exitCode === 0 ? ran.stdout : ''
144}
145
146// Repos found, by the folder the session ran in. A miss is not kept, so a `git init`
147// or a move into a repo mid-session is seen at the next command; snapshots run one at
148// a time, as they share an index.
149const repos = new Map<string, Repo>()
150let queue: Promise<unknown> = Promise.resolve()
151
152async function repoFor($: EngineInterface): Promise<Repo | null> {
153 try {
154 const dir = await $.session.cwd()
155 const known = repos.get(dir)
156 if (known) return known
157 const found = await findRepo($, dir)
158 if (found) repos.set(dir, found)
159 return found
160 } catch {
161 return null
162 }
163}
164
165/** Drops a repo whose snapshot failed (moved, renamed, removed), so the next command looks again. */
166function forgetRepo(repo: Repo) {
167 for (const [dir, known] of repos) if (known === repo) repos.delete(dir)
168}
169
170function oneAtATime<T>(work: () => Promise<T>): Promise<T> {
171 const run = queue.then(work, work)
172 queue = run.catch(() => undefined)
173 return run
174}
175
176/** Records what a shell command changed in the repo, file by file. */
177async function recordShell($: EngineInterface, repo: Repo, before: string, e: { tool_use_id: string; agentId?: string; command: string }) {
178 const after = await oneAtATime(() => snapshot($, repo))
179 if (!after || after === before) return
180 const files = parseGitDiff(await diffTrees($, repo, before, after))
181 for (const f of files) {
182 const note = f.isBinary ? 'Binary file: no text diff to show.' : f.hunks.length === 0 ? 'Mode or empty-file change.' : undefined
183 const change = makeChange(
184 { toolUseId: e.tool_use_id, tool: 'Bash', agentId: e.agentId, command: e.command.slice(0, 300), note },
185 f.hunks,
186 )
187 await record($, `${repo.top}/${f.path}`, f.kind, change)
188 }
189}
190
191/** The tool.call hooks only watch: if one fails, the call's own result stands. */
192function passThrough<E, R>($: unknown, e: E, next: (e: E) => R): R {
193 return next(e)
194}
195
196// ── The viewer ────────────────────────────────────────────────────────
197
198/** Opens the viewer on `next`, or moves it there, as tall as what it shows needs. */
199async function show($: EngineInterface, next: View) {
200 await update($, view, () => next)
201 const list = await read($, rounds)
202 const round = shownRound(list, next)
203 const rows = Math.min(40, Math.max(6, wantedRows(round, shownFile(round, next), next.screen)))
204 // Docked (fullscreen, 110+ columns) it takes a slim column on the right; inline, the rows it needs.
205 return $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true, rows, columns: DOCK_COLUMNS })
206}
207
208/** Opens a round: straight to the diff when it touched one file, else its overview. */
209async function showRound($: EngineInterface, roundId: string | null) {
210 const list = await read($, rounds)
211 const round = shownRound(list, { roundId, file: null, screen: 'round' })
212 const only = round?.files.length === 1 ? round.files[0] : undefined
213 return show($, { roundId, file: only?.path ?? null, screen: only ? 'file' : 'round' })
214}
215
216async function move($: EngineInterface, to: (list: readonly Round[], v: View) => View) {
217 const list = await read($, rounds)
218 await show($, to(list, await read($, view)))
219 try {
220 await $.ui.scroll({ in: PANE, to: 'start' })
221 } catch {}
222}
223
224async function scrollPane($: EngineInterface, to: 'start' | 'end') {
225 try {
226 await $.ui.scroll({ in: PANE, to })
227 } catch {}
228}
229
230async function openInEditor($: EngineInterface, path: string, line: number) {
231 const name = splitPath(path).name
232 try {
233 const ran = await $.process.run([EDITOR, '-r', '-g', `${path}:${line}`], { timeoutMs: 20_000 })
234 if (ran.exitCode === 0) $.ui.toast(`Opened ${name}:${line} in VS Code`, { timeoutMs: 2500 })
235 else {
236 const why = (ran.stderr || ran.stdout).trim().split('\n')[0] || `exit code ${ran.exitCode}`
237 $.ui.toast(`VS Code did not open ${name}: ${why}`)
238 }
239 } catch {
240 $.ui.toast(`Could not run \`${EDITOR}\`. Is the VS Code CLI on your PATH?`)
241 }
242}
243
244/** Opens the project in VS Code with the round's files, where the gutter marks what git sees changed. */
245async function auditInEditor($: EngineInterface, round: Round) {
246 const folder = (await repoFor($))?.top ?? (await $.session.cwd())
247 const { argv, opened, left } = auditCommand(EDITOR, folder, round.files)
248 try {
249 const ran = await $.process.run(argv, { timeoutMs: 20_000 })
250 if (ran.exitCode === 0) {
251 const more = left > 0 ? `; ${left} more in Source Control` : ''
252 $.ui.toast(`Opened ${splitPath(folder).name} with ${plural(opened, 'file')} in VS Code${more}`, { timeoutMs: 3000 })
253 } else {
254 const why = (ran.stderr || ran.stdout).trim().split('\n')[0] || `exit code ${ran.exitCode}`
255 $.ui.toast(`VS Code did not open the project: ${why}`)
256 }
257 } catch {
258 $.ui.toast(`Could not run \`${EDITOR}\`. Is the VS Code CLI on your PATH?`)
259 }
260}
261
262async function revealInChat($: EngineInterface, change: Change) {
263 if (change.agentId) {
264 $.ui.toast('A subagent made this change: it is in that agent’s transcript, not the main one.')
265 return
266 }
267 try {
268 const moved = await $.ui.scroll({ to: { requestId: change.toolUseId }, block: 'center' })
269 if (moved.deny) $.ui.toast(`Could not scroll the chat there: ${moved.deny}`)
270 } catch {
271 $.ui.toast('That change is no longer in the chat.')
272 }
273}
274
275export const register: Register = on => {
276 on('session.start', async ($, e, next) => {
277 const result = await next(e)
278 await $.command.register({
279 name: 'changes',
280 description: 'Review what the last round changed: files, diffs, open in VS Code',
281 })
282 const root = await $.session.cwd()
283 await update($, cwd, () => root)
284 const homeDir = (await $.env.get('HOME')) ?? ''
285 await update($, home, () => homeDir)
286 // The first version pinned a status line; the chips replace it.
287 $.ui.status(undefined)
288 await backfill($)
289 return result
290 })
291
292 on('command.run', { command: 'changes' }, async $ => {
293 const root = await $.session.cwd()
294 await update($, cwd, () => root)
295 await backfill($)
296 const opened = await showRound($, null)
297 if (!opened.isPlaced) return { text: `The changes viewer could not open: ${opened.reason}` }
298 return {}
299 })
300
301 // The window's moves, measured: the pills at its foot need the tree's true height.
302 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
303 const moved = await next(e)
304 if (moved.deny !== undefined) return moved
305 const key = viewKey(await read($, rounds), await read($, view))
306 await update($, scroll, () => ({ key, contentRows: e.contentRows }))
307 return moved
308 })
309
310 // Esc on a file's diff steps back to the round; on the round it closes.
311 on('ui.close', async ($, e, next) => {
312 if (e.id !== PANE || e.origin.kind !== 'person') return next(e)
313 const v = await read($, view)
314 if (v.screen !== 'file') return next(e)
315 await show($, { ...v, screen: 'round' })
316 return { value: undefined }
317 })
318
319 on('turn.start', async ($, e, next) => {
320 const at = await $.clock.now()
321 const n = (await read($, turns)) + 1
322 await update($, turns, () => n)
323 await update($, live, () => ({ id: e.turnId, turn: n, prompt: e.text.slice(0, 400), startedAt: at, shellCommands: 0 }))
324 return next(e)
325 })
326
327 on('turn.complete', async ($, e, next) => {
328 if (e.agentId !== undefined) return next(e)
329 const at = await $.clock.now()
330 const ended = await read($, live)
331 if (ended)
332 await update($, rounds, list =>
333 list.map(r =>
334 r.id === ended.id ? { ...r, endedAt: at, durationMs: e.durationMs } : r,
335 ),
336 )
337 await update($, live, () => null)
338 return next(e)
339 })
340
341 on('tool.call', { tool: 'Edit' }, recordFileTool).catch(passThrough)
342 on('tool.call', { tool: 'Write' }, recordFileTool).catch(passThrough)
343 on('tool.call', { tool: 'NotebookEdit' }, recordFileTool).catch(passThrough)
344
345 // Shell commands: the work tree before and after, diffed. Outside a git repo they are only counted.
346 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
347 if (e.tool !== 'Bash') return next(e)
348 const repo = await repoFor($)
349 const before = repo ? await oneAtATime(() => snapshot($, repo)) : undefined
350 const ran = await next(e)
351 if (ran.deny !== undefined || ran.isReadOnly) return ran
352 if (repo && before) {
353 await recordShell($, repo, before, e)
354 return ran
355 }
356 // No snapshot: the round still shows, with a warning that files may have changed unseen.
357 if (repo) forgetRepo(repo)
358 const into = await currentRound($)
359 await update($, live, l => (l && l.id === into.id ? { ...l, shellCommands: l.shellCommands + 1 } : l))
360 await update($, rounds, list => countShell(list, into, repo === null))
361 return ran
362 }).catch(passThrough)
363
364 // ── Entry point 1: a chip on each round's "✻ Baked for 48s" line ──────
365 // The line is drawn whole here, the engine's words kept, so the chip never wraps it.
366 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
367 const round = roundForDuration(await read($, rounds), e.props.durationMs)
368 if (!round) return next(e)
369 const { Box, Text, Button } = $.ui.resolve(e)
370 const s = roundStats(round)
371 const columns = e.viewport?.columns ?? 80
372 const said = `✻ ${e.props.word} for ${duration(e.props.durationMs)}`
373 const counts = columns >= said.length + 34
374 return (
375 <Box flexDirection="row" gap={2}>
376 <Text key="said" dimColor>
377 {said}
378 </Text>
379 <Box key="chip" flexDirection="row" gap={1} flexShrink={0}>
380 {round.shellCommands > 0 && (
381 <Text key="warn" color="yellow">
382 {round.isOutsideRepo
383 ? s.files === 0 && counts ? 'no git: shell changes not tracked' : 'no git'
384 : s.files === 0 && counts ? 'shell changes not tracked' : 'untracked'}
385 </Text>
386 )}
387 {counts && s.files > 0 && <Text key="files">{plural(s.files, 'file')}</Text>}
388 {counts && s.added > 0 && <Text key="a" color={ADDED}>{`+${s.added}`}</Text>}
389 {counts && s.removed > 0 && <Text key="r" color={REMOVED}>{`−${s.removed}`}</Text>}
390 <Button key={`view:${round.id}`} onPress={() => showRound($, round.id)}>
391 {counts || s.files === 0 ? 'view' : `${s.files} · view`}
392 </Button>
393 </Box>
394 </Box>
395 )
396 })
397
398 // ── Entry point 2: the newest round in the prompt footer, one click from the viewer ──
399 // Drawn in the footer's mode slot at the right of the hint row: no row of its own.
400 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
401 const list = await read($, rounds)
402 const newest = list[list.length - 1]
403 if (!newest) return next(e)
404 const s = roundStats(newest)
405 const files = s.files > 0 ? plural(s.files, 'file') : 'shell changes'
406 const counts = [s.added > 0 && `+${s.added}`, s.removed > 0 && `−${s.removed}`].filter(Boolean).join(' ')
407 const noGit = newest.shellCommands > 0 ? (newest.isOutsideRepo ? 'no git ' : 'untracked ') : ''
408 // The engine moves this slot to a row of its own when the hint leaves it no room, so
409 // it drops the counts, then steps aside: the hint is about 52 columns, 71 while a turn runs.
410 const columns = e.viewport?.columns ?? 0
411 const modes = e.props.modes.length > 0 ? e.props.modes.join(' & ').length + 2 : 0
412 const free = columns - ((await read($, live)) ? 71 : 52) - modes - 4
413 const full = `${noGit}${files} ${counts}`.trim()
414 const short = `${noGit}${files}`.trim()
415 if (free < short.length) return next(e)
416 const withCounts = free >= full.length && counts !== ''
417 const { Box, Text, Button } = $.ui.resolve(e)
418 return (
419 <Box flexDirection="row" gap={2}>
420 {e.props.modes.length > 0 && (
421 <Text key="modes" dimColor>
422 {e.props.modes.join(' & ')}
423 </Text>
424 )}
425 <Box key="changes" flexDirection="row" gap={1}>
426 {noGit !== '' && (
427 <Text key="nogit" color="yellow">
428 {noGit.trim()}
429 </Text>
430 )}
431 {/* One Button for the whole of it, counts included: a label holds no colors, but every cell presses. */}
432 <Button key="footer-changes" plain dimColor onPress={() => showRound($, newest.id)}>
433 {withCounts ? `${files} ${counts}` : files}
434 </Button>
435 </Box>
436 </Box>
437 )
438 })
439
440 // ── The viewer itself ─────────────────────────────────────────────────
441 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
442 const { Box, Text, Button, Code } = $.ui.resolve(e)
443 const list = await read($, rounds)
444 const running = await read($, live)
445 const v = await read($, view)
446 const root = await read($, cwd)
447 const homeDir = await read($, home)
448 const where = (path: string) => displayPath(path, root, homeDir)
449 const width = Math.max(24, e.props.bodyColumns)
450 // The body is padded by one column at each side: what is drawn fits `room`.
451 const room = width - 2
452 const fit = fitFor(width)
453 const round = shownRound(list, v)
454
455 /** A file's kind as a 3-cell chip: A added, M modified, D deleted. */
456 const kindBadge = (kind: FileTouch['kind']) => (
457 <Text key="badge" bold color="inverseText" backgroundColor={kind === 'deleted' ? 'error' : kind === 'added' ? 'success' : 'warning'}>
458 {kind === 'deleted' ? ' D ' : kind === 'added' ? ' A ' : ' M '}
459 </Text>
460 )
461
462 /**
463 * Pills at the window's foot, as the chat's own: to the top once scrolled down, to the
464 * bottom while there is more below. `estimate` stands in for the tree's height until
465 * the window has moved on this screen and the engine has said it.
466 */
467 const measured = await read($, scroll)
468 const here = viewKey(list, v)
469 const known = measured?.key === here ? measured : undefined
470 const treeRows = (estimate: number) => known?.contentRows ?? estimate
471 const pills = (estimate: number) => {
472 const { bodyRows } = e.props.scroll
473 const total = treeRows(estimate)
474 const { offset } = e.props.scroll
475 const canTop = offset > 0
476 const canBottom = offset + bodyRows < total
477 if (!canTop && !canBottom) return null
478 const short = fit === 'narrow'
479 return (
480 <Box key="pills" position="absolute" top={offset + bodyRows - 1} right={1} flexDirection="row" gap={1}>
481 {canTop && (
482 <Button key="top" hotkey="g" variant="primary" onPress={() => scrollPane($, 'start')}>
483 {short ? '⤒' : '⤒ top'}
484 </Button>
485 )}
486 {canBottom && (
487 <Button key="bottom" hotkey="e" variant="primary" onPress={() => scrollPane($, 'end')}>
488 {short ? '⤓' : '⤓ bottom'}
489 </Button>
490 )}
491 </Box>
492 )
493 }
494 // A tree taller than its window ends in a blank row, under the pills at its foot.
495 const scrolls = (estimate: number) => treeRows(estimate) > e.props.scroll.bodyRows
496
497 /** The key hints at the foot: key in the accent, what it does dim, wrapping instead of cutting. */
498 const hints = (pairs: readonly (readonly [string, string])[]) => (
499 <Box key="hints" flexDirection="row" flexWrap="wrap" columnGap={2} marginTop={1} width={room}>
500 {pairs.map(([key, label]) => (
501 <Box key={`hint:${key}`} flexDirection="row" gap={1}>
502 <Text key="key" bold color="claude">
503 {key}
504 </Text>
505 <Text key="label" dimColor>
506 {label}
507 </Text>
508 </Box>
509 ))}
510 </Box>
511 )
512
513 if (!round) {
514 return (
515 <Box flexDirection="column" paddingX={1} paddingY={1} width={width}>
516 <Text key="title" bold>
517 No changes yet
518 </Text>
519 <Box key="about" marginTop={1}>
520 <Text key="hint" dimColor wrap="wrap">
521 When Claude edits, creates or deletes files, each round appears here with its diffs.
522 </Text>
523 </Box>
524 {running && (
525 <Box key="running" marginTop={1}>
526 <Text key="live" color="claude">
527 {`● Turn ${running.turn} is running`}
528 </Text>
529 </Box>
530 )}
531 </Box>
532 )
533 }
534
535 const index = list.indexOf(round)
536 const isLive = running?.id === round.id
537 const stats = roundStats(round)
538 const file = v.screen === 'file' ? shownFile(round, v) : undefined
539
540 // ── Screen 2: one file's diff ───────────────────────────────────
541 if (file) {
542 const at = round.files.indexOf(file)
543 const { dir, name } = splitPath(where(file.path))
544 const fs = fileStats(file)
545 const isNew = file.kind === 'added'
546 const isGone = file.kind === 'deleted'
547 const canReveal = file.changes.some(c => !c.agentId)
548 const many = round.files.length > 1
549
550 // Row 1: back at the left, the walk between the round's files at the right.
551 const nav = (
552 <Box key="nav" flexDirection="row" justifyContent="space-between" width={room}>
553 <Button key="back" plain hotkey="b" dimColor onPress={() => move($, (_, w) => ({ ...w, screen: 'round' }))}>
554 ◀ Back
555 </Button>
556 {many && (
557 <Box key="files-nav" flexDirection="row" gap={1} flexShrink={0}>
558 <Button key="prev-file" plain hotkey="p" dimColor onPress={() => move($, (l, w) => stepFile(l, w, -1))}>
559 ◀
560 </Button>
561 <Text key="pos" dimColor>{`${at + 1}/${round.files.length}`}</Text>
562 <Button key="next-file" plain hotkey="n" dimColor onPress={() => move($, (l, w) => stepFile(l, w, 1))}>
563 ▶
564 </Button>
565 </Box>
566 )}
567 </Box>
568 )
569
570 // What the file is, how much changed, and where it lives.
571 const title = (
572 <Box key="title" flexDirection="column" marginTop={1} width={room}>
573 <Box key="what" flexDirection="row" justifyContent="space-between" gap={1} width={room}>
574 <Box key="who" flexDirection="row" gap={1} flexShrink={1} overflow="hidden">
575 {kindBadge(file.kind)}
576 <Text key="name" bold wrap="truncate-end">
577 {name}
578 </Text>
579 </Box>
580 <Box key="counts" flexDirection="row" gap={1} flexShrink={0}>
581 {fs.added > 0 && <Text key="a" color={ADDED}>{`+${fs.added}`}</Text>}
582 {fs.removed > 0 && <Text key="r" color={REMOVED}>{`−${fs.removed}`}</Text>}
583 </Box>
584 </Box>
585 {dir !== '' && (
586 <Box key="where" paddingLeft={4}>
587 <Text key="dir" dimColor wrap="truncate-start">
588 {shortDir(dir, room - 4)}
589 </Text>
590 </Box>
591 )}
592 </Box>
593 )
594
595 // The two things to do with the file.
596 const openLabel = fit === 'narrow' ? 'VS Code' : 'Open in VS Code'
597 const chatLabel = fit === 'narrow' ? 'chat' : 'Find in chat'
598 const actions = (!isGone || canReveal) && (
599 <Box key="actions" flexDirection="row" gap={2} marginTop={1} width={room}>
600 {!isGone && (
601 <Button key="open" hotkey="o" variant="primary" onPress={() => openInEditor($, file.path, file.changes[0]?.line ?? 1)}>
602 {openLabel}
603 </Button>
604 )}
605 {canReveal && (
606 <Button
607 key="reveal"
608 hotkey="t"
609 onPress={() => {
610 const first = file.changes.find(c => !c.agentId)
611 if (first) void revealInChat($, first)
612 }}
613 >
614 {chatLabel}
615 </Button>
616 )}
617 </Box>
618 )
619
620 const edits = file.changes.map((c, ci) => {
621 const what =
622 c.tool === 'Bash'
623 ? 'Shell'
624 : c.tool === 'Write'
625 ? isNew && ci === 0
626 ? 'Created'
627 : 'Rewritten'
628 : 'Edit'
629 const several = file.changes.length > 1
630 const label = several ? `${what} ${ci + 1} of ${file.changes.length}` : what
631 const line = `· line ${c.line}`
632 const agent = fit !== 'narrow' && c.agentId ? '· subagent' : ''
633 const hasOpen = several && !isGone
634 const hasChat = several && !c.agentId && fit !== 'narrow'
635 const buttonsWidth = (hasOpen ? 6 : 0) + (hasChat ? 6 : 0) + (hasOpen && hasChat ? 2 : 0)
636 // The rule runs from the label to the buttons (or the edge), one space between each part.
637 const parts = [2, label.length, line.length, ...(agent ? [agent.length] : []), ...(buttonsWidth > 0 ? [buttonsWidth] : [])]
638 // One cell spare: a rule that fills the row exactly is cut with an ellipsis.
639 const fill = room - parts.reduce((n, p) => n + p, 0) - parts.length - 1
640 return (
641 <Box key={`edit:${ci}`} flexDirection="column" marginTop={1} width={room}>
642 <Box key="head" flexDirection="row" gap={1} width={room}>
643 <Text key="lead" dimColor>
644 ──
645 </Text>
646 <Text key="label" bold>
647 {label}
648 </Text>
649 <Text key="line" dimColor>
650 {line}
651 </Text>
652 {agent !== '' && (
653 <Text key="agent" dimColor>
654 {agent}
655 </Text>
656 )}
657 {fill > 0 && (
658 <Box key="rule" flexGrow={1} flexShrink={1} overflow="hidden">
659 <Text key="dashes" dimColor wrap="truncate-end">
660 {'─'.repeat(fill)}
661 </Text>
662 </Box>
663 )}
664 {buttonsWidth > 0 && (
665 <Box key="go" flexDirection="row" gap={2} flexShrink={0}>
666 {hasOpen && (
667 <Button key={`open:${ci}`} plain dimColor onPress={() => openInEditor($, file.path, c.line)}>
668 ↗ open
669 </Button>
670 )}
671 {hasChat && (
672 <Button key={`reveal:${ci}`} plain dimColor onPress={() => revealInChat($, c)}>
673 ⌖ chat
674 </Button>
675 )}
676 </Box>
677 )}
678 </Box>
679 {!!c.command && (
680 <Text key="cmd" dimColor wrap="truncate-end">
681 {` $ ${c.command.replace(/\s+/g, ' ')}`}
682 </Text>
683 )}
684 {c.hunks.length > 0 && (
685 <Code key="diff" format="diff" source={diffSource(c.hunks)} path={file.path} language={c.language} />
686 )}
687 {!!c.note && (
688 <Text key="note" dimColor italic>
689 {c.note}
690 </Text>
691 )}
692 {c.isTruncated && (
693 <Text key="cut" dimColor italic>
694 … longer than shown: press o to see it all in VS Code
695 </Text>
696 )}
697 </Box>
698 )
699 })
700
701 const estimate = diffScreenRows(file, width, dir !== '')
702
703 return (
704 <Box flexDirection="column" paddingX={1} paddingBottom={scrolls(estimate) ? 1 : 0} width={width}>
705 {nav}
706 {title}
707 {actions}
708 {edits}
709 {hints([
710 ...(!isGone ? [['o', 'open'] as const] : []),
711 ...(canReveal ? [['t', 'chat'] as const] : []),
712 ...(many ? [['p/n', 'file'] as const] : []),
713 ...(scrolls(estimate) ? [['g/e', 'top/end'] as const] : []),
714 ['esc', 'back'],
715 ])}
716 {pills(estimate)}
717 </Box>
718 )
719 }
720
721 // ── Screen 1: the round's overview ──────────────────────────────
722 // Row 1: older ◀ which round ▶ newer.
723 const isLatest = index === list.length - 1
724 const nav = (
725 <Box key="nav" flexDirection="row" justifyContent="space-between" width={room}>
726 {index > 0 ? (
727 <Button key="older" plain hotkey="p" onPress={() => move($, (l, w) => stepRound(l, w, -1))}>
728 ◀
729 </Button>
730 ) : (
731 <Text key="older-off" dimColor>
732 {' ◀'}
733 </Text>
734 )}
735 <Box key="where" flexDirection="row" gap={1}>
736 <Text key="round" bold color="claude">
737 {`Round ${index + 1} of ${list.length}`}
738 </Text>
739 {isLive ? (
740 <Text key="tag" color="claude">
741 ● working
742 </Text>
743 ) : (
744 isLatest && (
745 <Text key="tag" dimColor>
746 latest
747 </Text>
748 )
749 )}
750 </Box>
751 {index < list.length - 1 ? (
752 <Button key="newer" plain hotkey="n" onPress={() => move($, (l, w) => stepRound(l, w, 1))}>
753 ▶
754 </Button>
755 ) : (
756 <Text key="newer-off" dimColor>
757 {'▶ '}
758 </Text>
759 )}
760 </Box>
761 )
762
763 // The prompt, up to two lines behind an accent bar; under it, which turn, how long, when.
764 const meta = [
765 `turn ${round.turn}`,
766 round.durationMs ? duration(round.durationMs) : '',
767 round.startedAt > 0 ? clock(round.startedAt) : 'earlier',
768 ]
769 .filter(Boolean)
770 .join(' · ')
771 const prompt = (
772 <Box key="prompt" flexDirection="column" marginTop={1} width={room}>
773 {wrapLines(promptLine(round.prompt), room - 2, 2).map((line, i) => (
774 <Box key={`prompt:${i}`} flexDirection="row" gap={1}>
775 <Text key="bar" color="claude">
776 ▎
777 </Text>
778 <Text key="text" italic wrap="truncate-end">
779 {line}
780 </Text>
781 </Box>
782 ))}
783 {fit !== 'narrow' && (
784 <Box key="meta" paddingLeft={2}>
785 <Text key="text" dimColor>
786 {meta}
787 </Text>
788 </Box>
789 )}
790 </Box>
791 )
792
793 // The round as a whole, in the editor: every file it left in place, beside git's markers.
794 // At the right end of the summary line, or under it when the line has no room.
795 const canAudit = round.files.some(f => f.kind !== 'deleted')
796 const auditButton = canAudit && (
797 <Button key="audit" hotkey="a" variant="primary" onPress={() => auditInEditor($, round)}>
798 {auditLabel(width)}
799 </Button>
800 )
801 const isAuditBeside = canAudit && auditFitsBeside(round, width)
802 const summary = (
803 <Box key="summary" flexDirection="row" justifyContent="space-between" marginTop={1} width={room}>
804 <Box key="totals" flexDirection="row" gap={1}>
805 <Text key="count" bold>
806 {summaryText(stats.files)}
807 </Text>
808 {stats.added > 0 && <Text key="a" color={ADDED}>{`+${stats.added}`}</Text>}
809 {stats.removed > 0 && <Text key="r" color={REMOVED}>{`−${stats.removed}`}</Text>}
810 </Box>
811 {isAuditBeside && auditButton}
812 </Box>
813 )
814 const audit = canAudit && !isAuditBeside && (
815 <Box key="audit" flexDirection="row" marginTop={1} width={room}>
816 {auditButton}
817 </Box>
818 )
819
820 const shell = round.shellCommands
821 const warning = shell > 0 && (
822 <Box key="warning" marginTop={1} width={room}>
823 <Text key="text" color="warning" wrap={fit === 'narrow' ? 'truncate-end' : 'wrap'}>
824 {shellWarning(shell, round.isOutsideRepo === true, fit === 'narrow')}
825 </Text>
826 </Box>
827 )
828
829 // One row per file: its kind, then one Button from the number to the counts, so a
830 // click anywhere on the row opens it (a label holds no colors; the badge and the
831 // size bar after it keep theirs).
832 const largest = Math.max(1, ...round.files.map(f => fileStats(f).added + fileStats(f).removed))
833 const badgeWidth = 3
834 const barWidth = fit === 'narrow' ? 0 : 5
835 const labelRoom = room - badgeWidth - 1 - 3 - (barWidth > 0 ? barWidth + 1 : 0)
836 const rows = round.files.map((f, i) => {
837 const { dir, name } = splitPath(where(f.path))
838 const s = fileStats(f)
839 const bar = sizeBar(s.added, s.removed, largest, barWidth)
840 const counts = s.added + s.removed === 0 ? '±0' : [s.added > 0 && `+${s.added}`, s.removed > 0 && `−${s.removed}`].filter(Boolean).join(' ')
841 const hotkey = i < 9 ? String(i + 1) : undefined
842 // A row past the ninth has no `n: ` before it: three spaces keep it in line.
843 const label = `${hotkey ? '' : ' '}${fileRowLabel(name, fit === 'narrow' ? '' : dir, counts, labelRoom)}`
844 return (
845 <Box key={`row:${i}`} flexDirection="row" gap={1} width={room}>
846 {kindBadge(f.kind)}
847 <Button
848 key={`file:${i}`}
849 plain
850 hotkey={hotkey}
851 onPress={() => move($, (_, w) => ({ roundId: w.roundId, file: f.path, screen: 'file' }))}
852 >
853 {label}
854 </Button>
855 {barWidth > 0 && (
856 <Box key="bar" width={barWidth} flexShrink={0}>
857 <Text key="p" color={ADDED}>
858 {'■'.repeat(bar.plus)}
859 </Text>
860 <Text key="m" color={REMOVED}>
861 {'■'.repeat(bar.minus)}
862 </Text>
863 <Text key="x" dimColor>
864 {'·'.repeat(bar.rest)}
865 </Text>
866 </Box>
867 )}
868 </Box>
869 )
870 })
871
872 const n = round.files.length
873 const estimate = wantedRows(round, undefined, 'round', width)
874 return (
875 <Box flexDirection="column" paddingX={1} paddingBottom={scrolls(estimate) ? 1 : 0} width={width}>
876 {nav}
877 {prompt}
878 {summary}
879 {audit}
880 {n > 0 && (
881 <Box key="files" flexDirection="column" marginTop={1} width={room}>
882 {rows}
883 </Box>
884 )}
885 {warning}
886 {hints([
887 ...(n > 0 ? [[n === 1 ? '1' : `1-${Math.min(9, n)}`, 'open'] as const] : []),
888 ...(canAudit ? [['a', 'audit'] as const] : []),
889 ['p/n', 'round'],
890 ...(scrolls(estimate) ? [['g/e', 'top/end'] as const] : []),
891 ['esc', 'close'],
892 ])}
893 {pills(estimate)}
894 </Box>
895 )
896 })
897}
898hooks/model.ts 557 lines1import type { Change, FileTouch, Hunk, Live, Round, View } from '../types'
2
3/** Diff lines kept per change; past this the diff is cut and says so. */
4export const MAX_LINES = 400
5/** Rounds kept; the oldest go first. */
6export const MAX_ROUNDS = 60
7
8const isChanged = (line: string) => line.startsWith('+') || line.startsWith('-')
9
10export const countLines = (hunks: readonly Hunk[]) => {
11 let added = 0
12 let removed = 0
13 for (const hunk of hunks)
14 for (const line of hunk.lines) {
15 if (line.startsWith('+')) added++
16 else if (line.startsWith('-')) removed++
17 }
18 return { added, removed }
19}
20
21/** The first changed line, numbered in the file as it is after the change. */
22export const firstChangedLine = (hunks: readonly Hunk[]): number => {
23 const hunk = hunks[0]
24 if (!hunk) return 1
25 let line = hunk.newStart
26 for (const text of hunk.lines) {
27 if (isChanged(text)) return Math.max(1, line)
28 if (!text.startsWith('\\')) line++
29 }
30 return Math.max(1, hunk.newStart)
31}
32
33/** Keeps the first `max` diff lines, recounting a cut hunk so it still parses. */
34export const capHunks = (hunks: readonly Hunk[], max = MAX_LINES): { hunks: Hunk[]; isTruncated: boolean } => {
35 const kept: Hunk[] = []
36 let room = max
37 for (const hunk of hunks) {
38 if (room <= 0) return { hunks: kept, isTruncated: true }
39 if (hunk.lines.length <= room) {
40 kept.push({ ...hunk, lines: [...hunk.lines] })
41 room -= hunk.lines.length
42 continue
43 }
44 const lines = hunk.lines.slice(0, room)
45 const oldLines = lines.filter(l => l.startsWith(' ') || l.startsWith('-')).length
46 const newLines = lines.filter(l => l.startsWith(' ') || l.startsWith('+')).length
47 kept.push({ ...hunk, oldLines, newLines, lines })
48 return { hunks: kept, isTruncated: true }
49 }
50 return { hunks: kept, isTruncated: false }
51}
52
53/** Every line of `text` as an added hunk: a new file. */
54export const addedHunk = (text: string): Hunk[] => {
55 if (text === '') return []
56 const lines = text.replace(/\n$/, '').split('\n')
57 return [{ oldStart: 0, oldLines: 0, newStart: 1, newLines: lines.length, lines: lines.map(l => `+${l}`) }]
58}
59
60/** `before` replaced by `after` as one hunk: a notebook cell. */
61export const replacedHunk = (before: string, after: string): Hunk[] => {
62 const old = before === '' ? [] : before.split('\n')
63 const now = after === '' ? [] : after.split('\n')
64 if (old.length === 0 && now.length === 0) return []
65 return [
66 {
67 oldStart: old.length ? 1 : 0,
68 oldLines: old.length,
69 newStart: now.length ? 1 : 0,
70 newLines: now.length,
71 lines: [...old.map(l => `-${l}`), ...now.map(l => `+${l}`)],
72 },
73 ]
74}
75
76/** A Change from the hunks a tool reported, capped and counted. */
77export const makeChange = (
78 base: Pick<Change, 'toolUseId' | 'tool' | 'agentId' | 'note' | 'language' | 'command'>,
79 hunks: readonly Hunk[],
80): Change => {
81 const { added, removed } = countLines(hunks)
82 const capped = capHunks(hunks)
83 return {
84 ...base,
85 hunks: capped.hunks,
86 isTruncated: capped.isTruncated,
87 added,
88 removed,
89 line: firstChangedLine(hunks),
90 }
91}
92
93/** The diff text `Code format="diff"` draws for a change. */
94export const diffSource = (hunks: readonly Hunk[]): string =>
95 hunks
96 .map(h => [`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`, ...h.lines].join('\n'))
97 .join('\n')
98
99/** What a file is after a second change in one round: created then deleted reads deleted, and so on. */
100export const mergeKind = (was: FileTouch['kind'], now: FileTouch['kind']): FileTouch['kind'] =>
101 now === 'deleted' ? 'deleted' : was === 'deleted' ? 'modified' : was
102
103/** Adds `change` to the round `live` names, opening that round on its first change. */
104export const recordChange = (
105 rounds: readonly Round[],
106 live: Live,
107 path: string,
108 kind: FileTouch['kind'],
109 change: Change,
110): Round[] => {
111 const existing = rounds.find(r => r.id === live.id)
112 const round: Round = existing ?? {
113 id: live.id,
114 turn: live.turn,
115 prompt: live.prompt,
116 startedAt: live.startedAt,
117 files: [],
118 shellCommands: 0,
119 }
120 const touched = round.files.find(f => f.path === path)
121 const files = touched
122 ? round.files.map(f => (f === touched ? { ...f, kind: mergeKind(f.kind, kind), changes: [...f.changes, change] } : f))
123 : [...round.files, { path, kind, changes: [change] }]
124 const next = { ...round, files }
125 return existing
126 ? rounds.map(r => (r === existing ? next : r))
127 : [...rounds, next].slice(-MAX_ROUNDS)
128}
129
130/**
131 * Counts one shell command whose changes could not be captured, opening the round
132 * if it has none yet; `outsideRepo` when the folder was in no git repo at the time.
133 */
134export const countShell = (rounds: readonly Round[], live: Live, outsideRepo = false): Round[] => {
135 const existing = rounds.find(r => r.id === live.id)
136 const mark = outsideRepo ? { isOutsideRepo: true } : {}
137 if (existing) return rounds.map(r => (r === existing ? { ...r, ...mark, shellCommands: r.shellCommands + 1 } : r))
138 const round: Round = {
139 id: live.id,
140 turn: live.turn,
141 prompt: live.prompt,
142 startedAt: live.startedAt,
143 files: [],
144 shellCommands: 1,
145 ...mark,
146 }
147 return [...rounds, round].slice(-MAX_ROUNDS)
148}
149
150export const fileStats = (file: FileTouch) => ({
151 added: file.changes.reduce((n, c) => n + c.added, 0),
152 removed: file.changes.reduce((n, c) => n + c.removed, 0),
153})
154
155export const roundStats = (round: Round) => {
156 let added = 0
157 let removed = 0
158 for (const file of round.files) {
159 const s = fileStats(file)
160 added += s.added
161 removed += s.removed
162 }
163 return { added, removed, files: round.files.length }
164}
165
166/** The round the view shows: the one it names, else the newest. */
167export const shownRound = (rounds: readonly Round[], view: View): Round | undefined =>
168 (view.roundId !== null && rounds.find(r => r.id === view.roundId)) || rounds[rounds.length - 1]
169
170/** The file the view shows in `round`: the one it names, else the first. */
171export const shownFile = (round: Round | undefined, view: View): FileTouch | undefined =>
172 round && ((view.file !== null && round.files.find(f => f.path === view.file)) || round.files[0])
173
174/** Names what the viewer shows (round, screen, file), so a measure of one screen is not read on another. */
175export const viewKey = (rounds: readonly Round[], view: View): string => {
176 const round = shownRound(rounds, view)
177 const file = view.screen === 'file' ? shownFile(round, view) : undefined
178 return `${round?.id ?? ''}:${view.screen}:${file?.path ?? ''}`
179}
180
181/** The view one round older (-1) or newer (+1), on its overview; the newest is followed again. */
182export const stepRound = (rounds: readonly Round[], view: View, by: number): View => {
183 const at = shownRound(rounds, view)
184 if (!at) return view
185 const index = Math.min(rounds.length - 1, Math.max(0, rounds.indexOf(at) + by))
186 const target = rounds[index]
187 if (!target || target === at) return view
188 return { roundId: index === rounds.length - 1 ? null : target.id, file: null, screen: 'round' }
189}
190
191/** The view on the next (+1) or previous (-1) file of the shown round, wrapping. */
192export const stepFile = (rounds: readonly Round[], view: View, by: number): View => {
193 const round = shownRound(rounds, view)
194 const file = shownFile(round, view)
195 if (!round || !file) return view
196 const n = round.files.length
197 const index = (((round.files.indexOf(file) + by) % n) + n) % n
198 return { ...view, file: round.files[index]?.path ?? null }
199}
200
201/** How much a row can hold at a width: everything, no folder, or name and counts alone. */
202export type Fit = 'wide' | 'medium' | 'narrow'
203export const fitFor = (columns: number): Fit => (columns >= 72 ? 'wide' : columns >= 46 ? 'medium' : 'narrow')
204
205/**
206 * A GitHub-style size bar of `cells` blocks: the share of added and removed
207 * lines, scaled to the round's largest file so sizes compare at a glance.
208 */
209export const sizeBar = (added: number, removed: number, largest: number, cells = 5) => {
210 const total = added + removed
211 if (total === 0 || largest === 0) return { plus: 0, minus: 0, rest: cells }
212 const filled = Math.max(1, Math.round((total / largest) * cells))
213 let plus = Math.round((added / total) * filled)
214 if (added > 0 && plus === 0) plus = 1
215 if (removed > 0 && plus === filled) plus = filled - 1
216 const minus = filled - plus
217 return { plus, minus: Math.max(0, minus), rest: cells - filled }
218}
219
220/** Cuts `text` to `width` cells, keeping its end: for paths, whose end says the most. */
221export const keepEnd = (text: string, width: number): string =>
222 width <= 0 ? '' : text.length <= width ? text : `…${text.slice(text.length - width + 1)}`
223
224/** Cuts `text` to `width` cells, keeping its start. */
225export const keepStart = (text: string, width: number): string =>
226 width <= 0 ? '' : text.length <= width ? text : `${text.slice(0, width - 1)}…`
227
228/**
229 * The prompt as at most `maxLines` rows of `width` cells, cut at spaces, the last
230 * row ending in … when more was left.
231 */
232export const wrapLines = (text: string, width: number, maxLines: number): string[] => {
233 const room = Math.max(1, width)
234 const lines: string[] = []
235 let rest = text.trim()
236 while (rest !== '' && lines.length < maxLines) {
237 if (rest.length <= room) {
238 lines.push(rest)
239 break
240 }
241 if (lines.length === maxLines - 1) {
242 lines.push(keepStart(rest, room))
243 break
244 }
245 let cut = rest.lastIndexOf(' ', room)
246 if (cut <= 0) cut = room
247 lines.push(rest.slice(0, cut).trimEnd())
248 rest = rest.slice(cut).trimStart()
249 }
250 return lines.length > 0 ? lines : ['']
251}
252
253const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? '' : 's'}`
254
255/** The warning for shell commands whose changes could not be shown; short when `narrow`. */
256export const shellWarning = (shell: number, isOutsideRepo: boolean, narrow: boolean): string => {
257 const commands = plural(shell, 'shell command')
258 if (narrow) return `⚠ ${isOutsideRepo ? 'No git' : 'Untracked'}: ${commands} not shown`
259 const its = shell === 1 ? 'its' : 'their'
260 return isOutsideRepo
261 ? `⚠ ${commands} ran outside a git repo, so ${its} changes can't be shown. Run git init to track them.`
262 : `⚠ ${commands} couldn't be snapshotted, so ${its} changes aren't shown.`
263}
264
265/** The room the viewer's body has at a pane `width`: its sides are padded by one. */
266const bodyRoom = (width: number) => Math.max(22, width - 2)
267
268/** The round's summary line: how many files changed. Its counts follow it, colored. */
269export const summaryText = (files: number) =>
270 files > 0 ? `${files} file${files === 1 ? '' : 's'} changed` : 'No file changes recorded'
271
272export const auditLabel = (width: number) => (fitFor(width) === 'narrow' ? 'Audit' : 'Audit in VS Code')
273
274/** Whether the audit button fits at the right end of the summary line; if not, it takes a row under it. */
275export const auditFitsBeside = (round: Round, width: number): boolean => {
276 const { added, removed, files } = roundStats(round)
277 const summary =
278 summaryText(files).length + (added > 0 ? `+${added}`.length + 1 : 0) + (removed > 0 ? `−${removed}`.length + 1 : 0)
279 // `[ label ]`, and two cells at least between it and the summary.
280 return summary + 2 + auditLabel(width).length + 4 <= bodyRoom(width)
281}
282
283/**
284 * The rows the viewer wants for what it shows, so it opens no taller than it needs;
285 * `width` is the pane's body, about the docked column.
286 */
287export const wantedRows = (
288 round: Round | undefined,
289 file: FileTouch | undefined,
290 screen: View['screen'],
291 width = 50,
292): number => {
293 if (!round) return 6
294 if (screen !== 'round' && file) return diffScreenRows(file, width)
295 const room = bodyRoom(width)
296 const narrow = fitFor(width) === 'narrow'
297 const prompt = wrapLines(promptLine(round.prompt), room - 2, 2).length
298 const warning =
299 round.shellCommands > 0
300 ? 1 + (narrow ? 1 : Math.ceil(shellWarning(round.shellCommands, round.isOutsideRepo === true, false).length / room))
301 : 0
302 const hints = room >= 34 ? 1 : 2
303 const audit = round.files.some(f => f.kind !== 'deleted') && !auditFitsBeside(round, width) ? 2 : 0
304 // nav, blank, prompt, meta, blank, summary, [blank, audit], [blank, files], [blank, warning], blank, hints
305 return 2 + prompt + (narrow ? 0 : 1) + 2 + audit + (round.files.length > 0 ? 1 + round.files.length : 0) + warning + 1 + hints
306}
307
308/** The finished round whose length matches a "Baked for" line, within a moment's drift. */
309export const roundForDuration = (rounds: readonly Round[], durationMs: number): Round | undefined => {
310 let best: Round | undefined
311 let gap = 1500
312 for (const round of rounds) {
313 if (round.durationMs === undefined) continue
314 const d = Math.abs(round.durationMs - durationMs)
315 if (d <= gap) {
316 gap = d
317 best = round
318 }
319 }
320 return best
321}
322
323/** `path` relative to `root` when inside it, `~/…` under home, else as is. */
324export const displayPath = (path: string, root: string, home?: string): string => {
325 if (root && path.startsWith(`${root}/`)) return path.slice(root.length + 1)
326 if (home && path.startsWith(`${home}/`)) return `~/${path.slice(home.length + 1)}`
327 return path
328}
329
330/** Splits a path into the directory part and the file name. */
331export const splitPath = (path: string): { dir: string; name: string } => {
332 const cut = path.lastIndexOf('/')
333 return cut < 0 ? { dir: '', name: path } : { dir: path.slice(0, cut), name: path.slice(cut + 1) }
334}
335
336/** The prompt as one short line. */
337export const promptLine = (text: string): string => {
338 const line = text.replace(/\s+/g, ' ').trim()
339 return line === '' ? '(continued without a new prompt)' : line
340}
341
342export const clock = (at: number): string => {
343 const d = new Date(at)
344 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
345}
346
347export const duration = (ms: number): string => {
348 const s = Math.max(0, Math.round(ms / 1000))
349 if (s < 60) return `${s}s`
350 const m = Math.floor(s / 60)
351 return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`
352}
353
354type PatchRecord = { filePath?: string; structuredPatch?: Hunk[]; staged?: boolean }
355type WriteRecord = PatchRecord & { type?: 'create' | 'update'; content?: string; originalFile?: string | null }
356type NotebookRecord = {
357 notebook_path?: string
358 new_source?: string
359 old_source?: string
360 cell_id?: string
361 cell_type?: string
362 language?: string
363 edit_mode?: string
364 error?: string
365}
366
367/**
368 * The file and change a file tool's stored record describes, or undefined when
369 * it changed nothing (an error, a staged write, a tool that edits no file).
370 */
371export const fromToolRecord = (
372 tool: string,
373 toolUseId: string,
374 record: unknown,
375 agentId?: string,
376): { path: string; kind: FileTouch['kind']; change: Change } | undefined => {
377 if (record === null || typeof record !== 'object') return undefined
378 if (tool === 'Edit') {
379 const r = record as PatchRecord
380 if (!r.filePath || r.staged) return undefined
381 const change = makeChange({ toolUseId, tool: 'Edit', agentId }, r.structuredPatch ?? [])
382 return { path: r.filePath, kind: 'modified', change }
383 }
384 if (tool === 'Write') {
385 const r = record as WriteRecord
386 if (!r.filePath || r.staged) return undefined
387 const isNew = r.type === 'create'
388 const patch = r.structuredPatch ?? []
389 const hunks = patch.length > 0 ? patch : isNew ? addedHunk(r.content ?? '') : []
390 const note =
391 hunks.length > 0
392 ? undefined
393 : r.originalFile === null
394 ? 'Rewritten whole; the previous version was too large to diff.'
395 : 'Written with the same content: nothing changed.'
396 const change = makeChange({ toolUseId, tool: 'Write', agentId, note }, hunks)
397 return { path: r.filePath, kind: isNew ? 'added' : 'modified', change }
398 }
399 if (tool === 'NotebookEdit') {
400 const r = record as NotebookRecord
401 if (!r.notebook_path || r.error) return undefined
402 const change = makeChange(
403 {
404 toolUseId,
405 tool: 'NotebookEdit',
406 agentId,
407 language: r.cell_type === 'markdown' ? 'markdown' : r.language,
408 note: `Cell ${r.cell_id ?? '?'} (${r.edit_mode ?? 'edit'})`,
409 },
410 replacedHunk(r.old_source ?? '', r.edit_mode === 'delete' ? '' : (r.new_source ?? '')),
411 )
412 return { path: r.notebook_path, kind: 'modified', change }
413 }
414 return undefined
415}
416
417/** The minimum of a transcript row the rebuild reads. */
418export type TranscriptRow = {
419 role: 'user' | 'assistant'
420 text: string
421 toolUses: readonly { tool_use_id: string; tool: string; result?: unknown; isError?: true }[]
422 toolResults?: readonly unknown[]
423}
424
425/**
426 * Rounds rebuilt from a transcript: each prompt the person typed opens a round,
427 * and the file changes its tool calls stored land in it. Times are unknown (0).
428 */
429export const roundsFromTranscript = (rows: readonly TranscriptRow[]): { rounds: Round[]; turns: number } => {
430 let rounds: Round[] = []
431 let turns = 0
432 let at: Live = { id: 'before-0', turn: 0, prompt: '(before the first prompt)', startedAt: 0, shellCommands: 0 }
433 for (const row of rows) {
434 if (row.role === 'user') {
435 if (row.text.trim() === '' || (row.toolResults && row.toolResults.length > 0)) continue
436 turns++
437 at = { id: `history-${turns}`, turn: turns, prompt: row.text.slice(0, 400), startedAt: 0, shellCommands: 0 }
438 continue
439 }
440 for (const use of row.toolUses) {
441 if (use.isError) continue
442 const found = fromToolRecord(use.tool, use.tool_use_id, use.result)
443 if (found) rounds = recordChange(rounds, at, found.path, found.kind, found.change)
444 }
445 }
446 return { rounds, turns }
447}
448
449/**
450 * A folder cut to `width` by whole segments from its end (`…/round-changes/hooks`),
451 * the nearest folders saying the most; the last segment alone when even one will not fit.
452 */
453export const shortDir = (dir: string, width: number): string => {
454 if (width <= 0) return ''
455 if (dir.length <= width) return dir
456 const parts = dir.split('/')
457 let kept = ''
458 for (let i = parts.length - 1; i >= 0; i--) {
459 const next = kept === '' ? (parts[i] ?? '') : `${parts[i]}/${kept}`
460 if (next.length + 2 > width) break
461 kept = next
462 }
463 return kept === '' ? keepEnd(dir, width) : `…/${kept}`
464}
465
466/**
467 * A file row as one line of `width` cells: the name, its folder when there is room,
468 * and the counts at the right end. The name gives way last.
469 */
470export const fileRowLabel = (name: string, dir: string, counts: string, width: number): string => {
471 const nameRoom = Math.max(4, width - counts.length - 2)
472 const shown = keepStart(name, nameRoom)
473 const dirRoom = width - shown.length - counts.length - 4
474 const folder = dir !== '' && dirRoom > 3 ? ` ${shortDir(dir, dirRoom)}` : ''
475 const left = `${shown}${folder}`
476 return `${left}${' '.repeat(Math.max(1, width - left.length - counts.length))}${counts}`
477}
478
479/** Files an audit opens at most; the rest are in the editor's Source Control view. */
480export const MAX_AUDIT_FILES = 25
481
482/**
483 * The editor command that audits a round: the project folder (its window, if one is
484 * open, else a new one) and each file still there at its first changed line.
485 */
486export const auditCommand = (editor: string, folder: string, files: readonly FileTouch[], max = MAX_AUDIT_FILES) => {
487 const present = files.filter(f => f.kind !== 'deleted')
488 const opened = present.slice(0, max)
489 return {
490 argv: [editor, folder, '-g', ...opened.map(f => `${f.path}:${f.changes[0]?.line ?? 1}`)],
491 opened: opened.length,
492 left: present.length - opened.length,
493 }
494}
495
496/** One file of a `git diff` between two snapshots. */
497export type DiffFile = { path: string; kind: FileTouch['kind']; hunks: Hunk[]; isBinary: boolean }
498
499const unquote = (path: string) =>
500 path.startsWith('"') && path.endsWith('"') ? path.slice(1, -1).replace(/\\(["\\])/g, '$1') : path
501
502/** The files of a `git diff --no-renames` and their hunks, paths relative to the repo's top. */
503export const parseGitDiff = (text: string): DiffFile[] => {
504 const files: DiffFile[] = []
505 let file: DiffFile | undefined
506 let hunk: Hunk | undefined
507 for (const line of text.split('\n')) {
508 if (line.startsWith('diff --git ')) {
509 const b = line.lastIndexOf(' b/')
510 file = { path: unquote(line.slice(b + 3)), kind: 'modified', hunks: [], isBinary: false }
511 hunk = undefined
512 files.push(file)
513 continue
514 }
515 if (!file) continue
516 if (!hunk) {
517 if (line.startsWith('new file mode')) file.kind = 'added'
518 else if (line.startsWith('deleted file mode')) file.kind = 'deleted'
519 else if (line.startsWith('Binary files')) file.isBinary = true
520 else if (line.startsWith('+++ b/')) file.path = unquote(line.slice(6))
521 }
522 const head = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line)
523 if (head) {
524 hunk = {
525 oldStart: Number(head[1]),
526 oldLines: head[2] === undefined ? 1 : Number(head[2]),
527 newStart: Number(head[3]),
528 newLines: head[4] === undefined ? 1 : Number(head[4]),
529 lines: [],
530 }
531 file.hunks.push(hunk)
532 continue
533 }
534 if (hunk && (line.startsWith(' ') || line.startsWith('+') || line.startsWith('-') || line.startsWith('\\')))
535 hunk.lines.push(line)
536 }
537 return files
538}
539
540/**
541 * About how many rows a file's diff screen draws at `width`, wrapped lines counted:
542 * the nav, title, folder and actions, each change under its rule, the key hints.
543 */
544export const diffScreenRows = (file: FileTouch, width: number, hasDir = true): number => {
545 const room = bodyRoom(width)
546 const code = Math.max(10, width - 9)
547 const hasActions = file.kind !== 'deleted' || file.changes.some(c => !c.agentId)
548 // nav, blank, title, [folder], [blank, actions], blank, hints
549 let rows = 3 + (hasDir ? 1 : 0) + (hasActions ? 2 : 0) + 1 + (room >= 48 ? 1 : 2)
550 for (const c of file.changes) {
551 // blank, rule, [command], [note], [cut note], the diff
552 rows += 2 + (c.command ? 1 : 0) + (c.note ? 1 : 0) + (c.isTruncated ? 1 : 0)
553 for (const h of c.hunks) for (const l of h.lines) rows += Math.max(1, Math.ceil(l.length / code))
554 }
555 return rows
556}
557types/index.d.ts 81 lines1/** One unified-diff hunk, as Edit and Write report it. */
2export type Hunk = {
3 oldStart: number
4 oldLines: number
5 newStart: number
6 newLines: number
7 lines: string[]
8}
9
10/** One tool call that changed a file. */
11export type Change = {
12 toolUseId: string
13 tool: 'Edit' | 'Write' | 'NotebookEdit' | 'Bash'
14 /** The shell command that made the change, for a Bash change. */
15 command?: string
16 /** Set when a subagent made the change: its row is not in the main transcript. */
17 agentId?: string
18 hunks: Hunk[]
19 added: number
20 removed: number
21 /** The first changed line in the file after the change, 1-based. */
22 line: number
23 /** Lines were dropped to keep the stored diff small. */
24 isTruncated: boolean
25 /** Why there is no diff to show, when there is none. */
26 note?: string
27 language?: string
28}
29
30export type FileTouch = {
31 path: string
32 kind: 'added' | 'modified' | 'deleted'
33 changes: Change[]
34}
35
36export type Round = {
37 id: string
38 /** The turn's ordinal in the session. */
39 turn: number
40 prompt: string
41 startedAt: number
42 endedAt?: number
43 files: FileTouch[]
44 /** The turn's length as turn.complete reported it: what finds its "Baked for" line. */
45 durationMs?: number
46 /** Shell commands run this round whose changes could not be captured. */
47 shellCommands: number
48 /** Some of them ran outside any git repo (the rest, if any, failed to snapshot). */
49 isOutsideRepo?: boolean
50}
51
52/** The turn running now, before or after its first change. */
53export type Live = {
54 id: string
55 turn: number
56 prompt: string
57 startedAt: number
58 shellCommands: number
59}
60
61/**
62 * What the viewer shows: a round (null follows the newest), and either its
63 * overview or one file's diff.
64 */
65export type View = { roundId: string | null; file: string | null; screen: 'round' | 'file' }
66
67declare module 'claude-code' {
68 interface PluginState {
69 'round-changes': {
70 rounds: Round[]
71 live: Live | null
72 turns: number
73 view: View
74 cwd: string
75 home: string
76 /** The viewer's tree height as the engine measured it at the last scroll, and on which screen (`viewKey`). */
77 scroll: { key: string; contentRows: number } | null
78 }
79 }
80}
81