SLOPSHOPPER

changes-pane

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

newpaneguardcommandtoastprocess
★ 1v1.0.0MITupdated 2026-10-26ArtyaEXE/claude-mods/changes-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · changes-pane
│ ┃ Session changes ✕ › fix the failing auth test and add an audit log call │ ┃ 3 files +0 −0 since the session started r: R │ ┃ Not a git repository: only the files Claude ⏺ Read(src/auth.ts) │ ┃ changed with Edit and Write are listed. ⎿ Read 6 lines │ ┃ ~ /work/app/src/audit.ts outside git open ⏺ Update(src/auth.ts) │ ┃ ~ /work/app/src/auth.ts outside git open ⎿ Added 2 lines, removed 1 line │ ┃ ~ /work/app/src/cache.ts outside git open ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /changes │ ⎿ changes-pane: Opened the changes pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session changes
3 files +0 −0 since the session started r: Refresh Not a git repository: only the files Claude changed with Edit and Write are listed. ~ /work/app/src/audit.ts outside git open ~ /work/app/src/auth.ts outside git open ~ /work/app/src/cache.ts outside git open
README

changes-pane

/changes: a pane of the files changed in this session with +/− counts; a click unfolds the diff.

English · Русский

English

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.

What it touches

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.

Settings (/config)

  • language: auto (default; follows LANG, else the Windows UI language or regional format), ru or en.

Install

/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.

Source 2 files
hooks/register.tsx 397 lines
1import { 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}
397
types/index.d.ts 37 lines
1export 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