/changes: a pane of the files changed in this session with +/- counts; a click unfolds the diff / /changes: панель файлов, изменённых за сессию, со счётчиками…

/changes: a pane of the files changed in this session with +/− counts; a click unfolds the diff.
After a long autonomous turn it is hard to tell what Claude actually touched. /changes opens a pane with every file changed since the session started: its git status letter, how many lines were added and removed, and open: the file opens in VS Code when its code command is on PATH, else in the program Windows opens it with. A click on a file unfolds its diff right in the pane (whole hunks, up to 400 lines); another click folds it. Refresh (r) reads the files again; the list also refreshes at the end of every turn.
The pane counts from the commit the session started on, so commits Claude made during the session are included. Files that already had uncommitted changes before the session are left out, unless Claude changed them with Edit or Write. Outside a git repository the pane lists only the files Claude changed with Edit and Write, without counts.
Runs read-only git commands in the repository (rev-parse, status, diff --numstat, diff --name-status, ls-files --others, diff -- <file>) at session start, on /changes, on Refresh and at the end of each turn; reads new untracked files to count their lines. On open: runs cmd /c code -g <file>, or explorer.exe <file> without VS Code. Adds the /changes command. Changes no files.
/config)language: auto (default; follows LANG, else the Windows UI language or regional format), ru or en./plugin install changes-pane --marketplace ArtyaEXE/claude-mods
Part of claude-mods. MIT license.
/changes: панель файлов, изменённых за сессию, со счётчиками +/−; клик раскрывает дифф.
После длинного автономного хода трудно понять, что Claude на самом деле поменял. /changes открывает панель со всеми файлами, изменёнными с начала сессии: буква статуса git, сколько строк добавлено и удалено, и кнопка открыть: файл откроется в VS Code, если его команда code есть в PATH, иначе в программе, которой Windows открывает такие файлы. Клик по файлу раскрывает его дифф прямо в панели (целыми ханками, до 400 строк), повторный клик сворачивает. Обновить (r) перечитывает файлы; список также обновляется в конце каждого хода.
Панель считает от коммита, на котором началась сессия, поэтому коммиты, сделанные Claude за сессию, тоже попадают в список. Файлы, у которых уже были незакоммиченные изменения до начала сессии, не показываются, если только Claude не правил их через Edit или Write. Вне git-репозитория панель показывает только файлы, изменённые Claude через Edit и Write, без счётчиков.
Запускает в репозитории git-команды только на чтение (rev-parse, status, diff --numstat, diff --name-status, ls-files --others, diff -- <файл>) при старте сессии, по /changes, по «Обновить» и в конце каждого хода; читает новые неотслеживаемые файлы, чтобы посчитать в них строки. По открыть: выполняет cmd /c code -g <файл>, а без VS Code — explorer.exe <файл>. Добавляет команду /changes. Файлы не меняет.
/config)language: auto (по умолчанию; по LANG, иначе по языку интерфейса или региональному формату Windows), ru или en./plugin install changes-pane --marketplace ArtyaEXE/claude-mods
Часть claude-mods. Лицензия MIT.
hooks/register.tsx 397 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Baseline, Changed } from '../types'
5
6type Lang = 'ru' | 'en'
7
8// `ru` / `en` as set; `auto`: the locale variables first, then Windows' UI language or regional format.
9async function resolveLang($: EngineInterface, setting: unknown): Promise<Lang> {
10 if (setting === 'ru' || setting === 'en') return setting
11 const env = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
12 if (env) return env.toLowerCase().startsWith('ru') ? 'ru' : 'en'
13 try {
14 const ran = await $.process.run(
15 ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', '(Get-UICulture).Name; (Get-Culture).Name'],
16 { timeoutMs: 10_000 },
17 )
18 if (ran.exitCode === 0 && /^ru\b/im.test(ran.stdout)) return 'ru'
19 } catch {
20 // Not Windows: English.
21 }
22
23 return 'en'
24}
25
26const PANE = 'changes-pane'
27// git's empty tree: the base of a repository with no commit yet.
28const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
29// Lines of a diff drawn at most, in whole hunks where they fit.
30const DIFF_LINES = 400
31
32const files = atom({ plugin: 'changes-pane', key: 'files' } as const, [] as Changed[], { shape: 'files-1' })
33const selected = atom({ plugin: 'changes-pane', key: 'selected' } as const, '')
34const diff = atom({ plugin: 'changes-pane', key: 'diff' } as const, '')
35const baseline = atom(
36 { plugin: 'changes-pane', key: 'baseline' } as const,
37 { sessionId: '', root: '', base: '', dirty: [], touched: [] } as Baseline,
38 { shape: 'baseline-1' },
39)
40
41const STRINGS = {
42 ru: {
43 command: 'Файлы, изменённые за эту сессию, с диффом',
44 title: 'Изменения сессии',
45 opened: 'Открыл панель изменений.',
46 files: (n: number) => `${n} ${n % 10 === 1 && n % 100 !== 11 ? 'файл' : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14) ? 'файла' : 'файлов'}`,
47 since: 'с начала сессии',
48 empty: 'За эту сессию файлы не менялись.',
49 noGit: 'Не git-репозиторий: показываю только файлы, которые Claude правил через Edit и Write.',
50 refresh: 'Обновить',
51 open: 'открыть',
52 binary: 'двоичный',
53 outside: 'вне git',
54 cut: (n: number) => `…показаны первые ${n} строк диффа`,
55 noDiff: 'Диффа нет: файл удалён, двоичный или вне git.',
56 before: (n: number) => `Изменено ещё до начала сессии: ${n}`,
57 openFailed: (path: string) => `changes-pane: не смог открыть ${path}`,
58 },
59 en: {
60 command: 'Files changed in this session, with their diff',
61 title: 'Session changes',
62 opened: 'Opened the changes pane.',
63 files: (n: number) => `${n} file${n === 1 ? '' : 's'}`,
64 since: 'since the session started',
65 empty: 'No files have changed in this session.',
66 noGit: 'Not a git repository: only the files Claude changed with Edit and Write are listed.',
67 refresh: 'Refresh',
68 open: 'open',
69 binary: 'binary',
70 outside: 'outside git',
71 cut: (n: number) => `…the first ${n} lines of the diff`,
72 noDiff: 'No diff: the file is deleted, binary or outside git.',
73 before: (n: number) => `Changed before the session started: ${n}`,
74 openFailed: (path: string) => `changes-pane: could not open ${path}`,
75 },
76}
77
78let lang: Lang = 'en'
79// The repository root with forward slashes, '' outside git; the commit the session started on.
80let root = ''
81let base = EMPTY_TREE
82// Files changed before the session started: left out unless Claude touched them.
83let dirtyAtStart = new Set<string>()
84// Files Claude changed through Edit, Write or NotebookEdit, as absolute paths with forward slashes.
85const touched = new Set<string>()
86let reading: Promise<void> | undefined
87let again = false
88
89const slash = (p: string) => p.replace(/\\/g, '/')
90const key = (p: string) => slash(p).toLowerCase()
91
92
93// Relative to the repository root when inside it, else the absolute path.
94const relative = (abs: string) => {
95 const a = slash(abs)
96
97 return root !== '' && key(a).startsWith(`${key(root)}/`) ? a.slice(root.length + 1) : a
98}
99
100async function git($: EngineInterface, args: string[]): Promise<string | undefined> {
101 try {
102 const ran = await $.process.run(['git', '-c', 'core.quotepath=false', ...args], { cwd: root || undefined, timeoutMs: 20_000 })
103
104 return ran.exitCode === 0 ? ran.stdout : undefined
105 } catch {
106 return undefined
107 }
108}
109
110const zsplit = (out: string | undefined) => (out ?? '').split('\0').filter(s => s !== '')
111
112// What the session started from: kept in the session's state, so a reload of the mod keeps the
113// session's own start instead of taking the files changed so far for older ones.
114async function start($: EngineInterface) {
115 const sessionId = await $.session.id()
116 const kept = await read($, baseline)
117 if (kept.sessionId === sessionId) {
118 root = kept.root
119 base = kept.base
120 dirtyAtStart = new Set(kept.dirty)
121 touched.clear()
122 for (const t of kept.touched) touched.add(t)
123
124 return
125 }
126
127 touched.clear()
128 root = ''
129 base = EMPTY_TREE
130 dirtyAtStart = new Set()
131 const top = await git($, ['rev-parse', '--show-toplevel'])
132 if (top !== undefined) {
133 root = slash(top.trim())
134 base = (await git($, ['rev-parse', 'HEAD']))?.trim() || EMPTY_TREE
135 // `XY path` entries; a rename carries its old path as the next entry.
136 const entries = zsplit(await git($, ['status', '--porcelain', '-z', '--untracked-files=all']))
137 for (let i = 0; i < entries.length; i++) {
138 const entry = entries[i] ?? ''
139 dirtyAtStart.add(key(entry.slice(3)))
140 if (entry[0] === 'R' || entry[0] === 'C') i++
141 }
142 }
143 await update($, baseline, () => ({ sessionId, root, base, dirty: [...dirtyAtStart], touched: [] }))
144}
145
146async function touch($: EngineInterface, path: string) {
147 const k = key(path)
148 if (touched.has(k)) return
149 touched.add(k)
150 await update($, baseline, b => ({ ...b, touched: [...touched] }))
151}
152
153async function countLines($: EngineInterface, abs: string): Promise<number | null> {
154 try {
155 const text = await $.fs.read(abs)
156 if (text.includes('\0')) return null
157
158 return text === '' ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0)
159 } catch {
160 return null
161 }
162}
163
164// The changed files now: git's view since the session's commit, plus what Claude touched outside git.
165// A file dirty before the session and not touched by Claude since is marked as changed before.
166async function readChanges($: EngineInterface) {
167 const list: Changed[] = []
168 const seen = new Set<string>()
169 const before = (rel: string) => dirtyAtStart.has(key(rel)) && !touched.has(key(`${root}/${rel}`))
170
171 if (root !== '') {
172 const counts = new Map<string, [number | null, number | null]>()
173 const numstat = zsplit(await git($, ['diff', '--numstat', '-z', '-M', base]))
174 for (let i = 0; i < numstat.length; i++) {
175 const [added, removed, path] = (numstat[i] ?? '').split('\t')
176 const n = (s: string | undefined) => (s === undefined || s === '-' ? null : Number(s))
177 if (path === '') {
178 // A rename: the old path, then the new one, follow as entries of their own.
179 counts.set(numstat[i + 2] ?? '', [n(added), n(removed)])
180 i += 2
181 } else if (path !== undefined) {
182 counts.set(path, [n(added), n(removed)])
183 }
184 }
185 const status = zsplit(await git($, ['diff', '--name-status', '-z', '-M', base]))
186 for (let i = 0; i < status.length; i++) {
187 const letter = (status[i] ?? '').charAt(0)
188 const path = letter === 'R' || letter === 'C' ? status[(i += 2)] : status[++i]
189 if (path === undefined) continue
190 const [added, removed] = counts.get(path) ?? [null, null]
191 list.push({ path, abs: `${root}/${path}`, status: letter, added, removed, isBefore: before(path) })
192 seen.add(key(path))
193 }
194 for (const path of zsplit(await git($, ['ls-files', '--others', '--exclude-standard', '-z']))) {
195 if (seen.has(key(path))) continue
196 const added = await countLines($, `${root}/${path}`)
197 list.push({ path, abs: `${root}/${path}`, status: '?', added, removed: 0, isBefore: before(path) })
198 seen.add(key(path))
199 }
200 }
201 for (const abs of touched) {
202 const path = relative(abs)
203 if (seen.has(key(path))) continue
204 // Inside the repository and not listed by git: back as it was, or ignored.
205 if (root !== '' && path !== slash(abs)) continue
206 list.push({ path, abs, status: '~', added: null, removed: null, isBefore: false })
207 }
208
209 list.sort((a, b) => a.path.localeCompare(b.path))
210 await update($, files, () => list)
211 const open = await read($, selected)
212 if (open !== '' && !list.some(f => f.path === open)) await choose($, '')
213}
214
215// One read at a time; a call while one runs asks for one more after it.
216async function refresh($: EngineInterface) {
217 if (reading !== undefined) {
218 again = true
219
220 return reading
221 }
222 reading = (async () => {
223 do {
224 again = false
225 try {
226 await readChanges($)
227 } catch {
228 // Keep the last list.
229 }
230 } while (again)
231 })()
232 await reading
233 reading = undefined
234}
235
236// Whole hunks up to DIFF_LINES lines; a first hunk longer than that is cut at the limit.
237function clip(text: string): { text: string; isCut: boolean } {
238 const lines = text.split('\n')
239 if (lines.length <= DIFF_LINES) return { text, isCut: false }
240 let end = 0
241 for (let i = 1; i < DIFF_LINES; i++) if (lines[i]?.startsWith('@@')) end = i
242
243 return { text: lines.slice(0, end > 0 ? end : DIFF_LINES).join('\n'), isCut: true }
244}
245
246async function diffOf($: EngineInterface, f: Changed): Promise<string> {
247 if (f.status === '?') {
248 try {
249 const text = await $.fs.read(f.abs)
250 if (text.includes('\0')) return ''
251 const lines = text.replace(/\n$/, '').split('\n')
252
253 return `@@ -0,0 +1,${lines.length} @@\n${lines.map(l => `+${l}`).join('\n')}`
254 } catch {
255 return ''
256 }
257 }
258 if (root === '' || f.status === '~') return ''
259 const out = await git($, ['diff', '-M', base, '--', f.path])
260
261 return (out ?? '').split('\n').filter(l => !/^(diff --git|index |--- |\+\+\+ |new file|deleted file|similarity|rename )/.test(l)).join('\n')
262}
263
264async function choose($: EngineInterface, path: string) {
265 const open = await read($, selected)
266 const next = open === path ? '' : path
267 await update($, selected, () => next)
268 if (next === '') {
269 await update($, diff, () => '')
270
271 return
272 }
273 const f = (await read($, files)).find(one => one.path === next)
274 const text = f === undefined ? '' : await diffOf($, f)
275 await update($, diff, () => text)
276}
277
278// In VS Code when its `code` command is on PATH, else in the file's default app.
279async function openFile($: EngineInterface, abs: string) {
280 const path = abs.replace(/\//g, '\\')
281 try {
282 const ran = await $.process.run(['cmd.exe', '/d', '/c', 'code', '-g', path], { timeoutMs: 20_000 })
283 if (ran.exitCode === 0) return
284 } catch {
285 // No cmd.exe: not Windows.
286 }
287 try {
288 await $.process.run(['explorer.exe', path], { timeoutMs: 20_000 })
289 } catch {
290 $.ui.toast(STRINGS[lang].openFailed(path))
291 }
292}
293
294const short = (s: string, n: number) => (s.length > n ? `…${s.slice(s.length - n + 1)}` : s)
295
296export const register: Register = (on, options) => {
297 on('session.start', async ($, e, next) => {
298 const started = await next(e)
299 lang = await resolveLang($, (options as { language?: unknown }).language)
300 await $.command.register({ name: 'changes', description: STRINGS[lang].command })
301 await start($)
302 await update($, selected, () => '')
303 await update($, diff, () => '')
304 void refresh($)
305
306 return started
307 })
308
309 on('command.run', { command: 'changes' }, async $ => {
310 await refresh($)
311 await $.ui.open({ id: PANE, title: STRINGS[lang].title, focus: true, closeOnEscape: true })
312
313 return { text: STRINGS[lang].opened }
314 })
315
316 // Remember what Claude itself changed: git sees the rest.
317 on('tool.call', async ($, e, next) => {
318 const ran = await next(e)
319 if (ran.deny !== undefined || ran.isError === true) return ran
320 if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit') {
321 const input = e as { file_path?: unknown; notebook_path?: unknown }
322 const path = String(input.file_path ?? input.notebook_path ?? '')
323 if (path !== '') await touch($, path)
324 }
325
326 return ran
327 })
328
329 // A turn's end is when the files settle: read them again.
330 on('turn.complete', async ($, e, next) => {
331 const done = await next(e)
332 if (e.agentId === undefined) void refresh($)
333
334 return done
335 })
336
337 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
338 const { Box, Button, Code, Text } = $.ui.resolve(e)
339 const L = STRINGS[lang]
340 const all = await read($, files)
341 const mine = all.filter(f => !f.isBefore)
342 const older = all.filter(f => f.isBefore)
343 const open = await read($, selected)
344 const shown = clip(await read($, diff))
345 const width = Math.max(20, e.props.bodyColumns - 24)
346 const added = mine.reduce((sum, f) => sum + (f.added ?? 0), 0)
347 const removed = mine.reduce((sum, f) => sum + (f.removed ?? 0), 0)
348
349 const row = (f: Changed) => (
350 <Box flexDirection="column">
351 <Box flexDirection="row" gap={1}>
352 <Text dimColor={f.isBefore} color={f.status === 'D' ? 'red' : f.status === 'A' || f.status === '?' ? 'green' : 'yellow'}>
353 {f.status}
354 </Text>
355 <Button key={`file:${f.path}`} plain dimColor={open !== f.path} label={short(f.path, width)} onPress={() => choose($, f.path)} />
356 {f.status === '~' ? (
357 <Text dimColor>{L.outside}</Text>
358 ) : f.added === null ? (
359 <Text dimColor>{L.binary}</Text>
360 ) : (
361 <Text dimColor={f.isBefore}>
362 <Text color="green">+{f.added}</Text> <Text color="red">−{f.removed ?? 0}</Text>
363 </Text>
364 )}
365 {f.status !== 'D' && <Button key={`open:${f.path}`} plain dimColor label={L.open} onPress={() => openFile($, f.abs)} />}
366 </Box>
367 {open === f.path &&
368 (shown.text === '' ? (
369 <Text dimColor>{L.noDiff}</Text>
370 ) : (
371 <Box flexDirection="column">
372 <Code source={shown.text} format="diff" path={f.path} />
373 {shown.isCut && <Text dimColor>{L.cut(DIFF_LINES)}</Text>}
374 </Box>
375 ))}
376 </Box>
377 )
378
379 return (
380 <Box flexDirection="column">
381 <Box flexDirection="row" gap={1}>
382 <Text>
383 <Text bold>{L.files(mine.length)}</Text> <Text color="green">+{added}</Text> <Text color="red">−{removed}</Text>{' '}
384 <Text dimColor>{L.since}</Text>
385 </Text>
386 <Button key="refresh" plain hotkey="r" label={L.refresh} onPress={() => refresh($)} />
387 </Box>
388 {root === '' && <Text dimColor>{L.noGit}</Text>}
389 {mine.length === 0 && <Text dimColor>{L.empty}</Text>}
390 {mine.map(row)}
391 {older.length > 0 && <Text dimColor>{L.before(older.length)}</Text>}
392 {older.map(row)}
393 </Box>
394 )
395 })
396}
397types/index.d.ts 37 lines1export type Changed = {
2 // Relative to the repository root (or absolute outside it), forward slashes.
3 path: string
4 abs: string
5 // git's letter: M, A, D, R, or ? for a new file git does not track yet, ~ for one outside git.
6 status: string
7 added: number | null
8 removed: number | null
9 // Already changed when the session started, and not touched by Claude since.
10 isBefore: boolean
11}
12
13// What the session started from, kept across reloads of the mod.
14export type Baseline = {
15 sessionId: string
16 // The repository root with forward slashes, '' outside git.
17 root: string
18 // The commit the session started on (git's empty tree in a repository with no commit).
19 base: string
20 // Files with uncommitted changes at the start, lower-cased paths relative to the root.
21 dirty: string[]
22 // Files Claude changed through Edit, Write or NotebookEdit, lower-cased absolute paths with forward slashes.
23 touched: string[]
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'changes-pane': {
29 files: Shaped<Changed[]>
30 baseline: Shaped<Baseline>
31 // The file whose diff is unfolded, '' for none.
32 selected: string
33 diff: string
34 }
35 }
36}
37