See what changed, right where Claude points. Run /diff-lens:walkthrough (or ask "guide me through this PR") and click any file link in the reply: the file…

A mod for Claude Code Desktop. Click a file link in a reply and see that file's changes against your base branch, scrolled to the line.

When Claude walks you through a pull request, or through what it just changed, every step points at a file and a line. Clicking that link opens the plain file: you land on the right line but cannot see what changed there. The app's Diff view shows what changed, but a link cannot open it, and it cannot be put on a line.
So you read "this line now retries the scroll", click, and then hunt for the same spot in the diff by hand, for every step.
diff-lens joins the two. A link to a changed file opens a pane that shows the file with added and removed lines highlighted, unchanged stretches folded away, and the linked line marked and centred. The explanation and the change it describes sit side by side.
In a terminal session of Claude Code:
/plugin install diff-lens --marketplace mkiselyow/diff-lens
Answer y to add the marketplace and pick the user scope. The mod then loads in every session started afterwards, including the ones the desktop app starts.
To update later, from a shell:
claude plugin marketplace update diff-lens
claude plugin update diff-lens@diff-lens
New sessions use the new version.
/diff-lens:walkthrough (or just ask "guide me through this PR") makes Claude explain the change step by step, with a link to the exact changed line at every step. It compares against the repository's default branch; add a branch to compare against another one, for example /diff-lens:walkthrough part-of-feature-1.src/app.ts:47. Links to unchanged files are left to the app, as before./diff-lens opens the pane with the list of changed files./diff-lens src/app.ts:47 opens one file at a line./diff-lens base origin/release changes the branch to compare against./diff-lens stats prints how long git, drawing and opening took in this session, and how each scroll went./diff-lens appears in the menu once a session is running; /diff-lens:walkthrough is there from the start. Claude can also set the base itself: the mod gives it a small compare tool, which the walkthrough uses.
In the pane:
| Control | What it does |
|---|---|
▸ 12 files | Show or hide the list of changed files; hover a file for its full path |
‹ › | Previous or next changed file |
↑ ↓ | Previous or next change in this file |
origin/main → working tree | Choose another base branch |
▸ 34 unmodified lines | Unfold that stretch |
| A line number | Put path:line into your prompt |
The working tree (committed and uncommitted changes, plus new untracked files) against the merge-base of HEAD and the base branch. That is what a pull request's "Files changed" shows, plus whatever is not committed yet.
The base can be any branch, tag or commit. By default it is, in order: the baseBranch option, the remote's default branch, then the first of origin/main, origin/master, origin/develop, origin/release, main, master that exists. Change it with /diff-lens base <ref>, with /diff-lens:walkthrough <ref>, or from the pane (the pane lists the detected branches only).
To compare feature-123 with part-of-feature-1, check out feature-123 and set the base to part-of-feature-1. The compared side is always what you have checked out; two branches that are both not checked out cannot be compared. A remote branch has to be fetched first, and the base is chosen anew in each session.
Everything the mod does, so there are no surprises.
Programs it starts: git only, read-only. Each command is written out in the source at the place it is run, in the session's folder, with no shell:
git rev-parse (is this a repository, does a branch exist, what is the upstream)git symbolic-ref (the remote's default branch)git merge-base (where the branch split from the base)git diff --numstat and git diff (which files changed, and one file's changes)git ls-files --others (new untracked files)It never fetches, switches branches, commits or writes files. A base name that starts with - is refused, so it cannot be read as an option.
What it passes to git. Two kinds of value, and nothing else: the base you or Claude chose (a branch, tag or commit name), and file paths taken from git's own list of changed files. No text from the conversation, no file contents and no URLs are passed. None of the commands contacts a remote: the mod never runs fetch, pull, push, clone or ls-remote.
Network: none. The mod makes no network requests and sends nothing anywhere. It does not call any other tool or MCP server.
What it reads. The output of the git commands above, and the file links in Claude's replies. It does not read the contents of your prompts or of Claude's tool calls.
Claude's replies. On the desktop app, a reply that links to a changed file is drawn by the mod so that a click on that link reaches it. The wording is not changed; file links are rewritten to absolute file:// addresses. Links to unchanged files are left to the app.
Its own pane. It opens the Diff lens pane and scrolls it.
Your prompt box. Clicking a line number in the pane puts path:line into the prompt box. The mod never sends a prompt.
One tool for Claude: compare. The mod registers this tool and answers it itself. It sets or reports the base branch and nothing else.
Claude's tool calls. The mod watches Claude's Edit, Write and Bash calls for one reason: to re-read the diff afterwards. It passes every call through unchanged and never blocks or rewrites one.
Memory only. Timing numbers for /diff-lens stats are kept in memory and are gone when the session ends.
/diff-lens do nothing, mods are probably not enabled for your account yet. Tested with Claude Code 2.1.292 and 2.1.293 on macOS.claude plugin validate .
claude plugin test .
claude --plugin-dir .
hooks/diff.ts (diff parsing and folding) and hooks/links.ts (link rewriting) are pure and unit-tested; hooks/register.tsx holds the hooks and the pane.
hooks/register.tsx 606 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FileStat, LensView } from '../types'
5import { isBinaryDiff, layout, nearestLine, parse, parseNumstat } from './diff'
6import type { Layout, Row, Run } from './diff'
7import { fromHref, rewrite, splitLine, toRelative } from './links'
8
9const NAME = 'diff-lens'
10const PANE = 'diff-lens'
11const TITLE = 'Diff lens'
12const targetKey = (v: LensView): string => `target${v.aim}`
13const changeKey = (start: number, change: number): string => `c${start}x${change}`
14const COMPARE = 'mcp__diff-lens__compare'
15const CONTEXT = 3
16const ROW_LIMIT = 110
17const PANE_COLUMNS = 150
18const GIT_TIMEOUT = 20000
19const FALLBACK_BASES = ['origin/main', 'origin/master', 'origin/develop', 'origin/release', 'main', 'master']
20const EMPTY: LensView = {
21 base: '',
22 mergeBase: '',
23 bases: [],
24 stats: [],
25 file: null,
26 line: null,
27 isFlashing: false,
28 diff: '',
29 isTruncated: false,
30 status: 'idle',
31 message: '',
32 isListOpen: true,
33 isBasePickerOpen: false,
34 expanded: [],
35 change: -1,
36 shown: -1,
37 aim: 0,
38}
39const view = atom({ plugin: 'diff-lens', key: 'view' } as const, EMPTY)
40
41const samples: { name: string; ms: number }[] = []
42const memo: { key: string; rows: Row[]; drawn: Layout } = {
43 key: '',
44 rows: [],
45 drawn: { parts: [], changes: 0, changeAt: [], start: 0, before: 0, hidden: 0, hasTarget: false },
46}
47const settings = { base: '' }
48let isRefreshQueued = false
49let opens = 0
50
51const record = (name: string, ms: number) => {
52 samples.push({ name, ms })
53 if (samples.length > 400) samples.splice(0, samples.length - 400)
54}
55
56const summarize = (): string => {
57 const names = [...new Set(samples.map(sample => sample.name))]
58 if (names.length === 0) return 'No timings recorded yet.'
59
60 return names
61 .map(name => {
62 const list = samples.filter(sample => sample.name === name).map(sample => sample.ms).sort((a, b) => a - b)
63 const pick = (share: number) => list[Math.min(list.length - 1, Math.floor(list.length * share))] ?? 0
64
65 return `${name}: n=${list.length} median=${pick(0.5)}ms p95=${pick(0.95)}ms max=${list[list.length - 1] ?? 0}ms`
66 })
67 .join('\n')
68}
69
70const current = async ($: EngineInterface): Promise<LensView> => ({ ...EMPTY, ...(await read($, view)) })
71
72const patch = ($: EngineInterface, change: Partial<LensView>) => update($, view, v => ({ ...EMPTY, ...v, ...change }))
73
74const timed = async ($: EngineInterface, name: string, run: (cwd: string) => Promise<{ exitCode: number; stdout: string; stderr: string; isStdoutTruncated?: boolean }>) => {
75 const cwd = await $.session.cwd()
76 const startedAt = await $.clock.now()
77 try {
78 const ran = await run(cwd)
79 record(`git ${name}`, (await $.clock.now()) - startedAt)
80
81 return { code: ran.exitCode, out: ran.stdout, err: ran.stderr.trim(), isTruncated: ran.isStdoutTruncated === true }
82 } catch (error) {
83 return { code: -1, out: '', err: String(error), isTruncated: false }
84 }
85}
86
87const exists = async ($: EngineInterface, ref: string) =>
88 !ref.startsWith('-') &&
89 (await timed($, 'verify', cwd =>
90 $.process.run(['git', '--no-pager', '-c', 'core.quotepath=off', 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { cwd, timeoutMs: GIT_TIMEOUT }),
91 )).code === 0
92
93const findBases = async ($: EngineInterface, preferred: string): Promise<string[]> => {
94 const head = await timed($, 'origin-head', cwd =>
95 $.process.run(['git', '--no-pager', '-c', 'core.quotepath=off', 'symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { cwd, timeoutMs: GIT_TIMEOUT }),
96 )
97 const upstream = await timed($, 'upstream', cwd =>
98 $.process.run(['git', '--no-pager', '-c', 'core.quotepath=off', 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { cwd, timeoutMs: GIT_TIMEOUT }),
99 )
100 const wanted = [preferred, settings.base, head.code === 0 ? head.out.trim() : '', ...FALLBACK_BASES, upstream.code === 0 ? upstream.out.trim() : '']
101 const bases: string[] = []
102 for (const ref of wanted) {
103 if (ref !== '' && !bases.includes(ref) && (await exists($, ref))) bases.push(ref)
104 }
105
106 return bases
107}
108
109const refresh = async ($: EngineInterface, wanted?: string): Promise<LensView> => {
110 const before = await current($)
111 const inside = await timed($, 'inside', cwd =>
112 $.process.run(['git', '--no-pager', '-c', 'core.quotepath=off', 'rev-parse', '--is-inside-work-tree'], { cwd, timeoutMs: GIT_TIMEOUT }),
113 )
114 if (inside.code !== 0) {
115 await patch($, { status: 'error', message: 'This folder is not a git repository.', stats: [], bases: [] })
116
117 return current($)
118 }
119 const bases = await findBases($, wanted ?? before.base)
120 const base = wanted !== undefined && bases.includes(wanted) ? wanted : before.base !== '' && bases.includes(before.base) ? before.base : bases[0]
121 if (wanted !== undefined && !bases.includes(wanted)) {
122 await patch($, { bases, message: `Base "${wanted}" was not found; still comparing against ${before.base || 'nothing'}.` })
123
124 return current($)
125 }
126 if (base === undefined) {
127 await patch($, { status: 'error', message: 'No base branch found. Set one with /diff-lens base <ref>.', stats: [], bases })
128
129 return current($)
130 }
131 const merge = await timed($, 'merge-base', cwd =>
132 $.process.run(['git', '--no-pager', '-c', 'core.quotepath=off', 'merge-base', base, 'HEAD'], { cwd, timeoutMs: GIT_TIMEOUT }),
133 )
134 const mergeBase = merge.code === 0 ? merge.out.trim() : base
135 const tracked = await timed($, 'numstat', cwd =>
136 $.process.run(['git', '--no-pager', '-c', 'core.quotepath=off', 'diff', '--numstat', '-z', '--no-renames', mergeBase], { cwd, timeoutMs: GIT_TIMEOUT }),
137 )
138 const others = await timed($, 'untracked', cwd =>
139 $.process.run(['git', '--no-pager', '-c', 'core.quotepath=off', 'ls-files', '--others', '--exclude-standard', '-z'], { cwd, timeoutMs: GIT_TIMEOUT }),
140 )
141 const stats: FileStat[] = parseNumstat(tracked.out).map(stat => ({ ...stat, isUntracked: false }))
142 for (const path of others.out.split('\u0000').filter(Boolean).slice(0, 200)) {
143 stats.push({ path, added: 0, removed: 0, isBinary: false, isUntracked: true })
144 }
145 const message = merge.code === 0 ? '' : `No common ancestor with ${base}; comparing the two directly.`
146 await patch($, { base, mergeBase, bases, stats, message, status: before.status === 'error' ? 'idle' : before.status })
147
148 return current($)
149}
150
151const loadDiff = async ($: EngineInterface, v: LensView, path: string) => {
152 const stat = v.stats.find(one => one.path === path)
153 const ran = stat?.isUntracked
154 ? await timed($, 'diff untracked', cwd =>
155 $.process.run(['git', '--no-pager', '-c', 'core.quotepath=off', 'diff', '--no-index', '-U100000', '--', '/dev/null', path], { cwd, timeoutMs: GIT_TIMEOUT }),
156 )
157 : await timed($, 'diff', cwd =>
158 $.process.run(['git', '--no-pager', '-c', 'core.quotepath=off', 'diff', '--no-renames', '-U100000', v.mergeBase, '--', path], { cwd, timeoutMs: GIT_TIMEOUT }),
159 )
160 const isFailed = ran.code !== 0 && !(stat?.isUntracked && ran.code === 1)
161
162 return { ...ran, isFailed, stat }
163}
164
165const measure = (v: LensView): { rows: Row[]; drawn: Layout } => {
166 const key = `${v.diff.length}:${v.line}:${v.expanded.join(',')}:${v.file}:${v.mergeBase}:${v.shown}`
167 if (memo.key !== key || memo.rows.length === 0) {
168 memo.key = key
169 memo.rows = parse(v.diff)
170 memo.drawn = layout(memo.rows, v.line, v.expanded, CONTEXT, ROW_LIMIT, v.shown < 0 ? null : v.shown)
171 }
172
173 return memo
174}
175
176const MISSING = /no element|not drawn|drawn under/i
177
178const reveal = async ($: EngineInterface, key: string): Promise<string | null> => {
179 const toKey = async (): Promise<string | null> => {
180 const moved = await $.ui.scroll({ in: PANE, to: { key }, block: 'center' })
181
182 return 'deny' in moved && moved.deny ? String(moved.deny) : null
183 }
184 const settle = async (name: string) => {
185 record(`scroll: ${name}`, 0)
186
187 return null
188 }
189 const poll = async (waits: number[]): Promise<string | null> => {
190 let refusal = ''
191 for (const wait of waits) {
192 await $.clock.sleep(wait)
193 const denied = await toKey()
194 if (denied === null) return null
195 refusal = denied
196 }
197
198 return refusal
199 }
200 const first = await poll([100, 120])
201 if (first === null) return settle('direct')
202 if (!MISSING.test(first)) {
203 const again = await poll([250])
204
205 return settle(again === null ? 'direct' : `refused (${again.slice(0, 40)})`)
206 }
207 await $.ui.scroll({ in: PANE, to: { key: 'list' }, block: 'start' })
208 const second = await poll([80, 120])
209 if (second === null) return settle('top nudge')
210 await $.ui.scroll({ in: PANE, to: 'end' })
211 const third = await poll([80, 150, 300, 500, 800])
212 if (third === null) return settle('end nudge')
213 await $.ui.scroll({ in: PANE, to: { key: 'list' }, block: 'start' })
214 record('scroll: failed', 0)
215
216 return third
217}
218
219const flash = async ($: EngineInterface, mine: number, landed: number, startedAt: number) => {
220 const refusal = await reveal($, targetKey(await current($)))
221 record('open+scroll', (await $.clock.now()) - startedAt)
222 if (mine !== opens) return
223 if (refusal !== null) await patch($, { message: `Could not scroll to line ${landed} (${refusal.slice(0, 80)}); it is marked with ▸.` })
224 await $.clock.sleep(1200)
225 if (mine === opens) await patch($, { isFlashing: false })
226}
227
228const openFile = async ($: EngineInterface, path: string, line: number | null, isForced = false) => {
229 try {
230 await load($, path, line, isForced)
231 } catch (error) {
232 await patch($, { status: 'error', message: `Could not open ${path}: ${String(error).slice(0, 120)}` })
233 }
234}
235
236const load = async ($: EngineInterface, path: string, line: number | null, isForced: boolean) => {
237 const mine = ++opens
238 const startedAt = await $.clock.now()
239 const shown = await current($)
240 if (!isForced && shown.file === path && shown.status === 'ready' && shown.diff !== '') {
241 const rows = measure(shown).rows
242 const landed = line === null ? null : nearestLine(rows, line)
243 const message = landed !== null && landed !== line ? `Line ${line} is not in this file; showing line ${landed}.` : ''
244 const fresh = await loadDiff($, shown, path)
245 if (mine !== opens) return
246 const diff = fresh.isFailed || fresh.out === '' ? shown.diff : fresh.out
247 await patch($, { diff, line: landed, isFlashing: landed !== null, isListOpen: false, change: -1, shown: -1, message, aim: shown.aim + 1 })
248 await $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS })
249 if (landed !== null) await flash($, mine, landed, startedAt)
250
251 return
252 }
253 await patch($, { file: path, line: null, diff: '', status: 'loading', message: '', isListOpen: false, isBasePickerOpen: false, expanded: [], change: -1, shown: -1 })
254 await $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS })
255 const before = await current($)
256 const v = before.mergeBase === '' || !before.stats.some(stat => stat.path === path) ? await refresh($) : before
257 if (v.status === 'error') return
258 const loaded = await loadDiff($, v, path)
259 if (mine !== opens) return
260 if (loaded.isFailed) {
261 await patch($, { status: 'error', message: loaded.err.split('\n')[0] || `git could not diff ${path}.` })
262
263 return
264 }
265 const rows = parse(loaded.out)
266 const isBinary = loaded.stat?.isBinary === true || (rows.length === 0 && isBinaryDiff(loaded.out))
267 const landed = line === null || rows.length === 0 ? null : nearestLine(rows, line)
268 const message = isBinary
269 ? 'Binary file: there are no lines to show.'
270 : rows.length === 0
271 ? `No changes in this file against ${v.base}.`
272 : landed !== null && landed !== line
273 ? `Line ${line} is not in this file; showing line ${landed}.`
274 : loaded.isTruncated
275 ? 'This diff is very large; the end of it is cut off.'
276 : ''
277 await patch($, { diff: isBinary ? '' : loaded.out, isTruncated: loaded.isTruncated, status: 'ready', message, line: landed, isFlashing: landed !== null, aim: v.aim + 1 })
278 record('open', (await $.clock.now()) - startedAt)
279 if (landed !== null) await flash($, mine, landed, startedAt)
280}
281
282const reload = async ($: EngineInterface) => {
283 const v = await refresh($)
284 if (v.file === null || v.status !== 'ready') return
285 const loaded = await loadDiff($, v, v.file)
286 if (!loaded.isFailed && loaded.out !== v.diff) await patch($, { diff: loaded.out, isTruncated: loaded.isTruncated })
287}
288
289const queueReload = async ($: EngineInterface) => {
290 if (isRefreshQueued) return
291 isRefreshQueued = true
292 await $.clock.sleep(400)
293 isRefreshQueued = false
294 await reload($)
295}
296
297const recover = async ($: EngineInterface) => {
298 const v = await refresh($)
299 if (v.isFlashing) await patch($, { isFlashing: false })
300 if (v.status === 'loading' && v.file !== null) await openFile($, v.file, v.line, true)
301}
302
303const stepFile = async ($: EngineInterface, by: number) => {
304 const v = await current($)
305 const at = v.stats.findIndex(stat => stat.path === v.file)
306 const next = v.stats[at + by]
307 if (next !== undefined) await openFile($, next.path, null)
308}
309
310const stepChange = async ($: EngineInterface, by: number) => {
311 const v = await current($)
312 const { changes, changeAt } = memo.drawn
313 if (changes === 0) return
314 const change = Math.max(0, Math.min(changes - 1, v.change + by))
315 const at = changeAt[change] ?? 0
316 const isDrawn = at >= memo.drawn.start && at < memo.drawn.start + ROW_LIMIT
317 await patch($, isDrawn ? { change } : { change, shown: Math.max(0, at - CONTEXT) })
318 const run = measure(await current($)).drawn.parts.find((part): part is Run => 'rows' in part && part.change === change)
319 const now = await current($)
320 await reveal($, run?.isTarget ? targetKey(now) : changeKey(measure(now).drawn.start, change))
321}
322
323const chooseBase = async ($: EngineInterface, base: string) => {
324 await patch($, { isBasePickerOpen: false })
325 const v = await refresh($, base)
326 if (v.file !== null && v.base === base) await openFile($, v.file, null, true)
327}
328
329const basename = (path: string): string => path.split('/').slice(-1)[0] ?? path
330
331export const register: Register = (on, options) => {
332 settings.base = typeof options?.baseBranch === 'string' ? options.baseBranch.trim() : ''
333
334 on('session.start', async ($, e, next) => {
335 await $.command.register({ name: NAME, description:
336 'See changes against your base branch. /diff-lens lists changed files · /diff-lens src/app.ts:47 opens a file at a line · /diff-lens base origin/main picks the base. Tip: ask "guide me through this PR" and click the file links.' })
337 await $.tool.register({
338 name: 'compare',
339 description:
340 'Sets the base branch or ref that the diff-lens pane compares the working tree against, and answers with the base in use. Call it once before a walkthrough of changes. Leave base out to learn the default base the pane detected.',
341 inputSchema: {
342 type: 'object',
343 properties: { base: { type: 'string', description: 'Branch, tag or commit, for example origin/main or a1b2c3d. Optional.' } },
344 },
345 isDeferred: false,
346 })
347 void recover($)
348
349 return next(e)
350 })
351
352 on('command.run', { command: NAME }, async ($, e) => {
353 const args = e.args.trim()
354 if (args === 'stats') return { text: summarize() }
355 if (args.startsWith('base')) {
356 const ref = args.slice(4).trim()
357 if (ref === '') return { text: `Diff lens compares against ${(await current($)).base || 'no base yet'}. Use /diff-lens base <ref> to change it.` }
358 await chooseBase($, ref)
359 const v = await current($)
360
361 return { text: v.base === ref ? `Now comparing against ${ref}.` : v.message }
362 }
363 const v = await refresh($)
364 if (args === '') {
365 await patch($, { isListOpen: true })
366 await $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS })
367
368 return { text: v.status === 'error' ? v.message : `${v.stats.length} changed files against ${v.base}.` }
369 }
370 const target = splitLine(args)
371 const path = toRelative(target.path, await $.session.cwd())
372 if (path === null || !v.stats.some(stat => stat.path === path)) return { text: `${target.path} has no changes against ${v.base}.` }
373 await $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS })
374 void openFile($, path, target.line)
375
376 return { text: `Opened ${path}${target.line === null ? '' : ` at line ${target.line}`}.` }
377 })
378
379 on('tool.call', { tool: COMPARE } as never, async ($: EngineInterface, e: unknown) => {
380 const base = String((e as { base?: unknown }).base ?? '').trim()
381 if (base === '') {
382 const detected = await refresh($)
383 if (detected.base === '') return { deny: detected.message || 'No base branch was found; pass one, for example origin/main.' }
384
385 return { result: `diff-lens compares the working tree against ${detected.base} (the detected default): ${detected.stats.length} changed files.` }
386 }
387 await chooseBase($, base)
388 const v = await current($)
389 if (v.base !== base) return { deny: v.message || `Base "${base}" was not found in this repository.` }
390
391 return { result: `diff-lens now compares the working tree against ${base}: ${v.stats.length} changed files. Links to those files open in the diff-lens pane.` }
392 })
393
394 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
395 const ran = await next(e)
396 void queueReload($)
397
398 return ran
399 })
400
401 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
402 const ran = await next(e)
403 void queueReload($)
404
405 return ran
406 })
407
408 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
409 const ran = await next(e)
410 void queueReload($)
411
412 return ran
413 })
414
415 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
416 if (e.surface !== 'desktop' || !e.props.text.includes('](')) return next(e)
417 const v = await current($)
418 if (v.stats.length === 0) return next(e)
419 const cwd = await $.session.cwd()
420 const startedAt = Date.now()
421 const changed = new Set(v.stats.map(stat => stat.path))
422 const { text, pressable, changed: hits } = rewrite(e.props.text, cwd, path => changed.has(path))
423 record('rewrite links', Date.now() - startedAt)
424 if (hits === 0 || pressable.length > 256) return next(e)
425 const { Markdown } = $.ui.resolve(e)
426
427 return (
428 <Markdown
429 key="reply"
430 text={text}
431 pressableLinks={pressable}
432 onLinkPress={link => {
433 const target = fromHref(link.href, cwd)
434 if (target !== null) void openFile($, target.path, target.line)
435 }}
436 />
437 )
438 })
439
440 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
441 const { Box, Text, Button } = $.ui.resolve(e)
442 const v = await current($)
443 const startedAt = Date.now()
444 const { rows, drawn } = measure(v)
445 const stat = v.stats.find(one => one.path === v.file)
446 const tint = (kind: Row['kind']) => (kind === '+' ? 'diffAddedDimmed' : kind === '-' ? 'diffRemovedDimmed' : undefined)
447 const ink = (kind: Row['kind']) => (kind === '+' ? 'diffAdded' : kind === '-' ? 'diffRemoved' : undefined)
448 const width = String(rows.reduce((max, row) => Math.max(max, row.n), 0)).length + 2
449 const at = v.stats.findIndex(one => one.path === v.file)
450 const cite = (row: Row) => {
451 if (v.file !== null) void $.prompt.fill({ text: `${v.file}:${row.n} `, mode: 'insert' })
452 }
453 const counts = (one: FileStat) =>
454 one.isUntracked ? (
455 <Text color="diffAdded">new</Text>
456 ) : one.isBinary ? (
457 <Text dimColor>binary</Text>
458 ) : (
459 <Text>
460 <Text color="diffAdded">+{one.added}</Text> <Text color="diffRemoved">-{one.removed}</Text>
461 </Text>
462 )
463
464 const drawRun = (run: Run, index: number) => (
465 <Box key={`run${index}`} flexDirection="column">
466 {run.rows.map((row, i) => {
467 const key = run.isTarget ? targetKey(v) : run.change !== null && i === 0 ? changeKey(drawn.start, run.change) : undefined
468
469 return (
470 <Box flexDirection="row" backgroundColor={run.isTarget && v.isFlashing ? 'suggestion' : tint(row.kind)}>
471 <Box width={width} flexShrink={0} justifyContent="flex-end">
472 <Button key={key} plain dimColor label={String(row.n)} onPress={() => cite(row)} />
473 </Box>
474 <Text bold={run.isTarget} color={run.isTarget ? (v.isFlashing ? 'text' : 'suggestion') : ink(row.kind)}>
475 {run.isTarget ? ' ▸ ' : ` ${row.kind} `}
476 </Text>
477 <Text wrap="truncate-end">{row.text === '' ? ' ' : row.text}</Text>
478 </Box>
479 )
480 })}
481 </Box>
482 )
483
484 const drawFile = (one: FileStat, i: number) => {
485 const isActive = one.path === v.file
486
487 return (
488 <Box flexDirection="row" backgroundColor={isActive ? 'diffAddedDimmed' : undefined} hover={{ backgroundColor: 'subtle', scope: `file${i}` }}>
489 <Box width={2} flexShrink={0}>
490 <Text color="suggestion">{isActive ? '▌' : ' '}</Text>
491 </Box>
492 <Box flexShrink={1} minWidth={0} overflow="hidden">
493 <Button key={`f${i}`} plain label={basename(one.path)} onPress={() => void openFile($, one.path, null)} />
494 </Box>
495 <Box flexGrow={1} minWidth={1} />
496 <Box flexShrink={0} marginRight={1}>
497 {counts(one)}
498 </Box>
499 </Box>
500 )
501 }
502
503 const drawPath = (one: FileStat, i: number) => (
504 <Box position="absolute" top={0} left={2} right={0} display="none" hover={{ display: 'flex', scope: `file${i}` }}>
505 <Text dimColor wrap="truncate-start">
506 {one.path}
507 </Text>
508 </Box>
509 )
510
511 const tree = (
512 <Box flexDirection="column">
513 <Box flexDirection="row" gap={1} alignItems="center">
514 <Button key="list" plain label={`${v.isListOpen ? '▾' : '▸'} ${v.stats.length} ${v.stats.length === 1 ? 'file' : 'files'}`} onPress={() => void patch($, { isListOpen: !v.isListOpen })} />
515 {v.file !== null && (
516 <Box flexShrink={1} minWidth={0} overflow="hidden">
517 <Text bold wrap="truncate-end">
518 {basename(v.file)}
519 </Text>
520 </Box>
521 )}
522 {v.file !== null && stat !== undefined && <Box flexShrink={0}>{counts(stat)}</Box>}
523 <Box flexGrow={1} />
524 {v.stats.length > 1 && at >= 0 && (
525 <Box flexShrink={0} flexDirection="row" gap={1}>
526 <Text dimColor>
527 {at + 1}/{v.stats.length}
528 </Text>
529 <Button key="prevFile" plain label="‹" onPress={() => void stepFile($, -1)} />
530 <Button key="nextFile" plain label="›" onPress={() => void stepFile($, 1)} />
531 </Box>
532 )}
533 </Box>
534 <Box flexDirection="row" gap={1} alignItems="center">
535 <Button key="base" plain dimColor label={`${v.base || 'no base'} → working tree`} onPress={() => void patch($, { isBasePickerOpen: !v.isBasePickerOpen })} />
536 <Box flexGrow={1} />
537 {drawn.changes > 0 && (
538 <Box flexShrink={0} flexDirection="row" gap={1}>
539 <Text dimColor>{v.change >= 0 ? `change ${v.change + 1}/${drawn.changes}` : `${drawn.changes} ${drawn.changes === 1 ? 'change' : 'changes'}`}</Text>
540 <Button key="prevChange" plain label="↑" onPress={() => void stepChange($, -1)} />
541 <Button key="nextChange" plain label="↓" onPress={() => void stepChange($, 1)} />
542 </Box>
543 )}
544 </Box>
545 {v.isBasePickerOpen && (
546 <Box flexDirection="row" flexWrap="wrap" gap={1} marginTop={1}>
547 <Text dimColor>Compare against</Text>
548 {v.bases.map((base, i) => (
549 <Button key={`base${i}`} label={base} variant={base === v.base ? 'primary' : 'secondary'} onPress={() => void chooseBase($, base)} />
550 ))}
551 </Box>
552 )}
553 {v.message !== '' && (
554 <Text color={v.status === 'error' ? 'error' : 'warning'} wrap="wrap">
555 {v.message}
556 </Text>
557 )}
558 {v.status === 'loading' && (
559 <Box flexDirection="row" gap={1}>
560 <Text dimColor>Loading…</Text>
561 {v.file !== null && <Button key="retry" plain dimColor label="Retry" onPress={() => void openFile($, v.file ?? '', v.line, true)} />}
562 </Box>
563 )}
564 {v.status === 'error' && v.file !== null && <Button key="retryError" plain label="Try again" onPress={() => void openFile($, v.file ?? '', v.line, true)} />}
565 {v.status !== 'error' && v.stats.length === 0 && <Text dimColor>No changes against {v.base || 'the base branch'}.</Text>}
566 {v.status === 'idle' && v.stats.length > 0 && v.file === null && <Text dimColor>Click a file link in a reply, or pick a file below.</Text>}
567 {v.isListOpen && (
568 <Box flexDirection="column" marginTop={1}>
569 {v.stats.map(drawFile)}
570 <Box position="relative" height={1}>
571 {v.stats.map(drawPath)}
572 </Box>
573 </Box>
574 )}
575 <Box flexDirection="column" marginTop={1}>
576 {drawn.before > 0 && (
577 <Button key="earlier" plain dimColor label={`▴ ${drawn.before} earlier lines`} onPress={() => void patch($, { shown: Math.max(0, drawn.start - ROW_LIMIT + CONTEXT) })} />
578 )}
579 {drawn.parts.map((part, index) =>
580 'fold' in part ? (
581 <Box flexDirection="row">
582 <Box width={width} flexShrink={0} />
583 <Button
584 key={`fold${part.fold}`}
585 plain
586 dimColor
587 label={`▸ ${part.count} unmodified ${part.count === 1 ? 'line' : 'lines'}`}
588 onPress={() => void patch($, { expanded: [...v.expanded, part.fold] })}
589 />
590 </Box>
591 ) : (
592 drawRun(part, index)
593 ),
594 )}
595 {drawn.hidden > 0 && (
596 <Button key="later" plain dimColor label={`▾ ${drawn.hidden} more lines`} onPress={() => void patch($, { shown: drawn.start + ROW_LIMIT - CONTEXT })} />
597 )}
598 </Box>
599 </Box>
600 )
601 record('render pane', Date.now() - startedAt)
602
603 return tree
604 })
605}
606hooks/diff.ts 126 lines1export type RowKind = ' ' | '+' | '-'
2export type Row = { kind: RowKind; n: number; text: string }
3export type Run = { rows: Row[]; change: number | null; isTarget: boolean }
4export type Fold = { fold: number; count: number }
5export type Part = Run | Fold
6export type Layout = { parts: Part[]; changes: number; changeAt: number[]; start: number; before: number; hidden: number; hasTarget: boolean }
7
8type Item = Fold | { row: Row; change: number | null; isTarget: boolean }
9
10const MIN_FOLD = 3
11const HUNK = /^@@ -(\d+)(?:,\d+)? \+(\d+)/
12const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g
13
14export const clean = (text: string): string => text.replace(/\r$/, '').replace(/\t/g, ' ').replace(CONTROL, '')
15
16export const parse = (diff: string): Row[] => {
17 const rows: Row[] = []
18 let oldN = 0
19 let newN = 0
20 let isInHunk = false
21 for (const raw of diff.split('\n')) {
22 const hunk = HUNK.exec(raw)
23 if (hunk) {
24 oldN = Number(hunk[1])
25 newN = Number(hunk[2])
26 isInHunk = true
27 } else if (!isInHunk) continue
28 else if (raw.startsWith('+')) rows.push({ kind: '+', n: newN++, text: clean(raw.slice(1)) })
29 else if (raw.startsWith('-')) rows.push({ kind: '-', n: oldN++, text: clean(raw.slice(1)) })
30 else if (raw.startsWith(' ')) {
31 rows.push({ kind: ' ', n: newN++, text: clean(raw.slice(1)) })
32 oldN++
33 } else if (raw.startsWith('diff --git ')) isInHunk = false
34 }
35
36 return rows
37}
38
39export const isBinaryDiff = (diff: string): boolean => /^Binary files .* differ$/m.test(diff) || /^GIT binary patch$/m.test(diff)
40
41const isTargetRow = (row: Row, line: number | null): boolean => line !== null && row.kind !== '-' && row.n === line
42
43export const nearestLine = (rows: Row[], line: number): number | null => {
44 let best: number | null = null
45 for (const row of rows) {
46 if (row.kind === '-') continue
47 if (best === null || Math.abs(row.n - line) < Math.abs(best - line)) best = row.n
48 }
49
50 return best
51}
52
53export const layout = (
54 rows: Row[],
55 line: number | null,
56 expanded: readonly number[],
57 context: number,
58 limit: number,
59 start: number | null = null,
60): Layout => {
61 const isNear = rows.map(() => false)
62 rows.forEach((row, i) => {
63 if (row.kind === ' ' && !isTargetRow(row, line)) return
64 for (let j = Math.max(0, i - context); j <= Math.min(rows.length - 1, i + context); j++) isNear[j] = true
65 })
66 const items: Item[] = []
67 const changeAt: number[] = []
68 let targetAt = -1
69 let openUntil = 0
70 let i = 0
71 while (i < rows.length) {
72 const from = i
73 if (!isNear[i] && i >= openUntil) {
74 while (i < rows.length && !isNear[i]) i++
75 if (!expanded.includes(from) && i - from >= MIN_FOLD) {
76 items.push({ fold: from, count: i - from })
77 continue
78 }
79 openUntil = i
80 i = from
81 }
82 const row = rows[i] as Row
83 const startsChange = row.kind !== ' ' && (i === 0 || (rows[i - 1] as Row).kind === ' ')
84 const isTarget = isTargetRow(row, line)
85 if (startsChange) changeAt.push(items.length)
86 if (isTarget) targetAt = items.length
87 items.push({ row, change: startsChange ? changeAt.length - 1 : null, isTarget })
88 i++
89 }
90 const total = items.length
91 const first =
92 total <= limit ? 0 : Math.max(0, Math.min(total - limit, start !== null ? start : targetAt >= 0 ? targetAt - Math.floor(limit / 2) : 0))
93 const shown = items.slice(first, first + limit)
94 const parts: Part[] = []
95 for (const item of shown) {
96 const last = parts[parts.length - 1]
97 if ('fold' in item) parts.push(item)
98 else if (last !== undefined && 'rows' in last && !last.isTarget && !item.isTarget && item.change === null && (last.rows[0] as Row).kind === item.row.kind)
99 last.rows.push(item.row)
100 else parts.push({ rows: [item.row], change: item.change, isTarget: item.isTarget })
101 }
102
103 const lines = (list: Item[]) => list.reduce((sum, item) => sum + ('fold' in item ? item.count : 1), 0)
104
105 return {
106 parts,
107 changes: changeAt.length,
108 changeAt,
109 start: first,
110 before: lines(items.slice(0, first)),
111 hidden: lines(items.slice(first + shown.length)),
112 hasTarget: targetAt >= first && targetAt < first + limit,
113 }
114}
115
116export const parseNumstat = (out: string): { path: string; added: number; removed: number; isBinary: boolean }[] =>
117 out
118 .split('\u0000')
119 .filter(Boolean)
120 .map(entry => {
121 const [added = '', removed = '', ...path] = entry.split('\t')
122
123 return { path: path.join('\t'), added: Number(added) || 0, removed: Number(removed) || 0, isBinary: added === '-' && removed === '-' }
124 })
125 .filter(stat => stat.path !== '')
126hooks/links.ts 66 lines1export type Target = { path: string; line: number | null }
2export type Rewritten = { text: string; hrefs: string[]; pressable: string[]; changed: number }
3
4const SCHEME = 'file://'
5const LINK = /\]\(([^)\s]+)\)/g
6const EXTERNAL = /^(?:[a-z][a-z0-9+.-]*:|#|\/\/)/i
7
8export const toRelative = (path: string, cwd: string): string | null => {
9 if (path.startsWith(`${cwd}/`)) return path.slice(cwd.length + 1)
10 if (path.startsWith('/')) return null
11
12 return path.replace(/^\.\//, '')
13}
14
15export const splitLine = (target: string): Target => {
16 const match = /^(.*?)(?::(\d+)(?::\d+)?|#L(\d+)(?:-L?\d+)?)?$/.exec(target)
17 const line = match?.[2] ?? match?.[3]
18
19 return { path: decode(match?.[1] ?? target), line: line === undefined ? null : Number(line) }
20}
21
22const decode = (path: string): string => {
23 try {
24 return decodeURI(path)
25 } catch {
26 return path
27 }
28}
29
30export const toHref = (cwd: string, target: Target): string =>
31 `${SCHEME}${encodeURI(`${cwd}/${target.path}`)}${target.line === null ? '' : `#L${target.line}`}`
32
33export const fromHref = (href: string, cwd: string): Target | null => {
34 if (!href.startsWith(SCHEME)) return null
35 const target = splitLine(href.slice(SCHEME.length))
36 const path = toRelative(target.path, cwd)
37
38 return path === null ? null : { path, line: target.line }
39}
40
41export const rewrite = (text: string, cwd: string, isChanged: (path: string) => boolean): Rewritten => {
42 const hrefs: string[] = []
43 const pressable: string[] = []
44 let changed = 0
45 const rewriteProse = (prose: string): string =>
46 prose.replace(LINK, (whole: string, raw: string) => {
47 const isFile = raw.startsWith(SCHEME)
48 if (EXTERNAL.test(raw) && !isFile) return whole
49 const target = splitLine(isFile ? raw.slice(SCHEME.length) : raw)
50 const path = toRelative(target.path, cwd)
51 if (path === null) return whole
52 const href = toHref(cwd, { path, line: target.line })
53 if (isChanged(path)) {
54 changed++
55 if (!pressable.includes(href)) pressable.push(href)
56 }
57 if (!hrefs.includes(href)) hrefs.push(href)
58
59 return `](${href})`
60 })
61 const pieces = text.split(/(```[\s\S]*?(?:```|$)|`[^`\n]*`)/)
62 const out = pieces.map((piece, i) => (i % 2 === 1 ? piece : rewriteProse(piece))).join('')
63
64 return { text: out, hrefs, pressable, changed }
65}
66types/index.d.ts 30 lines1export type FileStat = { path: string; added: number; removed: number; isBinary: boolean; isUntracked: boolean }
2
3export type Status = 'idle' | 'loading' | 'ready' | 'error'
4
5export type LensView = {
6 base: string
7 mergeBase: string
8 bases: string[]
9 stats: FileStat[]
10 file: string | null
11 line: number | null
12 isFlashing: boolean
13 diff: string
14 isTruncated: boolean
15 status: Status
16 message: string
17 isListOpen: boolean
18 isBasePickerOpen: boolean
19 expanded: number[]
20 change: number
21 shown: number
22 aim: number
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'diff-lens': { view: LensView }
28 }
29}
30