SLOPSHOPPER

round-changes

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

newpanespinnerguardcommandtoast
v0.2.1MITupdated 2026-10-08joaofatoretto/round-changes
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · round-changes
│ ┃ Changes ✕ › fix the failing auth test and add an audit log call │ ┃ ◀ Round 1 of 1 latest │ ┃ ⏺ Read(src/auth.ts) │ ┃ ▎ (background work after turn 1) ⎿ Read 6 lines │ ┃ turn 1 · 08:53 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No file changes recorded ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ⚠ 2 shell commands ran outside a git repo, │ ┃ changes can't be shown. Run git init to tra ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ p/n round esc close ✻ Worked for 42s · done 4:20 PM │ │ › /changes │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits no git shell changes

Draws

Pane · Changes
◀ Round 1 of 1 latest ▶ ▎ (background work after turn 1) turn 1 · 08:53 No file changes recorded ⚠ 2 shell commands ran outside a git repo, so their changes can't be shown. Run git init to track them. p/n round esc close
README

Round Changes

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 Code mod License: MIT Tested on Claude Code 2.1.293

The Round Changes viewer docked beside the chat, listing three files a round changed with M, DEL and NEW badges and their line counts

Why

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.

Features

  • Rounds: every prompt that changed files becomes a round. Step through them with p and n.
  • Every kind of change: Edit, Write and notebook edits are recorded as they happen. In a git repo, shell commands are diffed too, including new and deleted files.
  • Clear diffs: syntax-highlighted, with line numbers, one section per edit, and the command that made each shell change.
  • Open in VS Code: jumps to the first changed line, in the window you already have open.
  • Audit in VS Code: opens the project with every file the round touched, so the editor's git markers and Source Control view show each change in place.
  • Find in chat: scrolls the conversation back to the tool call that made the change.
  • Takes no space until you want it: a chip on each round's "✻ Worked for…" line and a button in the prompt footer open the viewer. Neither adds a row.
  • Fits small terminals: the layout adapts from 46 columns up, and Esc puts it away.

Install

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.

How to use it

Work with Claude as usual. When a round changes files, three ways in appear:

WhereWhat you seeWhat it opens
End of the round, on the "✻ Worked for…" line3 files +7 −6 [ view ]That round
Prompt footer, right side3 files +7 −6The latest round
Anywhere/changesThe latest round

A round's chip, '1 file +2 view', on the Baked for line, and the same counts at the right of the prompt footer

The viewer has two screens. The round lists every file it changed:

  • M, NEW or DEL, with green and red line counts.
  • In wider layouts, the folder and a size bar so the big changes stand out.

A file shows its diff, one section per edit:

A file's diff in the viewer: an EDIT badge, buttons to open it in VS Code or find it in the chat, and the edit with added lines in green

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

A file deleted by a shell command: a DEL badge, a Shell section showing the command, and the removed lines in red

Keys

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–9Open that file's diff (or click its row)
aAudit the round in VS Code
p / nOlder / newer round
escClose
On a file
oOpen in VS Code at the changed line
tFind the change in the chat
p / nPrevious / next file in the round
g / eJump to the top / bottom of a long diff
↑ ↓Scroll
b or escBack to the round

Layout and small terminals

  • Fullscreen layout, 110+ columns: the viewer docks on the right, about 52 columns wide, leaving most of the row to the chat.
  • Any other size: it opens above the prompt, at full width and only as tall as it needs. Esc closes it.
  • Narrow widths: each row drops what doesn't fit, folders and size bars first, and keeps the file names and counts. It stays usable down to about 46 columns.

The viewer above the prompt in a 64-column terminal, listing three files with their badges and counts

Claude Code docks panels on the right only in its fullscreen layout at 110 columns or more. A mod can't change that.

How shell changes are captured

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:

  1. Before the command: 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.
  2. After the command: the same again, then 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:

  • They follow your .gitignore.
  • They include untracked files, so new files show up.
  • They write file contents into .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.

What it runs on your machine

Round Changes makes no network requests and calls no model.

WhenCommand
Shell commands, until a repo is foundgit rev-parse to find the repo. cp seeds the private index, once.
Around each shell commandgit add -A and git write-tree with the private index, then git diff between the two snapshots
When you press Open in VS Codecode -r -g <file>:<line>
When you press Audit in VS Codecode <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.

Requirements

  • Claude Code: built and tested on 2.1.293.
  • git: to capture shell changes.
  • VS Code's 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.
  • Clicking needs a terminal that passes mouse clicks to Claude Code (its fullscreen layout). The keys work everywhere.

Limitations

  • Shell commands are diffed only in git repos.
  • A shell command is credited with every change in the repo while it ran. That includes files your editor or a dev server saved in that moment.
  • Commands started in the background aren't captured once they return. Changes outside the repo aren't captured either.
  • Rounds rebuilt from the transcript show only Edit, Write and notebook changes. Shell diffs and the "Worked for" chip need the mod to be loaded while the round runs.
  • Each change's diff is capped at 400 lines. Press o to see the rest in VS Code.
  • Changes made by subagents are listed, but Find in chat can't open a subagent's transcript.
  • The footer button hides when the footer row is too narrow, below about 64 columns. The chip and /changes still work.
  • Only VS Code is supported for Open in editor.

Development

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.

License

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.

Source 3 files
hooks/register.tsx 898 lines
1import { 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}
898
hooks/model.ts 557 lines
1import 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}
557
types/index.d.ts 81 lines
1/** 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