Copilot-style review of the files Claude changes: a tree of changed files with +/- counts, accept/reject per file, folder or all, and a VS Code diff to revert…

Review every file Claude touches, like a pull request, without leaving the terminal.
<img src="assets/demo.gif" alt="pending-changes demo: Claude edits files, the review pane lists them, changes are reverted in VS Code and rejected with one click" width="900">
Claude just edited five files. Which ones? What changed? How do you undo only the bad part?
pending-changes docks a GitHub Copilot-style review pane next to your chat. Every file Claude creates, edits or deletes shows up as a tree with green/red line counts. Accept or reject per file, per folder or all at once, or open a side-by-side VS Code diff to revert single blocks. Whatever you decide, Claude is told, so its picture of the code never drifts from what's actually on disk.
/plugin install pending-changes --marketplace comertial/comertial-mods
| 🌳 Changes as a tree | Every changed file with +added -removed counts, new and deleted tags, and collapsible, compact folders. |
| ✅ One-click accept / reject | Per file, per folder or everything. Reject restores originals, deletes new files (and the empty folders Claude made for them) and brings deleted files back. |
| 🔍 Real diffs in VS Code | Click a file for a side-by-side diff. Revert single blocks or edit by hand; the pane updates within ~2 seconds. |
| 🐚 Catches shell changes too | Not just Edit/Write: formatters, codegen, git checkout, npm install, scripts. Anything that moves in git status is tracked. |
| 🧠 Claude stays in sync | Every reject, revert or hand edit is reported back to Claude, so it never works from a stale picture of your code. |
▾ demo/config/ +4 -0 [ ✓ ] [ ✗ ]
└─ settings.json new +4 -0 [ ✓ ] [ ✗ ]
▾ testFolder/ +11 -1 [ ✓ ] [ ✗ ]
├─ ▾ sub/ +7 -0 [ ✓ ] [ ✗ ]
│ ├─ deeper.json new +4 -0 [ ✓ ] [ ✗ ]
│ └─ notes.md new +3 -0 [ ✓ ] [ ✗ ]
└─ test.txt +1 -1 [ ✓ ] [ ✗ ]
t̶e̶s̶t̶.̶j̶s̶o̶n̶ deleted +0 -3 [ ✓ ] [ ✗ ]
5 files +15 -4 [ Accept all ] [ Reject all ]
Published through the comertial-mods marketplace. In a Claude Code session:
/plugin install pending-changes --marketplace comertial/comertial-mods
Or from a shell:
claude plugin marketplace add comertial/comertial-mods
claude plugin install pending-changes@comertial-mods --scope user
--scope user loads it in every session, in any project. Verify with claude plugin list: it should show pending-changes@comertial-mods with Status: ✔ enabled.
| Update | claude plugin marketplace update comertial-mods, then claude plugin update pending-changes@comertial-mods |
| Remove | claude plugin uninstall pending-changes@comertial-mods |
Requirements
code command on PATH for the diff view. Without it, clicking a file shows a toast.It opens on its own the first time Claude changes a file in the project, or any time with /changes.
| Where it shows | |
|---|---|
/changes | At any width: docked beside the chat on a wide fullscreen terminal (110+ columns), otherwise below the chat, above the prompt. |
| On its own | Claude Code only seats a pane a mod opens unprompted from 144 columns (110 once you've opened it with /changes, until you close it by hand). On a narrower terminal it waits, and a toast tells you to run /changes. |
| Element | What it does |
|---|---|
| File row | File name, a new tag for files Claude created or a red deleted tag (struck-through name) for files Claude deleted, then green +added and red -removed counts. |
| File name (click) | Opens the VS Code diff for that file. |
| ✓ on a file | Accepts: removes it from the list. The change is already on disk, so nothing else happens. |
| ✗ on a file | Rejects: restores the original, deletes a file Claude created (plus any now-empty folders Claude created), or recreates a file Claude deleted. Then Claude is told. |
| Folder row | Bold colored name with ▾ (expanded) or ▸ (collapsed), plus the combined counts of everything under it. |
| Folder name (click) | Collapses or expands it. The state survives mod reloads. |
| ✓ / ✗ on a folder | Accepts or rejects every file under it, including subfolders. |
| Accept all / Reject all | The same for every file in the list. |
| Hover | The row under the mouse gets a grey background, so it's clear which ✓/✗ belongs to which file. |
Layout: folders come before files, each sorted by name. A folder containing only one other folder is merged into one row (▾ demo/config/), like VS Code's compact folders. Dim ├─ └─ │ guides show nesting below the top level. Long names are trimmed from the left with ….
Clicking a file runs code --diff <original> <file>:
Revert single blocks with the gutter arrow between the sides (VS Code calls it "Revert Block"; it copies the original block back over Claude's version), edit the right side by hand, and save. Saves go straight to your project file. Within about 2 seconds:
Only files inside the session's working directory. Scratch scripts in temp folders and files elsewhere are ignored.
| Source of the change | How it's caught |
|---|---|
| Claude's Edit, Write, NotebookEdit | Directly: each call is hooked, the file's original is saved before the first touch, and the diff is recomputed after. |
| Claude's Bash / PowerShell, in a git work tree | git status before and after each command that might write. Edits, new files, deletions, renames (shown as delete + new), git checkout/restore, formatters, codegen, npm install, scripts. How the command is written doesn't matter. |
| Claude's shell commands, outside git | Only files already in the list, plus plain deletes of named files: rm, del, erase, Remove-Item, ri. |
| Your own edits to a listed file | The 2-second refresh updates the counts, and Claude is told on your next message. |
Not tracked: gitignored files (node_modules, build output, .env), files outside the project, and your edits to files that aren't listed.
Multi-round changes. The original is saved the first time Claude touches a file; later edits, in the same turn or later ones, add to the same diff. The pane always shows the net change from that first original, so a file Claude changes and then changes back disappears. Files stay listed across prompts until you resolve them.
Notes are delivered to Claude as hidden context (Claude reads them; your chat doesn't show them), at whichever comes first: the next tool result if Claude is mid-task, or your next message.
| Event | Note Claude receives |
|---|---|
| You reject a change | "The user rejected your changes to X: it is back to its content from before you edited it. Re-read it before editing it again." |
| You reject a new file | "The user rejected your new file X: it has been deleted." |
| You reject a deletion | "The user rejected your deletion of X: it has been restored." |
| You revert every block in the diff | "The user reverted all your changes to X…" (or the new-file rejection above) |
| You edit a listed file yourself | "The user modified X after your last edit; re-read it before editing it again." |
| You delete a listed file yourself | "The user deleted X after your last edit." |
| You accept | Nothing. |
"You modified" notes come from comparing each file's current content to lastSeenByClaude when you send a message; each change is reported once. As a backstop, Claude Code's Edit tool already refuses to edit a file that changed since Claude last read it.
rm/del/Remove-Item of named files. Edits and new files made by shell commands are missed, as are deletes using globs or variables, inside scripts, or of whole folders. Run inside a git repo for full tracking.git status, so it's invisible.| Symptom | Likely cause / fix |
|---|---|
| The pane doesn't appear | The terminal is too narrow for Claude Code to open it unprompted (see The pane); a toast says so. Run /changes, which opens it at any width. |
| Clicking a file does nothing / toast "Could not open VS Code" | code isn't on PATH. In VS Code: Command Palette → Shell Command: Install 'code' command in PATH. |
| Counts don't update after saving in VS Code | The refresh timer isn't running. Any tool call, prompt or /changes restarts it. If it persists, check claude --debug for pending-changes lines. |
| A shell change wasn't listed | The file is gitignored, outside the project, or the folder isn't a git repo (only plain deletes are caught there). Or the command matched the read-only list. |
| The hook seems to run twice | The installed copy and a --plugin-dir / session dev copy are both loaded. Use one. |
| A diff tab shows "deleted" | Its temp file was cleaned up after accept/reject. Close the tab. |
test.json shows +3 -2 after a PowerShell edit | Windows PowerShell 5.1's -Encoding utf8 adds a byte-order mark to the first line. That's the command, not the mod. |
The mod is one hooks module (hooks/register.tsx) plus pure helper files. It runs inside Claude Code's plugin environment, which has no Node or DOM; everything outside the module is reached through the engine interface $ ($.fs, $.process, $.state, $.ui, $.clock and so on).
All state lives in $.state (the session's state, which survives a mod reload), declared in types/index.d.ts:
| Value | Type | Purpose |
|---|---|---|
files | Record<key, Tracked> | One entry per pending file, keyed by its normalized absolute path (lower-cased on Windows). |
notices | string[] | Notes waiting to be delivered to Claude. |
collapsed | string[] | Folders collapsed in the pane, by path relative to the working directory. |
A Tracked entry holds:
path: the absolute path.rel: the path relative to the working directory.original: the text before Claude's first touch, or null if Claude created the file.added and removed: the line counts.lastSeenByClaude: the text right after Claude's last change, or null if that left the file deleted.isDeleted: set while the file is gone from disk.createdDirs: for a new file, the folders it caused to be created, outermost first.review: set once a diff has been opened; holds the temp original's path.The pane is drawn from this state, so any write to it redraws the pane automatically.
| Hook | Role |
|---|---|
session.start | Registers /changes, starts the refresh timer, sweeps stale temp folders, reopens the pane if files are pending. |
session.end | Deletes this session's temp folder. |
command.run (/changes) | Opens and focuses the pane. |
tool.call | The core. For Edit/Write/NotebookEdit it saves the original before the call and settles the entry after it. For Bash/PowerShell it takes a git snapshot before and compares after. It also delivers queued notices to Claude alongside the tool result. |
prompt.submit | Delivers queued notices, plus "you modified X" notes for files you edited, as hidden context on your next message. |
ui.render (Pane) | Draws the tree. |
countChanges(before, after) runs a Myers O(ND) line diff:
\r?\n, so CRLF vs LF doesn't matter. A trailing newline doesn't count as an extra line.added = m - common and removed = n - common, where common = (n + m - D) / 2.It's written by hand because npm packages aren't available in the mod environment. Past 20,000 edits it falls back to treating all remaining lines as changed.
Every place that observes a file's current content calls settle(key, base, current, seenByClaude):
current === original, the entry is dropped: nothing left to review.isDeleted when current === null. If nothing visible changed, it skips the write, so the pane doesn't redraw for nothing.ensurePolling() starts a $.clock.every(2000) timer. It's called from every hook, not just session.start, because a hot reload drops the old timer and doesn't always fire session.start again. On each tick, for each tracked file:
The refresh pauses while any of Claude's writing tool calls is running (the toolsInFlight counter). Otherwise it could see Claude's change half-done, for example a delete that landed before the after-command check, and report it as yours.
For each Bash/PowerShell call, tool.call does this:
hooks/commands.ts, isReadOnlyCommand). If every part of the command (split on &&, ||, ;, |, newlines) starts with a known read-only verb (ls, cat, grep, find, git log/diff/status/show, Get-Content, Get-ChildItem, Test-Path, …) and nothing redirects (>), tees, -execs or -deletes, nothing more is done.beforeShell):git status --porcelain -z --untracked-files=all and save the content of every file it lists (capped at 500), i.e. the files that already differ from the last commit.deleteTargets).afterShell):git status again.git cat-file --filters HEAD:<path>, which applies line-ending filters so it matches the working copy on Windows; a file git doesn't have counts as new.shellCreatedDirs works out which parent folders hold nothing git or the snapshot knows about, so a later reject can remove them.Failed commands are still compared, because they may have changed files before failing.
Why this design: a full copy of the repo would be too slow for large repos, and parsing commands misses scripts and tools. git status is fast because git keeps an index, it already skips ignored files, and it sees every change regardless of how it was made.
original === null (a new file), delete it, then delete the folders in createdDirs, deepest first, stopping at the first one that isn't empty.original back. This also recreates a deleted file, and its folders.folder/.removePath deletes through $.process.run (cmd /c del/rmdir on Windows, rm -rf elsewhere), because $.fs has no delete. It refuses any path outside an allowed root: the working directory for project files, the temp root for review copies.
Folder ownership: when Claude creates a file, the mod records which folders didn't exist yet. A folder created by an earlier pending write of Claude's also counts. So two new files in a new folder both "own" it, and rejecting both removes it. A folder that existed before is never removed.
Review copies live in %TEMP%\claude-pending-changes\<session id>\, mirroring the project's folders:
<name>.original<ext>: the left side of the diff.<name>.deleted<ext>: an empty right side for deleted files.| When | What's deleted |
|---|---|
| A file is accepted, rejected, or drops off the list | Its .original copy and .deleted stand-in |
The session ends (/exit, /clear) | The whole session folder |
| A session starts | Other sessions' folders older than 24 hours (in case of a crash) |
A VS Code diff tab left open after its temp file is deleted shows the file as deleted; close the tab.
$.state; bold colored folder names and dim tree guides, to tell folders from files.+0 -0. Now it's deleted like a ✗, unless Claude itself created it empty (an __init__.py, say).session.start started the timer. Now every hook starts it if it isn't running, and one bad file no longer stops it for the others.git status before and after (see Tracking shell commands). Deleted files got their own look: struck through, red deleted tag.lastSeenByClaude couldn't represent "deleted"; it's now string | null.comertial/comertial-mods, so later mods join the same comertial-mods marketplace and users add it only once.hooks/register.tsx 803 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Review, Tracked } from '../types'
5import { countChanges } from './diff'
6import { folderId, idOf } from './ids'
7import { deleteTargets, isReadOnlyCommand } from './commands'
8
9const PANE = 'pending-changes'
10const TITLE = 'Changes'
11const POLL_MS = 2000
12const STALE_MS = 24 * 60 * 60 * 1000
13/** The row under the mouse is tinted, so its ✓/✗ read as its own. */
14const ROW_HOVER = 'inactive'
15
16const files = atom({ plugin: 'pending-changes', key: 'files' } as const, {} as Record<string, Tracked>)
17const notices = atom({ plugin: 'pending-changes', key: 'notices' } as const, [] as string[])
18const collapsed = atom({ plugin: 'pending-changes', key: 'collapsed' } as const, [] as string[])
19
20type $ = EngineInterface
21
22// ---------------------------------------------------------------- context
23
24type Ctx = {
25 cwd: string
26 isWindows: boolean
27 /** `<OS temp>/claude-pending-changes`: the only place temp cleanup may delete. */
28 tmpRoot: string
29 /** The git work tree holding cwd, `/`-separated; null outside git (shell changes then fall back to delete commands). */
30 gitRoot: string | null
31 /** `<tmpRoot>/<sessionId>`: this session's review copies. */
32 sessionDir: string
33}
34
35let ctxPromise: Promise<Ctx> | undefined
36
37function ctx($: $): Promise<Ctx> {
38 ctxPromise ??= (async () => {
39 const cwd = slash(await $.session.cwd())
40 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
41 const tmp =
42 (await $.env.get('TEMP')) ?? (await $.env.get('TMPDIR')) ?? (await $.env.get('TMP')) ?? '/tmp'
43 const tmpRoot = `${slash(tmp)}/claude-pending-changes`
44 const sessionDir = `${tmpRoot}/${await $.session.id()}`
45 return { cwd, isWindows, tmpRoot, sessionDir, gitRoot: await findGitRoot($, cwd) }
46 })().catch(error => {
47 ctxPromise = undefined
48 throw error
49 })
50 return ctxPromise
51}
52
53// ---------------------------------------------------------------- paths
54
55function slash(path: string): string {
56 return path.replace(/\\/g, '/').replace(/\/+$/, '')
57}
58
59function keyOf(path: string, isWindows: boolean): string {
60 return isWindows ? slash(path).toLowerCase() : slash(path)
61}
62
63function isInside(path: string, root: string, isWindows: boolean): boolean {
64 return keyOf(path, isWindows).startsWith(`${keyOf(root, isWindows)}/`)
65}
66
67function native(path: string, isWindows: boolean): string {
68 return isWindows ? path.replace(/\//g, '\\') : path
69}
70
71function fit(text: string, room: number): string {
72 return text.length <= room ? text : `…${text.slice(text.length - Math.max(1, room - 1))}`
73}
74
75// ---------------------------------------------------------------- host io
76
77async function readOrNull($: $, path: string): Promise<string | null> {
78 return (await $.fs.exists(path)) ? await $.fs.read(path) : null
79}
80
81/** Deletes a file or folder, refusing anything outside `allowedRoot`. */
82async function removePath($: $, c: Ctx, path: string, allowedRoot: string): Promise<void> {
83 if (!isInside(path, allowedRoot, c.isWindows)) return
84 if (!(await $.fs.exists(path))) return
85 const p = native(path, c.isWindows)
86 if (!c.isWindows) {
87 await $.process.run(['rm', '-rf', p])
88 return
89 }
90 const { kind } = await $.fs.stat(path)
91 await $.process.run(kind === 'dir' ? ['cmd', '/c', 'rmdir', '/s', '/q', p] : ['cmd', '/c', 'del', '/f', '/q', p])
92}
93
94async function openInVsCode($: $, c: Ctx, review: Review, source: string): Promise<void> {
95 const left = native(review.originalCopy, c.isWindows)
96 const right = native(source, c.isWindows)
97 const argv = c.isWindows ? ['cmd', '/c', 'code', '--diff', left, right] : ['code', '--diff', left, right]
98 try {
99 const { exitCode, stderr } = await $.process.run(argv)
100 if (exitCode !== 0) $.ui.toast(`Could not open VS Code (is \`code\` on PATH?) ${stderr.trim().slice(0, 200)}`)
101 } catch {
102 $.ui.toast('Could not open VS Code: is `code` on PATH?')
103 }
104}
105
106// ---------------------------------------------------------------- state
107
108async function queueNotice($: $, text: string): Promise<void> {
109 await update($, notices, list => [...list, text])
110}
111
112type Base = Pick<Tracked, 'path' | 'rel' | 'original' | 'createdDirs'>
113
114/** Records `current` against the original, or drops the entry when they match again. */
115async function settle(
116 $: $,
117 key: string,
118 base: Base,
119 current: string | null,
120 seenByClaude: string | null | undefined,
121): Promise<{ wasEmpty: boolean; dropped?: Tracked }> {
122 let wasEmpty = false
123 let dropped: Tracked | undefined
124 await update($, files, all => {
125 wasEmpty = Object.keys(all).length === 0
126 const prev = all[key]
127 const next = { ...all }
128 if (current === base.original) {
129 dropped = prev
130 delete next[key]
131 return next
132 }
133 const { added, removed } = countChanges(base.original, current)
134 const isDeleted = current === null
135 if (
136 prev &&
137 seenByClaude === undefined &&
138 prev.added === added &&
139 prev.removed === removed &&
140 Boolean(prev.isDeleted) === isDeleted
141 ) {
142 return all
143 }
144 next[key] = {
145 path: base.path,
146 rel: base.rel,
147 original: base.original,
148 ...(base.createdDirs?.length ? { createdDirs: base.createdDirs } : {}),
149 added,
150 removed,
151 lastSeenByClaude: seenByClaude !== undefined ? seenByClaude : prev ? prev.lastSeenByClaude : current,
152 ...(current === null ? { isDeleted: true } : {}),
153 ...(prev?.review ? { review: prev.review } : {}),
154 }
155 return next
156 })
157 return { wasEmpty, dropped }
158}
159
160async function forget($: $, key: string): Promise<void> {
161 await update($, files, all => {
162 if (!(key in all)) return all
163 const next = { ...all }
164 delete next[key]
165 return next
166 })
167}
168
169async function cleanupReview($: $, review: Review | undefined): Promise<void> {
170 if (!review) return
171 const c = await ctx($)
172 await removePath($, c, review.originalCopy, c.tmpRoot)
173 if (review.deletedCopy) await removePath($, c, review.deletedCopy, c.tmpRoot)
174}
175
176// ---------------------------------------------------------------- actions
177
178async function accept($: $, key: string): Promise<void> {
179 const entry = (await read($, files))[key]
180 if (!entry) return
181 await forget($, key)
182 await cleanupReview($, entry.review)
183}
184
185async function reject($: $, key: string): Promise<void> {
186 const entry = (await read($, files))[key]
187 if (!entry) return
188 const c = await ctx($)
189 if (entry.original === null) {
190 await removePath($, c, entry.path, c.cwd)
191 await removeEmptyDirs($, c, entry.createdDirs ?? [])
192 } else {
193 await $.fs.write(entry.path, entry.original)
194 }
195 await forget($, key)
196 await cleanupReview($, entry.review)
197 await queueNotice(
198 $,
199 entry.original === null
200 ? `The user rejected your new file \`${entry.rel}\`: it has been deleted.`
201 : entry.isDeleted
202 ? `The user rejected your deletion of \`${entry.rel}\`: it has been restored.`
203 : `The user rejected your changes to \`${entry.rel}\`: it is back to its content from before you edited it. Re-read it before editing it again.`,
204 )
205}
206
207/**
208 * Opens VS Code's diff: the original (a temp copy) read-only on the left, the
209 * source file itself editable on the right. A block's arrow reverts it; saves
210 * land in the source, and the poll keeps the counts and the list true.
211 */
212async function openReview($: $, key: string): Promise<void> {
213 const entry = (await read($, files))[key]
214 if (!entry) return
215 const c = await ctx($)
216 // A review from the earlier layout (kept in $.state across the reload) has no originalCopy.
217 const cut = entry.rel.lastIndexOf('/')
218 const dir = cut < 0 ? '' : `/${entry.rel.slice(0, cut)}`
219 const name = entry.rel.slice(cut + 1)
220 const dot = name.lastIndexOf('.')
221 const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, '']
222
223 // A deleted file has no right side to edit: show it against an empty stand-in.
224 // It is recorded on the review so resolving the file deletes it too.
225 let deletedCopy: string | undefined
226 if (!(await $.fs.exists(entry.path))) {
227 deletedCopy = `${c.sessionDir}${dir}/${stem}.deleted${ext}`
228 await $.fs.write(deletedCopy, '')
229 }
230
231 // A review from the earlier layout (kept in $.state across the reload) has no originalCopy.
232 const reusable = entry.review?.originalCopy && (await $.fs.exists(entry.review.originalCopy))
233 let review: Review = reusable ? entry.review! : { originalCopy: `${c.sessionDir}${dir}/${stem}.original${ext}` }
234 if (!reusable) await $.fs.write(review.originalCopy, entry.original ?? '')
235 if (deletedCopy) review = { ...review, deletedCopy }
236 if (review !== entry.review) {
237 await update($, files, all => (all[key] ? { ...all, [key]: { ...all[key]!, review } } : all))
238 }
239 await openInVsCode($, c, review, deletedCopy ?? entry.path)
240}
241
242/** Deletes the folders Claude's write created, deepest first, while each is empty. */
243async function removeEmptyDirs($: $, c: Ctx, dirs: readonly string[]): Promise<void> {
244 for (const dir of [...dirs].reverse()) {
245 if (!(await $.fs.exists(dir))) continue
246 if ((await $.fs.list(dir)).length > 0) return
247 await removePath($, c, dir, c.cwd)
248 }
249}
250
251/** Runs `act` on every tracked file, or on those under the folder `relDir`. */
252async function forAll($: $, act: (key: string) => Promise<void>, relDir?: string): Promise<void> {
253 for (const [key, entry] of Object.entries(await read($, files))) {
254 if (relDir === undefined || entry.rel.startsWith(`${relDir}/`)) await act(key)
255 }
256}
257
258/**
259 * The folders between the working directory and `rel`'s file that Claude is
260 * creating: missing now, or created by an earlier pending write of Claude's.
261 */
262async function missingDirs($: $, c: Ctx, rel: string): Promise<string[]> {
263 const byClaude = new Set(
264 Object.values(await read($, files)).flatMap(f => (f.createdDirs ?? []).map(d => keyOf(d, c.isWindows))),
265 )
266 const parts = rel.split('/').slice(0, -1)
267 const missing: string[] = []
268 for (let i = 1; i <= parts.length; i++) {
269 const dir = `${c.cwd}/${parts.slice(0, i).join('/')}`
270 if (missing.length > 0 || byClaude.has(keyOf(dir, c.isWindows)) || !(await $.fs.exists(dir))) missing.push(dir)
271 }
272 return missing
273}
274
275// ---------------------------------------------------------------- tree
276
277type FolderNode = {
278 name: string
279 rel: string
280 folders: Map<string, FolderNode>
281 files: [string, Tracked][]
282 added: number
283 removed: number
284}
285
286/** `guide` is the dim tree drawing in front of the row: `│ ├─ `. */
287type Row =
288 | { kind: 'folder'; guide: string; node: FolderNode }
289 | { kind: 'file'; guide: string; key: string; name: string; file: Tracked }
290
291/** Groups the files by folder and flattens them, folders first, chains of lone folders merged. */
292function treeRows(all: [string, Tracked][], closed: ReadonlySet<string> = new Set()): Row[] {
293 const root: FolderNode = { name: '', rel: '', folders: new Map(), files: [], added: 0, removed: 0 }
294 for (const [key, file] of all) {
295 let node = root
296 for (const part of file.rel.split('/').slice(0, -1)) {
297 let child = node.folders.get(part)
298 if (!child) {
299 const rel = node.rel ? `${node.rel}/${part}` : part
300 child = { name: part, rel, folders: new Map(), files: [], added: 0, removed: 0 }
301 node.folders.set(part, child)
302 }
303 node = child
304 }
305 node.files.push([key, file])
306 }
307
308 const total = (node: FolderNode): void => {
309 for (const child of node.folders.values()) {
310 total(child)
311 node.added += child.added
312 node.removed += child.removed
313 }
314 for (const [, f] of node.files) {
315 node.added += f.added
316 node.removed += f.removed
317 }
318 }
319 total(root)
320
321 const rows: Row[] = []
322 // Top-level rows get no connector; below them, `├─`/`└─` and `│` columns show the nesting.
323 const walk = (node: FolderNode, prefix: string, isTop: boolean): void => {
324 const folders = [...node.folders.values()].sort((a, b) => a.name.localeCompare(b.name))
325 const files = [...node.files].sort((a, b) => a[1].rel.localeCompare(b[1].rel))
326 const count = folders.length + files.length
327 let index = 0
328 const place = () => {
329 const isLast = ++index === count
330 return {
331 guide: isTop ? '' : `${prefix}${isLast ? '└─ ' : '├─ '}`,
332 childPrefix: isTop ? '' : `${prefix}${isLast ? ' ' : '│ '}`,
333 }
334 }
335 for (let folder of folders) {
336 let name = folder.name
337 while (folder.files.length === 0 && folder.folders.size === 1) {
338 folder = [...folder.folders.values()][0]!
339 name = `${name}/${folder.name}`
340 }
341 const { guide, childPrefix } = place()
342 rows.push({ kind: 'folder', guide, node: { ...folder, name } })
343 if (!closed.has(folder.rel)) walk(folder, childPrefix, false)
344 }
345 for (const [key, file] of files) {
346 const { guide } = place()
347 rows.push({ kind: 'file', guide, key, name: file.rel.slice(file.rel.lastIndexOf('/') + 1), file })
348 }
349 }
350 walk(root, '', true)
351 return rows
352}
353
354// ---------------------------------------------------------------- polling
355
356let pollTimer: { cancel: () => void } | undefined
357
358/**
359 * Claude tool calls that may write and are still running. The poll waits while
360 * any is, or it would see Claude's own change half-done and call it the user's.
361 */
362let toolsInFlight = 0
363
364/**
365 * Starts the 2 s refresh if this load of the module has none. Called from
366 * every hook, since a reload drops the timer and may not raise session.start.
367 */
368function ensurePolling($: $): void {
369 if (pollTimer) return
370 pollTimer = $.clock.every(POLL_MS, () => {
371 void poll($).catch(() => {})
372 })
373}
374
375/** Keeps counts true after edits made outside Claude, and drops files back at their original. */
376async function poll($: $): Promise<void> {
377 if (toolsInFlight > 0) return
378 const all = await read($, files)
379 for (const [key, entry] of Object.entries(all)) {
380 try {
381 const current = await readOrNull($, entry.path)
382 if (toolsInFlight > 0) return
383 // A new file the user emptied (every block reverted in the diff) is a rejection.
384 // One Claude itself wrote empty stays.
385 if (entry.original === null && current === '' && entry.lastSeenByClaude) {
386 await reject($, key)
387 continue
388 }
389 const { dropped } = await settle($, key, entry, current, undefined)
390 if (!dropped) continue
391 await cleanupReview($, dropped.review)
392 if (current !== dropped.lastSeenByClaude) {
393 await queueNotice(
394 $,
395 `The user reverted all your changes to \`${dropped.rel}\`: it is back to its content from before you edited it. Re-read it before editing it again.`,
396 )
397 }
398 } catch {
399 // One unreadable file must not stop the others from refreshing.
400 }
401 }
402}
403
404async function sweepStale($: $, c: Ctx): Promise<void> {
405 if (!(await $.fs.exists(c.tmpRoot))) return
406 const now = await $.clock.now()
407 for (const entry of await $.fs.list(c.tmpRoot)) {
408 const path = `${c.tmpRoot}/${entry.name}`
409 if (path === c.sessionDir) continue
410 const { mtimeMs } = await $.fs.stat(path)
411 if (now - mtimeMs > STALE_MS) await removePath($, c, path, c.tmpRoot)
412 }
413}
414
415// ---------------------------------------------------------------- shell commands
416//
417// Bash and PowerShell calls are bracketed by two `git status` runs: what moved
418// between them is what the command changed, however it was written. Outside a
419// git work tree only plain delete commands (`rm`, `del`, `Remove-Item`) are seen.
420
421async function findGitRoot($: $, cwd: string): Promise<string | null> {
422 try {
423 const { exitCode, stdout } = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
424 return exitCode === 0 && stdout.trim() ? slash(stdout.trim()) : null
425 } catch {
426 return null
427 }
428}
429
430/** The paths (relative to the work tree) that differ from HEAD, untracked files included, ignored ones not. */
431async function gitDirty($: $, c: Ctx): Promise<Set<string> | null> {
432 if (!c.gitRoot) return null
433 const { exitCode, stdout } = await $.process.run(
434 ['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
435 { cwd: c.gitRoot },
436 )
437 if (exitCode !== 0) return null
438 const paths = new Set<string>()
439 const parts = stdout.split('\0')
440 for (let i = 0; i < parts.length; i++) {
441 const entry = parts[i]!
442 if (entry.length < 4) continue
443 paths.add(entry.slice(3))
444 // A rename or copy carries its old path as the next field.
445 if (entry[0] === 'R' || entry[0] === 'C') {
446 const old = parts[++i]
447 if (old) paths.add(old)
448 }
449 }
450 return paths
451}
452
453/** The file's committed text as the work tree would hold it (eol filters applied), or null when HEAD has none. */
454async function headContent($: $, c: Ctx, gitRel: string): Promise<string | null> {
455 if (!c.gitRoot) return null
456 const { exitCode, stdout } = await $.process.run(['git', 'cat-file', '--filters', `HEAD:${gitRel}`], {
457 cwd: c.gitRoot,
458 })
459 return exitCode === 0 ? stdout : null
460}
461
462type ShellSnapshot = {
463 /** Paths (work-tree relative) dirty before the command; null outside git. */
464 dirty: Set<string> | null
465 /** Content before the command, by key: dirty files, tracked files, delete targets. */
466 before: Map<string, { path: string; text: string | null }>
467}
468
469function absolute(c: Ctx, path: string): string {
470 const p = slash(path)
471 return /^([a-z]:)?\//i.test(p) ? p : `${c.cwd}/${p.replace(/^\.\//, '')}`
472}
473
474async function beforeShell($: $, c: Ctx, command: string): Promise<ShellSnapshot> {
475 const before = new Map<string, { path: string; text: string | null }>()
476 const keep = async (path: string) => {
477 const key = keyOf(path, c.isWindows)
478 if (before.has(key) || !isInside(path, c.cwd, c.isWindows)) return
479 try {
480 before.set(key, { path, text: await readOrNull($, path) })
481 } catch {
482 // Unreadable (too large, a folder): not compared.
483 }
484 }
485 for (const entry of Object.values(await read($, files))) await keep(entry.path)
486 const dirty = await gitDirty($, c).catch(() => null)
487 if (dirty && c.gitRoot) {
488 for (const rel of [...dirty].slice(0, 500)) await keep(`${c.gitRoot}/${rel}`)
489 } else {
490 for (const target of deleteTargets(command)) await keep(absolute(c, target))
491 }
492 return { dirty, before }
493}
494
495/** The folders between cwd and a file a command created that hold nothing git or the snapshot knows. */
496async function shellCreatedDirs($: $, c: Ctx, snap: ShellSnapshot, rel: string): Promise<string[]> {
497 if (!c.gitRoot) return []
498 const known = [...snap.before.keys()]
499 const created: string[] = []
500 const parts = rel.split('/').slice(0, -1)
501 for (let i = parts.length; i >= 1; i--) {
502 const dir = `${c.cwd}/${parts.slice(0, i).join('/')}`
503 const dirKey = keyOf(dir, c.isWindows)
504 if (known.some(k => k.startsWith(`${dirKey}/`))) break
505 const { stdout } = await $.process.run(['git', 'ls-files', '--', dir], { cwd: c.gitRoot })
506 if (stdout.trim() !== '') break
507 created.unshift(dir)
508 }
509 return created
510}
511
512async function afterShell($: $, c: Ctx, snap: ShellSnapshot): Promise<void> {
513 const candidates = new Map<string, string>() // key -> absolute path
514 for (const [key, { path }] of snap.before) candidates.set(key, path)
515 const dirty = snap.dirty ? await gitDirty($, c).catch(() => null) : null
516 if (dirty && c.gitRoot) {
517 for (const rel of dirty) {
518 const path = `${c.gitRoot}/${rel}`
519 if (isInside(path, c.cwd, c.isWindows)) candidates.set(keyOf(path, c.isWindows), path)
520 }
521 }
522
523 const tracked = await read($, files)
524 for (const [key, path] of candidates) {
525 let current: string | null
526 try {
527 current = await readOrNull($, path)
528 } catch {
529 continue
530 }
531 const had = snap.before.get(key)
532 if (had && had.text === current) continue // the command left it as it was
533
534 const known = tracked[key]
535 if (known) {
536 const { dropped } = await settle($, key, known, current, current)
537 if (dropped) await cleanupReview($, dropped.review)
538 continue
539 }
540 let original: string | null
541 if (had) original = had.text
542 else if (c.gitRoot) original = await headContent($, c, slash(path).slice(c.gitRoot.length + 1))
543 else continue
544 if (current === original) continue
545
546 const rel = slash(path).slice(c.cwd.length + 1)
547 const createdDirs = original === null ? await shellCreatedDirs($, c, snap, rel) : undefined
548 const { wasEmpty, dropped } = await settle($, key, { path, rel, original, createdDirs }, current, current)
549 if (wasEmpty && !dropped) void openUnasked($, CHANGED_HINT)
550 }
551}
552
553// ---------------------------------------------------------------- opening the pane
554
555const CHANGED_HINT = 'Claude changed files. Run /changes to review them.'
556
557/**
558 * Opens the pane on the mod's own initiative. The engine seats such a pane only
559 * from 144 columns (110 once the person opened it), so on a narrower terminal
560 * it waits undrawn: then a toast says how to see it.
561 */
562async function openUnasked($: $, hint: string): Promise<void> {
563 const opened = await $.ui.open({ id: PANE, title: TITLE })
564 if (!opened.isPlaced) $.ui.toast(hint, { timeoutMs: 8000 })
565}
566
567// ---------------------------------------------------------------- hooks
568
569export const register: Register = on => {
570 on('session.start', async ($, e, next) => {
571 await $.command.register({
572 name: 'changes',
573 description: 'Show the files Claude changed, to accept, reject or review',
574 })
575 ensurePolling($)
576 void ctx($)
577 .then(c => sweepStale($, c))
578 .catch(() => {})
579 const pending = Object.keys(await read($, files)).length
580 if (pending > 0) {
581 void openUnasked($, `${pending} file${pending === 1 ? '' : 's'} still pending review. Run /changes to see them.`)
582 }
583 return next(e)
584 })
585
586 on('session.end', async ($, e, next) => {
587 try {
588 const c = await ctx($)
589 await removePath($, c, c.sessionDir, c.tmpRoot)
590 } catch {
591 // Nothing to clean, or the host is going away.
592 }
593 ctxPromise = undefined
594 return next(e)
595 })
596
597 on('command.run', { command: 'changes' }, async $ => {
598 ensurePolling($)
599 await $.ui.open({ id: PANE, title: TITLE, focus: true })
600 return { text: 'Changes pane opened.' }
601 })
602
603 on('tool.call', async ($, e, next) => {
604 ensurePolling($)
605 const path =
606 e.tool === 'Edit' || e.tool === 'Write'
607 ? e.file_path
608 : e.tool === 'NotebookEdit'
609 ? e.notebook_path
610 : undefined
611
612 let tracked: { key: string; base: Base } | undefined
613 if (path !== undefined) {
614 const c = await ctx($)
615 if (isInside(path, c.cwd, c.isWindows)) {
616 const key = keyOf(path, c.isWindows)
617 const rel = slash(path).slice(c.cwd.length + 1)
618 const known = (await read($, files))[key]
619 const original = known ? known.original : await readOrNull($, path)
620 const createdDirs = known ? known.createdDirs : original === null ? await missingDirs($, c, rel) : undefined
621 tracked = { key, base: { path, rel, original, createdDirs } }
622 }
623 }
624
625 // A shell command that may write: note what git sees as changed before it runs.
626 const command = e.tool === 'Bash' || e.tool === 'PowerShell' ? e.command : undefined
627 let shell: { c: Ctx; snap: ShellSnapshot } | undefined
628 if (command !== undefined && !isReadOnlyCommand(command)) {
629 try {
630 const c = await ctx($)
631 shell = { c, snap: await beforeShell($, c, command) }
632 } catch {
633 // No snapshot: the command runs untracked.
634 }
635 }
636
637 const isWriting = tracked !== undefined || shell !== undefined
638 if (isWriting) toolsInFlight++
639 try {
640 const ran = await next(e)
641 if (ran.deny !== undefined) return ran
642
643 // Failed commands can still have changed files, so compare either way.
644 if (shell) {
645 try {
646 await afterShell($, shell.c, shell.snap)
647 } catch {
648 // Comparing failed; the poll still refreshes what is already listed.
649 }
650 }
651
652 if (tracked && ran.isError !== true) {
653 const current = await readOrNull($, tracked.base.path)
654 const { wasEmpty, dropped } = await settle($, tracked.key, tracked.base, current, current)
655 if (dropped) await cleanupReview($, dropped.review)
656 if (wasEmpty && !dropped) void openUnasked($, CHANGED_HINT)
657 }
658
659 // Deliver reverts the user made mid-turn with the next tool result.
660 const queued = await read($, notices)
661 if (queued.length === 0) return ran
662 await update($, notices, () => [])
663 return { ...ran, context: [...(ran.context ?? []), ...queued] }
664 } finally {
665 if (isWriting) toolsInFlight--
666 }
667 }).catch(($, e, next) => next(e))
668
669 on('prompt.submit', async ($, e, next) => {
670 ensurePolling($)
671 const notes = [...(await read($, notices))]
672 const all = await read($, files)
673 const seen: Record<string, string | null> = {}
674 for (const [key, entry] of Object.entries(all)) {
675 const current = await readOrNull($, entry.path)
676 if (current === entry.lastSeenByClaude) continue
677 notes.push(
678 current === null
679 ? `The user deleted \`${entry.rel}\` after your last edit.`
680 : `The user modified \`${entry.rel}\` after your last edit; re-read it before editing it again.`,
681 )
682 seen[key] = current
683 }
684 if (notes.length === 0) return next(e)
685
686 await update($, notices, () => [])
687 await update($, files, now => {
688 const out = { ...now }
689 for (const [key, text] of Object.entries(seen)) {
690 if (out[key]) out[key] = { ...out[key]!, lastSeenByClaude: text }
691 }
692 return out
693 })
694 return next({ ...e, context: [...(e.context ?? []), `[pending-changes]\n${notes.join('\n')}`] })
695 }).catch(($, e, next) => next(e))
696
697 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
698 // The pane redraws right after a reload, before any tool call or prompt.
699 try {
700 ensurePolling($)
701 } catch {
702 // Drawing may not start timers on some surface; the other hooks will.
703 }
704 const { Box, Text, Button } = $.ui.resolve(e)
705 const all = Object.entries(await read($, files)).sort((a, b) => a[1].rel.localeCompare(b[1].rel))
706 if (all.length === 0) {
707 return (
708 <Box flexDirection="column">
709 <Text dimColor>No pending changes.</Text>
710 </Box>
711 )
712 }
713
714 const width = e.props.bodyColumns
715 const closedDirs = new Set(await read($, collapsed))
716 const added = all.reduce((sum, [, f]) => sum + f.added, 0)
717 const removed = all.reduce((sum, [, f]) => sum + f.removed, 0)
718
719 return (
720 <Box flexDirection="column">
721 {treeRows(all, closedDirs).map(row => {
722 const guide = row.guide ? <Text dimColor>{row.guide}</Text> : ''
723 const added = row.kind === 'folder' ? row.node.added : row.file.added
724 const removed = row.kind === 'folder' ? row.node.removed : row.file.removed
725 const room = Math.max(8, width - row.guide.length - `+${added} -${removed}`.length - 24)
726 const counts = [
727 <Text color="success">{`+${added}`}</Text>,
728 <Text color="error">{`-${removed}`}</Text>,
729 ]
730 // Pushes the counts and buttons to the right edge.
731 const leader = <Box flexGrow={1} minWidth={1} height={1} />
732
733 if (row.kind === 'folder') {
734 const id = folderId(row.node.rel)
735 const relDir = row.node.rel
736 const isOpen = !closedDirs.has(relDir)
737 const toggle = () =>
738 void update($, collapsed, list =>
739 list.includes(relDir) ? list.filter(d => d !== relDir) : [...list, relDir],
740 )
741 return (
742 <Box key={`row-${id}`} flexDirection="row" gap={1} hover={{ backgroundColor: ROW_HOVER }}>
743 <Button key={`toggle-dir-${id}`} plain onPress={toggle}>
744 {guide}
745 <Text bold color="suggestion">{`${isOpen ? '▾' : '▸'} ${fit(row.node.name, room)}/`}</Text>
746 </Button>
747 {leader}
748 <Box flexDirection="row" gap={1} flexShrink={0}>
749 {counts}
750 <Button
751 key={`accept-dir-${id}`}
752 label="✓"
753 onPress={() => void forAll($, key => accept($, key), relDir)}
754 />
755 <Button
756 key={`reject-dir-${id}`}
757 label="✗"
758 onPress={() => void forAll($, key => reject($, key), relDir)}
759 />
760 </Box>
761 </Box>
762 )
763 }
764
765 const { key, file } = row
766 const id = idOf(key)
767 return (
768 <Box key={`row-${id}`} flexDirection="row" gap={1} hover={{ backgroundColor: ROW_HOVER }}>
769 <Button key={`open-${id}`} plain onPress={() => void openReview($, key)}>
770 {guide}
771 {file.isDeleted ? <Text strikethrough dimColor>{fit(row.name, room)}</Text> : fit(row.name, room)}
772 {file.original === null ? <Text dimColor> new</Text> : file.isDeleted ? <Text color="error"> deleted</Text> : ''}
773 </Button>
774 {leader}
775 <Box flexDirection="row" gap={1} flexShrink={0}>
776 {counts}
777 <Button key={`accept-${id}`} label="✓" onPress={() => void accept($, key)} />
778 <Button key={`reject-${id}`} label="✗" onPress={() => void reject($, key)} />
779 </Box>
780 </Box>
781 )
782 })}
783 <Box flexDirection="row" justifyContent="space-between" gap={1} marginTop={1}>
784 <Box flexDirection="row" gap={1}>
785 <Text>{`${all.length} file${all.length === 1 ? '' : 's'}`}</Text>
786 <Text color="success">{`+${added}`}</Text>
787 <Text color="error">{`-${removed}`}</Text>
788 </Box>
789 <Box flexDirection="row" gap={1} flexShrink={0}>
790 <Button
791 key="accept-all"
792 label="Accept all"
793 variant="primary"
794 onPress={() => void forAll($, key => accept($, key))}
795 />
796 <Button key="reject-all" label="Reject all" onPress={() => void forAll($, key => reject($, key))} />
797 </Box>
798 </Box>
799 </Box>
800 )
801 })
802}
803hooks/diff.ts 56 lines1/** Splits text into lines; a trailing newline does not add an empty line. */
2export function lines(text: string | null): string[] {
3 if (text === null || text === '') return []
4 const all = text.split(/\r?\n/)
5 if (all[all.length - 1] === '') all.pop()
6 return all
7}
8
9/**
10 * Lines added and removed between two texts, from the edit distance of a
11 * Myers O(ND) diff. Past `maxD` edits it settles for the length difference
12 * bounds, which only a wholesale rewrite of a huge file reaches.
13 */
14export function countChanges(
15 before: string | null,
16 after: string | null,
17 maxD = 20000,
18): { added: number; removed: number } {
19 const a = lines(before)
20 const b = lines(after)
21 let start = 0
22 while (start < a.length && start < b.length && a[start] === b[start]) start++
23 let endA = a.length
24 let endB = b.length
25 while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
26 endA--
27 endB--
28 }
29 const x = a.slice(start, endA)
30 const y = b.slice(start, endB)
31 const n = x.length
32 const m = y.length
33 if (n === 0 || m === 0) return { added: m, removed: n }
34
35 const max = Math.min(n + m, maxD)
36 const offset = max + 1
37 const v = new Int32Array(2 * max + 3)
38 for (let d = 0; d <= max; d++) {
39 for (let k = -d; k <= d; k += 2) {
40 const goDown = k === -d || (k !== d && v[offset + k - 1]! < v[offset + k + 1]!)
41 let i = goDown ? v[offset + k + 1]! : v[offset + k - 1]! + 1
42 let j = i - k
43 while (i < n && j < m && x[i] === y[j]) {
44 i++
45 j++
46 }
47 v[offset + k] = i
48 if (i >= n && j >= m) {
49 const common = (n + m - d) / 2
50 return { added: m - common, removed: n - common }
51 }
52 }
53 }
54 return { added: m, removed: n }
55}
56hooks/ids.ts 12 lines1/** A short stable id for element keys, so a press never lands on a shifted row. */
2export function idOf(key: string): string {
3 let hash = 5381
4 for (let i = 0; i < key.length; i++) hash = ((hash * 33) ^ key.charCodeAt(i)) >>> 0
5 return hash.toString(36)
6}
7
8/** The key of a folder row's buttons: `accept-dir-…` and `reject-dir-…`. */
9export function folderId(relDir: string): string {
10 return idOf(`dir:${relDir}`)
11}
12hooks/commands.ts 33 lines1// Pure readings of a shell command's text: no host access, so tests import them directly.
2
3const READ_ONLY_COMMAND =
4 /^(ls|dir|cat|type|head|tail|less|more|wc|grep|egrep|rg|find|pwd|cd|echo|which|where|tree|stat|file|du|df|env|printenv|git\s+(log|diff|status|show|branch|rev-parse|ls-files|blame|remote|fetch)|Get-ChildItem|gci|Get-Content|gc|Get-Item|Get-Location|Select-String|Test-Path|Resolve-Path|Measure-Object|Write-Output|Write-Host|Set-Location)\b/i
5
6/** True when every part of the command only reads: then no git status runs around it. */
7export function isReadOnlyCommand(command: string): boolean {
8 if (/>|\b(tee|xargs|sed\s+-i)\b|-exec\b|-delete\b|Out-File|Set-Content|Add-Content|New-Item|Copy-Item|Move-Item|Remove-Item/i.test(command)) {
9 return false
10 }
11 return command.split(/&&|\|\||[;|\n]/).every(part => part.trim() === '' || READ_ONLY_COMMAND.test(part.trim()))
12}
13
14/** Splits a command into words, honouring single and double quotes. */
15export function words(text: string): string[] {
16 return [...text.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map(m => m[1] ?? m[2] ?? m[3] ?? '')
17}
18
19/** The plain file arguments of delete commands, for folders outside git. */
20export function deleteTargets(command: string): string[] {
21 const targets: string[] = []
22 for (const part of command.split(/&&|\|\||[;|\n]/)) {
23 const [verb, ...args] = words(part.trim())
24 if (!verb || !/^(rm|del|erase|Remove-Item|ri)$/i.test(verb)) continue
25 for (const arg of args) {
26 if (/^-/.test(arg) || /^\/[a-z]$/i.test(arg)) continue
27 targets.push(arg)
28 }
29 }
30 return targets
31}
32
33types/index.d.ts 40 lines1/** The VS Code diff opened for one tracked file. */
2export type Review = {
3 /** Temp copy of the original: the read-only left side; the source is the right. */
4 originalCopy: string
5 /** Empty stand-in for the right side, written when the file was deleted at the time of opening. */
6 deletedCopy?: string
7}
8
9/** One file Claude changed that you have not resolved yet. */
10export type Tracked = {
11 /** The path as the tool named it, absolute. */
12 path: string
13 /** The path relative to the session's working directory, `/`-separated. */
14 rel: string
15 /** The text before Claude's first touch; null when Claude created the file. */
16 original: string | null
17 added: number
18 removed: number
19 /** For a file Claude created: the folders the write created, outermost first. */
20 createdDirs?: string[]
21 /** True while the file is gone from disk (a deletion to accept or reject). */
22 isDeleted?: boolean
23 /** The text right after Claude's last change, or the last note it was sent; null when that left it deleted. */
24 lastSeenByClaude: string | null
25 review?: Review
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 'pending-changes': {
31 /** Keyed by the normalised absolute path. */
32 files: Record<string, Tracked>
33 /** Folders collapsed in the pane, by their path relative to the working directory. */
34 collapsed: string[]
35 /** Notes waiting to reach Claude with the next prompt. */
36 notices: string[]
37 }
38 }
39}
40