Pane that shows what Claude added, changed or deleted since the last Clear

Claude Code에서 이번 요청으로 바뀐 부분만 보여주는 패널(Mod)입니다.
git diff는 마지막 커밋이 기준이라, 커밋 없이 요청을 여러 번 이어가면 A → A' → A''의 변경이 한데 섞여 "방금 요청에서 뭐가 바뀌었는지" 알기 어렵습니다. change-monitor는 Clear를 누른 시점을 기준으로 Claude가 추가·수정·삭제한 파일과 diff를 보여줍니다. Clear → 요청 → 확인을 반복하면 요청 단위로 변경을 볼 수 있습니다.
/plugin install change-monitor --marketplace ByeongbumSeo/claude-change-monitor
/changes: 패널 열기 / /changes clear 또는 패널의 Clear: 지금 상태를 새 기준으로/changesclaude plugin disable change-monitorA Claude Code pane (mod) that shows only what the current request changed.
Why: git diff compares against the last commit, so across several uncommitted requests (A → A' → A'') the changes pile up and you can't tell what the latest request did. change-monitor uses the moment you press Clear as its baseline and shows the files Claude added, modified or deleted since then, with diffs.
Install: /plugin install change-monitor --marketplace ByeongbumSeo/claude-change-monitor
Use: /changes opens the pane; /changes clear or the Clear button makes the current state the new baseline. Off stops tracking in this session only; /changes turns it back on. To turn it off in every session: claude plugin disable change-monitor. Tracks Claude's Edit, Write and Bash calls; edits made outside Claude are kept out of the diff.
hooks/register.tsx 334 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Entry } from '../types'
5import { merge3, unifiedDiff } from './diff'
6
7const PANE = 'change-monitor'
8const TITLE = 'Changes'
9const MAX_BYTES = 1_000_000
10// Caps on how many dirty files one Bash call inspects.
11const MAX_PRE_READ = 100
12const MAX_NEW = 200
13
14const entries = atom({ plugin: 'change-monitor', key: 'entries' } as const, {})
15const collapsed = atom({ plugin: 'change-monitor', key: 'collapsed' } as const, {})
16const root = atom({ plugin: 'change-monitor', key: 'root' } as const, '')
17const status = atom({ plugin: 'change-monitor', key: 'status' } as const, 'on')
18
19type $ = EngineInterface
20
21const NOTICE_MS = 5000
22
23let isGit = false
24let offTimer: ReturnType<$['clock']['after']> | undefined
25// Pre-Bash reads, reused while a file's mtime and size stay the same.
26const readCache = new Map<string, { mtimeMs: number; size: number; text: string }>()
27
28/** The file's text, null when absent, undefined when it cannot be tracked (dir, too large, unreadable). */
29async function snapshot($: $, path: string): Promise<string | null | undefined> {
30 const stat = await $.fs.stat(path).catch(() => undefined)
31 if (stat === undefined) return null
32 if (stat.kind !== 'file' || stat.size > MAX_BYTES) return undefined
33 const hit = readCache.get(path)
34 if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) return hit.text
35 const text = await $.fs.read(path).catch(() => undefined)
36 if (text !== undefined) readCache.set(path, { mtimeMs: stat.mtimeMs, size: stat.size, text })
37 return text
38}
39
40/** Starts tracking `path` with `baseline` unless it is tracked already. */
41const track = ($: $, path: string, baseline: string | null, current: string | null) =>
42 update($, entries, map => (path in map ? map : { ...map, [path]: { baseline, current } }))
43
44async function refresh($: $, path: string) {
45 const now = await snapshot($, path)
46 if (now === undefined) return
47 await update($, entries, map => {
48 const entry = map[path]
49 return entry === undefined || entry.current === now ? map : { ...map, [path]: { ...entry, current: now } }
50 })
51}
52
53/**
54 * Folds an edit made outside Claude since its last tool call into the baseline,
55 * so the diff keeps showing Claude's changes alone; marks the entry mixed when
56 * the two cannot be separated.
57 */
58async function absorbOutside($: $, path: string) {
59 const now = await snapshot($, path)
60 if (now === undefined) return
61 await update($, entries, map => {
62 const entry = map[path]
63 if (entry === undefined || entry.current === now) return map
64 const { baseline, current } = entry
65 const merged =
66 baseline !== null && current !== null && now !== null ? merge3(current, baseline, now) : undefined
67 const next: Entry =
68 merged === undefined
69 ? { baseline, current: now, isMixed: true }
70 : { baseline: merged, current: now, isMixed: entry.isMixed }
71 return { ...map, [path]: next }
72 })
73}
74
75async function absolute($: $, path: string) {
76 if (path.startsWith('/')) return path
77 return `${(await $.session.cwd()).replace(/\/$/, '')}/${path}`
78}
79
80/** Absolute paths `git status` lists as changed or untracked. */
81async function dirtyPaths($: $, top: string): Promise<string[]> {
82 const run = await $.process.run(
83 ['git', '-c', 'core.quotepath=off', 'status', '--porcelain=v1', '-z', '-uall'],
84 { cwd: top, timeoutMs: 5000 },
85 )
86 if (run.exitCode !== 0) return []
87 const parts = run.stdout.split('\0')
88 const paths: string[] = []
89 for (let k = 0; k < parts.length; k++) {
90 const part = parts[k]!
91 if (part.length < 4) continue
92 paths.push(`${top}/${part.slice(3)}`)
93 // A rename or copy is followed by its original path.
94 if (part[0] === 'R' || part[0] === 'C') k++
95 }
96 return paths
97}
98
99async function headContent($: $, top: string, path: string): Promise<string | null> {
100 const rel = path.slice(top.length + 1)
101 const run = await $.process.run(['git', 'show', `HEAD:${rel}`], { cwd: top, timeoutMs: 5000 })
102 return run.exitCode === 0 && !run.isStdoutTruncated ? run.stdout : null
103}
104
105async function beforeBash($: $, top: string) {
106 const tracked = await read($, entries)
107 for (const path of Object.keys(tracked)) await absorbOutside($, path)
108 const before = new Map<string, string | null>()
109 const skipped = new Set<string>()
110 for (const path of await dirtyPaths($, top)) {
111 if (path in tracked) continue
112 const text = before.size < MAX_PRE_READ ? await snapshot($, path) : undefined
113 if (text === undefined) skipped.add(path)
114 else before.set(path, text)
115 }
116 return { before, skipped }
117}
118
119async function afterBash($: $, top: string, pre: { before: Map<string, string | null>; skipped: Set<string> }) {
120 const tracked = await read($, entries)
121 for (const path of Object.keys(tracked)) await refresh($, path)
122
123 let fresh = 0
124 const candidates = new Set([...pre.before.keys(), ...(await dirtyPaths($, top))])
125 for (const path of candidates) {
126 if (path in tracked || pre.skipped.has(path)) continue
127 const isNew = !pre.before.has(path)
128 if (isNew && ++fresh > MAX_NEW) continue
129 const now = await snapshot($, path)
130 if (now === undefined) continue
131 const baseline = isNew ? await headContent($, top, path) : pre.before.get(path)!
132 if (baseline !== now) await track($, path, baseline, now)
133 }
134}
135
136function pathOf(e: object): string | undefined {
137 const input = e as Record<string, unknown>
138 const path = input.file_path ?? input.notebook_path
139 return typeof path === 'string' && path !== '' ? path : undefined
140}
141
142const kindOf = (entry: Entry): 'A' | 'M' | 'D' | undefined =>
143 entry.baseline === entry.current ? undefined
144 : entry.baseline === null ? 'A'
145 : entry.current === null ? 'D'
146 : 'M'
147
148const COLOR = { A: 'success', M: 'warning', D: 'error' } as const
149
150const clear = ($: $) =>
151 Promise.all([update($, entries, () => ({})), update($, collapsed, () => ({}))])
152
153const OFF_STATUS = 'change-monitor off · /changes to turn on'
154
155/** Stops tracking in this session and shows the off notice, which closes after NOTICE_MS. */
156async function turnOff($: $) {
157 if ((await read($, status)) === 'off') return
158 await Promise.all([update($, status, () => 'off'), clear($)])
159 $.ui.status(OFF_STATUS)
160 offTimer?.cancel()
161 offTimer = $.clock.after(NOTICE_MS, () => void closeNotice($))
162}
163
164async function closeNotice($: $) {
165 offTimer?.cancel()
166 offTimer = undefined
167 if ((await read($, status)) === 'off') await $.ui.close({ id: PANE })
168}
169
170/** Opens the pane; says why when the surface does not draw it now. */
171async function openPane($: $) {
172 const opened = await $.ui.open({ id: PANE, title: TITLE })
173 return opened.isPlaced ? 'Changes pane opened.' : `Changes pane is open but not drawn: ${opened.reason}`
174}
175
176/** Resumes tracking in this session; the current state is the baseline. */
177async function turnOn($: $) {
178 offTimer?.cancel()
179 offTimer = undefined
180 await Promise.all([update($, status, () => 'on'), clear($)])
181 $.ui.status(undefined)
182 return openPane($)
183}
184
185export const register: Register = on => {
186 on('session.start', async ($, e, next) => {
187 const cwd = await $.session.cwd()
188 const top = await $.process
189 .run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 5000 })
190 .catch(() => undefined)
191 isGit = top?.exitCode === 0
192 await update($, root, () => (isGit ? top!.stdout.trim() : cwd))
193
194 await $.command.register({
195 name: 'changes',
196 description: 'Open the Changes pane; turns tracking back on when off (/changes clear | on | off)',
197 argumentHint: '[clear|on|off]',
198 })
199
200 // On/off is this session's own state, kept across a reload of the mod.
201 if ((await read($, status)) === 'off') $.ui.status(OFF_STATUS)
202 else void $.ui.open({ id: PANE, title: TITLE })
203
204 return next(e)
205 })
206
207 on('command.run', { command: 'changes' }, async ($, e) => {
208 const arg = e.args.trim()
209 if (arg === 'off') {
210 await turnOff($)
211 await $.ui.open({ id: PANE, title: TITLE })
212 return { text: 'Change tracking turned off for this session. Turn it back on with /changes' }
213 }
214 if (arg === 'on' || (await read($, status)) === 'off') {
215 const pane = await turnOn($)
216 return { text: `Change tracking turned on: the current state is the baseline. ${pane}` }
217 }
218 if (arg === 'clear') {
219 await clear($)
220 return { text: 'Changes cleared: the current state is the new baseline.' }
221 }
222 return { text: await openPane($) }
223 })
224
225 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
226 if ((await read($, status)) !== 'on') return next(e)
227 const raw = pathOf(e)
228 let path: string | undefined
229 try {
230 if (raw !== undefined) {
231 path = await absolute($, raw)
232 if (path in (await read($, entries))) await absorbOutside($, path)
233 else {
234 const before = await snapshot($, path)
235 if (before !== undefined) await track($, path, before, before)
236 }
237 }
238 } catch {
239 path = undefined
240 }
241
242 const ran = await next(e)
243 if (path !== undefined) await refresh($, path).catch(() => {})
244 return ran
245 })
246
247 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
248 if ((await read($, status)) !== 'on') return next(e)
249 const top = await read($, root)
250 const pre = isGit ? await beforeBash($, top).catch(() => undefined) : undefined
251
252 const ran = await next(e)
253 if (pre !== undefined) await afterBash($, top, pre).catch(() => {})
254 return ran
255 })
256
257 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
258 const { Box, Text, Button, Code } = $.ui.resolve(e)
259 const [map, folded, top, state] = await Promise.all([
260 read($, entries),
261 read($, collapsed),
262 read($, root),
263 read($, status),
264 ])
265
266 if (state === 'off')
267 return (
268 <Box flexDirection="column" gap={1}>
269 <Text weight="bold">change-monitor is off for this session.</Text>
270 <Text>
271 To turn it back on: <Text color="suggestion">/changes</Text>
272 </Text>
273 <Text dimColor>Closes in {NOTICE_MS / 1000} seconds.</Text>
274 <Button key="close-notice" hotkey="x" onPress={() => void closeNotice($)}>
275 Close
276 </Button>
277 </Box>
278 )
279
280 const files = Object.entries(map)
281 .flatMap(([path, entry]) => {
282 const kind = kindOf(entry)
283 if (kind === undefined) return []
284 const rel = path.startsWith(`${top}/`) ? path.slice(top.length + 1) : path
285 return [{ path, rel, kind, isMixed: entry.isMixed === true, diff: unifiedDiff(entry.baseline, entry.current) }]
286 })
287 .sort((a, b) => a.rel.localeCompare(b.rel))
288
289 const added = files.reduce((sum, f) => sum + f.diff.added, 0)
290 const removed = files.reduce((sum, f) => sum + f.diff.removed, 0)
291
292 return (
293 <Box flexDirection="column" gap={1}>
294 <Box flexDirection="row" justifyContent="space-between">
295 <Text weight="bold">
296 {files.length} files <Text color="diffAdded">+{added}</Text>{' '}
297 <Text color="diffRemoved">-{removed}</Text>
298 </Text>
299 <Box flexDirection="row" gap={1}>
300 <Button key="clear" hotkey="c" onPress={() => void clear($)}>
301 Clear
302 </Button>
303 <Button key="off" hotkey="o" onPress={() => void turnOff($)}>
304 Off
305 </Button>
306 </Box>
307 </Box>
308 {files.length === 0 && <Text dimColor>No changes since the last Clear.</Text>}
309 {files.map(f => (
310 <Box key={`file:${f.path}`} flexDirection="column">
311 <Button
312 key={`toggle:${f.path}`}
313 plain
314 onPress={() => void update($, collapsed, c => ({ ...c, [f.path]: !c[f.path] }))}
315 >
316 <Text color={COLOR[f.kind]} weight="bold">
317 {f.kind}
318 </Text>{' '}
319 {folded[f.path] ? '▸' : '▾'} {f.rel}{' '}
320 <Text dimColor>
321 +{f.diff.added} -{f.diff.removed}
322 </Text>
323 {f.isMixed && <Text color="warning"> ⚠ mixed with outside edits</Text>}
324 </Button>
325 {!folded[f.path] && f.diff.text !== '' && (
326 <Code source={f.diff.text} format="diff" path={f.rel} />
327 )}
328 </Box>
329 ))}
330 </Box>
331 )
332 })
333}
334hooks/diff.ts 161 lines1export type Diff = { text: string; added: number; removed: number }
2
3type Op = { t: ' ' | '-' | '+'; line: string }
4
5// LCS beyond this many cells falls back to "all removed, then all added".
6const MAX_CELLS = 1_000_000
7
8const linesOf = (text: string | null): string[] =>
9 text === null || text === '' ? [] : text.replace(/\n$/, '').split('\n')
10
11function ops(a: string[], b: string[]): Op[] {
12 let head = 0
13 while (head < a.length && head < b.length && a[head] === b[head]) head++
14 let tail = 0
15 while (
16 tail < a.length - head &&
17 tail < b.length - head &&
18 a[a.length - 1 - tail] === b[b.length - 1 - tail]
19 )
20 tail++
21
22 const A = a.slice(head, a.length - tail)
23 const B = b.slice(head, b.length - tail)
24 const mid: Op[] = []
25
26 if (A.length * B.length <= MAX_CELLS) {
27 const n = A.length
28 const m = B.length
29 const w = m + 1
30 const lcs = new Uint32Array((n + 1) * w)
31 for (let i = n - 1; i >= 0; i--)
32 for (let j = m - 1; j >= 0; j--)
33 lcs[i * w + j] =
34 A[i] === B[j]
35 ? lcs[(i + 1) * w + j + 1]! + 1
36 : Math.max(lcs[(i + 1) * w + j]!, lcs[i * w + j + 1]!)
37 let i = 0
38 let j = 0
39 while (i < n && j < m) {
40 if (A[i] === B[j]) {
41 mid.push({ t: ' ', line: A[i]! })
42 i++
43 j++
44 } else if (lcs[(i + 1) * w + j]! >= lcs[i * w + j + 1]!) mid.push({ t: '-', line: A[i++]! })
45 else mid.push({ t: '+', line: B[j++]! })
46 }
47 while (i < n) mid.push({ t: '-', line: A[i++]! })
48 while (j < m) mid.push({ t: '+', line: B[j++]! })
49 } else {
50 for (const line of A) mid.push({ t: '-', line })
51 for (const line of B) mid.push({ t: '+', line })
52 }
53
54 const same = (line: string): Op => ({ t: ' ', line })
55 return [...a.slice(0, head).map(same), ...mid, ...a.slice(a.length - tail).map(same)]
56}
57
58/** A replaced run of the base's lines: [start, end) becomes `lines`. */
59type Chunk = { start: number; end: number; lines: string[] }
60
61function chunks(base: string[], other: string[]): Chunk[] {
62 const out: Chunk[] = []
63 let at = 0
64 let open: Chunk | undefined
65 for (const op of ops(base, other)) {
66 if (op.t === ' ') {
67 if (open) out.push(open)
68 open = undefined
69 at++
70 continue
71 }
72 open ??= { start: at, end: at, lines: [] }
73 if (op.t === '-') open.end = ++at
74 else open.lines.push(op.line)
75 }
76 if (open) out.push(open)
77 return out
78}
79
80const sameChunk = (a: Chunk, b: Chunk) =>
81 a.start === b.start && a.end === b.end && a.lines.join('\n') === b.lines.join('\n')
82
83/**
84 * Three-way merge of `ours` and `theirs` from their common `base`, or undefined
85 * on a conflict (both sides touching the same or adjacent lines differently).
86 */
87export function merge3(base: string, ours: string, theirs: string): string | undefined {
88 const lines = linesOf(base)
89 const all = [
90 ...chunks(lines, linesOf(ours)),
91 ...chunks(lines, linesOf(theirs)),
92 ].sort((a, b) => a.start - b.start || a.end - b.end)
93
94 const picked: Chunk[] = []
95 for (const chunk of all) {
96 const last = picked[picked.length - 1]
97 if (last && chunk.start <= last.end) {
98 if (sameChunk(last, chunk)) continue
99 return undefined
100 }
101 picked.push(chunk)
102 }
103
104 const out: string[] = []
105 let at = 0
106 for (const chunk of picked) {
107 out.push(...lines.slice(at, chunk.start), ...chunk.lines)
108 at = chunk.end
109 }
110 out.push(...lines.slice(at))
111 return out.length ? out.join('\n') + '\n' : ''
112}
113
114/** Unified diff hunks (no file header) between two texts; null means the file is absent. */
115export function unifiedDiff(before: string | null, after: string | null, context = 3): Diff {
116 const list = ops(linesOf(before), linesOf(after))
117
118 // Lines of a and b consumed before each op.
119 const aBefore: number[] = []
120 const bBefore: number[] = []
121 let ai = 0
122 let bi = 0
123 for (const op of list) {
124 aBefore.push(ai)
125 bBefore.push(bi)
126 if (op.t !== '+') ai++
127 if (op.t !== '-') bi++
128 }
129
130 const changed = list.flatMap((op, k) => (op.t === ' ' ? [] : [k]))
131 const out: string[] = []
132 let added = 0
133 let removed = 0
134
135 let g = 0
136 while (g < changed.length) {
137 let last = g
138 while (last + 1 < changed.length && changed[last + 1]! - changed[last]! <= 2 * context) last++
139 const start = Math.max(0, changed[g]! - context)
140 const end = Math.min(list.length, changed[last]! + context + 1)
141
142 const body: string[] = []
143 let aCount = 0
144 let bCount = 0
145 for (let k = start; k < end; k++) {
146 const op = list[k]!
147 body.push(op.t + op.line)
148 if (op.t !== '+') aCount++
149 if (op.t !== '-') bCount++
150 if (op.t === '+') added++
151 if (op.t === '-') removed++
152 }
153 const aStart = aCount ? aBefore[start]! + 1 : aBefore[start]!
154 const bStart = bCount ? bBefore[start]! + 1 : bBefore[start]!
155 out.push(`@@ -${aStart},${aCount} +${bStart},${bCount} @@`, ...body)
156 g = last + 1
157 }
158
159 return { text: out.join('\n'), added, removed }
160}
161types/index.d.ts 18 lines1/**
2 * A tracked file: its content at the baseline (Clear) and as Claude's last tool
3 * call left it; null = absent. `isMixed` when an outside edit could not be
4 * separated from Claude's.
5 */
6export type Entry = { baseline: string | null; current: string | null; isMixed?: boolean }
7
8declare module 'claude-code' {
9 interface PluginState {
10 'change-monitor': {
11 entries: Record<string, Entry>
12 collapsed: Record<string, boolean>
13 root: string
14 status: 'on' | 'off'
15 }
16 }
17}
18