A pane listing the files Claude changed since your last message, each one's diff a click away.

A pane that lists the files Claude changed since your last message. Click a file to unfold its diff.
The list starts over each time you send a message, so it only ever shows what the reply you are reading changed. A slash command does not start it over, and neither does what reaches Claude without you typing it: a background task's notice, a scheduled prompt, another session's message. Answering Claude's questions or approving its plan keeps the list going too, since neither is a new message.
A file Claude changed and then put back exactly as it was leaves the list.
In the Desktop app the header is drawn as an image that takes the pane's width, and the tiles are dark, as in Session Recap. In the terminal the pane reads as Claude Code's own lists: a ❯ on the focused row, each file's letter, its path from the session's folder and its line counts. ↑ and ↓ move, Enter unfolds the diff under the row, Escape hands the keyboard back to the prompt.
/changed-files once. The mod remembers that across sessions, /clear and a plan approved with a fresh context included. On the main screen (CLAUDE_CODE_NO_FLICKER=0, tmux) a pane sits above the prompt, so there it waits for /changed-files./changed-files opens the pane and gives it the keyboard, so ↑, ↓ and Enter work at once. Later, ctrl+x tab takes you back to it.claude plugin marketplace add endless-fr/claude-mods
claude plugin install changed-files@endless-claude-mods
Run /reload-plugins in a session that is already open. Requires Claude Code v2.1.287 or later in the terminal, v2.1.286 or later in the Desktop app.
The mod makes no network requests, calls no model and writes no file. The only process it runs is git, to read the repository's state.
Write, Edit, MultiEdit and NotebookEdit call names, and that file's text, once before Claude's first change to it in a reply and again after each change.cat, sed, rm or a script): the files the command names for rm, unlink, git rm or as the source of mv, the files already in the list and, in a git repository, every file git status lists as changed, untracked or deleted; each one's size and time before and after, and its text before. A file that git had clean takes its text before from the index (git show :<path>)./changed-files last saw it./diff to close the built-in panel; Claude Code keeps that choice, and the list comes back. /changed-files says so the first time you run it in a repository.dist/, node_modules/) changed through the shell appears only if it is already in the list.run_in_background) is looked at as it starts, not as it ends.claude plugin validate mods/changed-files
claude plugin test mods/changed-files
The diff lives in hooks/diff.ts, the header card in hooks/card.ts and the small drawings of a tile in hooks/designs.ts, all three free of any engine call. hooks/row.tsx draws a tile's text in the Desktop app and turns a click on it into a message. hooks/register.tsx follows the file tools and draws the pane.
hooks/register.tsx 413 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput } from 'claude-code'
3
4import type { ChangedFile } from '../types'
5import { altOf, headerSvg } from './card'
6import { dirtyIn, foldersOf, removalsOf } from './shell'
7import { badgeOf, barSvg, iconSvg, keyOf, relativeTo, split } from './designs'
8import { diffOf } from './diff'
9
10const PANE = 'changed-files'
11const TITLE = 'Fichiers modifiés'
12const WRITE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit']
13
14const files = atom({ plugin: 'changed-files', key: 'files' } as const, [] as ChangedFile[])
15const originals = atom({ plugin: 'changed-files', key: 'originals' } as const, {} as Record<string, string | null>)
16const selected = atom({ plugin: 'changed-files', key: 'selected' } as const, null as string | null)
17const cursor = atom({ plugin: 'changed-files', key: 'cursor' } as const, null as string | null)
18const hinted = atom({ plugin: 'changed-files', key: 'hinted' } as const, false as boolean)
19const docks = atom({ plugin: 'changed-files', key: 'docks' } as const, null as boolean | null)
20
21function targetOf(e: { tool: string }): string | undefined {
22 const input = e as Record<string, unknown>
23 const path = input.file_path ?? input.notebook_path
24 return typeof path === 'string' ? path : undefined
25}
26
27/** The file's text, or null when there is none to read. */
28async function textOf($: EngineInterface, path: string): Promise<string | null> {
29 try {
30 const text = await $.fs.read(path)
31 return typeof text === 'string' ? text : null
32 } catch {
33 return null
34 }
35}
36
37/** Git's answer, or null outside a repository or on any failure. */
38async function git($: EngineInterface, args: string[], cwd: string): Promise<string | null> {
39 try {
40 const ran = await $.process.run(['git', ...args], { cwd })
41 return ran.exitCode === 0 ? ran.stdout : null
42 } catch {
43 return null
44 }
45}
46
47/** A file's size and time, to tell it changed; null when it is not there. */
48async function stampOf($: EngineInterface, path: string): Promise<string | null> {
49 try {
50 const stat = await $.fs.stat(path)
51 return stat.kind === 'file' ? `${stat.size}:${stat.mtimeMs}` : null
52 } catch {
53 return null
54 }
55}
56
57// Past this many files git lists as changed, the shell's are followed only among those the mod knows.
58const MOST_DIRTY = 300
59
60/** A file before and after: null before for one that did not exist, null after for one deleted. */
61type Change = { path: string; before: string | null; after: string | null }
62
63/**
64 * Puts each change in the list: its first text kept for the reply, its diff
65 * against that text, its letter. A file back as it was, or made and deleted
66 * in the same reply, leaves it. Opens the pane on the reply's first file.
67 */
68async function record($: EngineInterface, changes: Change[]) {
69 if (changes.length === 0) return
70 const wasEmpty = (await read($, files)).length === 0
71 for (const { path, before, after } of changes) {
72 await update($, originals, all => (path in all ? all : { ...all, [path]: before }))
73 const first = (await read($, originals))[path] ?? null
74 const { source, added, removed } = diffOf(first, after ?? '')
75 await update($, files, list => {
76 const rest = list.filter(one => one.path !== path)
77 if (after === null) {
78 return first === null ? rest : [...rest, { path, isNew: false, isDeleted: true, added: 0, removed, diff: source }]
79 }
80 // Edited back to how it was: nothing to show for it.
81 if (added === 0 && removed === 0) return rest
82 return [...rest, { path, isNew: first === null, isDeleted: false, added, removed, diff: source }]
83 })
84 }
85 if (wasEmpty && (await read($, files)).length > 0 && (await mayOpenUnasked($))) void open($).catch(() => undefined)
86}
87
88const open = ($: EngineInterface) => $.ui.open({ id: PANE, title: TITLE })
89
90/**
91 * Whether the pane may open on its own: where it lands as a sidebar. The app
92 * docks it; the terminal only in its fullscreen layout, which /changed-files
93 * reports. On the terminal's main screen it would sit above the prompt, so
94 * there it waits for /changed-files.
95 */
96async function mayOpenUnasked($: EngineInterface): Promise<boolean> {
97 const surfaces = await $.session.surfaces().catch(() => [] as const)
98 if (surfaces.some(surface => surface !== 'terminal')) return true
99 // Kept in the store too: a /clear, or a plan approved with a fresh context,
100 // starts a new session whose state knows nothing of the layout.
101 const known = (await read($, docks)) ?? (await $.store.get('docks').catch(() => undefined))
102 return known === true
103}
104
105/**
106 * Whether the file is there at all, unlike textOf, which also fails on one too
107 * big to read; `unsure` is the answer when the engine cannot say.
108 */
109const exists = ($: EngineInterface, path: string, unsure: boolean) => $.fs.exists(path).catch(() => unsure)
110
111/** Who sends a prompt that starts a new list: the person, not a task's notice or a peer. */
112const PERSON = ['composer', 'bridge', 'sdk', 'slack-ping']
113
114export const register: Register = on => {
115 on('session.start', async ($, e, next) => {
116 await $.command.register({
117 name: 'changed-files',
118 description: 'Afficher les fichiers modifiés depuis votre dernier message',
119 })
120 if ((await read($, files)).length > 0 && (await mayOpenUnasked($))) void open($)
121
122 return next(e)
123 })
124
125 on('command.run', { command: 'changed-files' }, async ($, e) => {
126 await update($, docks, () => e.presentation.isFullscreen)
127 await $.store.set('docks', e.presentation.isFullscreen).catch(() => undefined)
128 // Asked for: it takes the keyboard, so the arrows work at once.
129 const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true })
130 if (!opened.isPlaced) return { text: `Le panneau est ouvert mais pas encore affiché : ${opened.reason}` }
131
132 // In a git repository the fullscreen terminal docks Claude Code's own diff
133 // panel as Claude edits, over every plugin pane. Said once a session.
134 if (e.presentation.isFullscreen && !(await read($, hinted))) {
135 const cwd = await $.session.cwd().catch(() => '')
136 if (cwd !== '' && (await git($, ['rev-parse', '--is-inside-work-tree'], cwd))?.trim() === 'true') {
137 await update($, hinted, () => true)
138 return {
139 text: 'Dans un dépôt git, le panneau Diff de Claude Code s’ouvre quand Claude modifie un fichier et recouvre « Fichiers modifiés ». Tapez /diff pour le fermer : Claude Code s’en souviendra.',
140 }
141 }
142 }
143
144 return {}
145 })
146
147 // A new message from the person starts a new list; a slash command does not,
148 // nor what the engine delivers on its own: a background task's notice, a
149 // scheduled prompt, another session's message.
150 on('prompt.submit', async ($, e, next) => {
151 if (PERSON.includes(e.origin.kind) && !e.text.trimStart().startsWith('/')) {
152 await update($, files, () => [])
153 await update($, originals, () => ({}))
154 await update($, selected, () => null)
155 await update($, cursor, () => null)
156 }
157
158 return next(e)
159 })
160
161 on('tool.call', async ($, e, next) => {
162 if (!WRITE_TOOLS.includes(e.tool)) return next(e)
163 const path = targetOf(e)
164 if (path === undefined) return next(e)
165
166 const kept = await read($, originals)
167 const before = path in kept ? (kept[path] ?? null) : await textOf($, path)
168 // There but unreadable (past 4 MiB, say): no diff to show, so not followed.
169 if (!(path in kept) && before === null && (await exists($, path, false))) return next(e)
170
171 const ran = await next(e)
172 if (ran.deny !== undefined || ran.isError === true) return ran
173
174 const after = await textOf($, path)
175 if (after === null && (await exists($, path, false))) return ran
176 await record($, [{ path, before, after }])
177
178 return ran
179 })
180
181 // Claude often writes, edits and deletes files through the shell. Around each
182 // command: the files it names for removal and the ones already listed, and in
183 // a git repository every file git sees changed, untracked or deleted, each
184 // looked at before and after; a file whose size or time moved, that came or
185 // went, changed.
186 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
187 const command = (e as { command?: unknown }).command
188 if (typeof command !== 'string') return next(e)
189 const cwd = await $.session.cwd().catch(() => '')
190 // The repositories the command works in: the session's, and any a `cd` leads to.
191 const roots = new Set<string>()
192 for (const folder of cwd === '' ? [] : foldersOf(command, cwd)) {
193 const root = (await git($, ['rev-parse', '--show-toplevel'], folder))?.trim()
194 if (root) roots.add(root)
195 }
196 const rootOf = (path: string) => [...roots].filter(root => path.startsWith(`${root}/`)).sort((a, b) => b.length - a.length)[0] ?? null
197 const dirty = async () => {
198 const all = new Map<string, string>()
199 for (const root of roots) {
200 const status = await git($, ['status', '--porcelain', '-z', '--untracked-files=all'], root)
201 for (const [path, code] of dirtyIn(status ?? '', root)) all.set(path, code)
202 }
203 return all
204 }
205
206 const dirtyBefore = await dirty()
207 // A repository with a crowd of changes: their texts are not all read before
208 // each command, so a file already changed is followed once the mod knows it.
209 const isCrowded = dirtyBefore.size > MOST_DIRTY
210 const listed = (await read($, files)).filter(file => !file.isDeleted).map(file => file.path)
211 const watched = new Set([...removalsOf(command, cwd), ...listed, ...(isCrowded ? [] : dirtyBefore.keys())])
212 const kept = await read($, originals)
213 const seen = new Map<string, { stamp: string | null; text: string | null }>()
214 for (const path of watched) {
215 const stamp = await stampOf($, path)
216 seen.set(path, { stamp, text: stamp === null || path in kept ? null : await textOf($, path) })
217 }
218
219 const ran = await next(e)
220 if (ran.deny !== undefined) return ran
221
222 const dirtyAfter = await dirty()
223 const touched = new Set<string>()
224 for (const [path, { stamp }] of seen) if ((await stampOf($, path)) !== stamp) touched.add(path)
225 // Newly changed, untracked or deleted: few, whatever the repository holds,
226 // and git's index has their text before.
227 for (const path of dirtyAfter.keys()) if (!dirtyBefore.has(path)) touched.add(path)
228 // Back to as committed: only where the text before was read.
229 if (!isCrowded) for (const path of dirtyBefore.keys()) if (!dirtyAfter.has(path) && !seen.has(path)) touched.add(path)
230 if (touched.size === 0) return ran
231
232 // A file git had clean: its text before is the index's.
233 const committed = async (path: string) => {
234 const root = rootOf(path)
235 if (dirtyBefore.has(path) || root === null) return null
236 return git($, ['show', `:${path.slice(root.length + 1)}`], root)
237 }
238 const changes: Change[] = []
239 for (const path of touched) {
240 const known = seen.get(path)
241 // The text before: kept, read just now, or, for a file git had clean, the committed one.
242 const before =
243 path in kept
244 ? (kept[path] ?? null)
245 : known !== undefined
246 ? known.stamp === null
247 ? null
248 : known.text
249 : await committed(path)
250 const isThere = await exists($, path, true)
251 const after = isThere ? await textOf($, path) : null
252 // There but unreadable, or a folder: nothing to show.
253 if (isThere && after === null) continue
254 changes.push({ path, before, after })
255 }
256 await record($, changes)
257
258 return ran
259 })
260
261 // A click on a file's row, from its Client: the row's key names the file.
262 on('ui.message', { requestId: PANE }, async ($, e) => {
263 const data = e.data as { toggle?: boolean } | null
264 if (!data?.toggle) return {}
265 const file = (await read($, files)).find(one => keyOf(one.path) === e.element)
266 if (file) await update($, selected, now => (now === file.path ? null : file.path))
267
268 return {}
269 })
270
271 // The terminal's ❯ follows the focus ring, as in Claude Code's own lists.
272 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
273 const result = await next(e)
274 if (result.deny === undefined) await update($, cursor, () => e.element ?? null)
275
276 return result
277 })
278
279 // A drawing that throws makes the engine drop the pane: never let it.
280 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
281 try {
282 return await draw($, e)
283 } catch (error) {
284 const { Box, Text } = $.ui.resolve(e)
285 return (
286 <Box flexDirection="column" paddingX={1}>
287 <Text bold>Fichiers modifiés</Text>
288 <Text dimColor>{`Affichage impossible : ${error instanceof Error ? error.message : String(error)}`}</Text>
289 </Box>
290 )
291 }
292 })
293}
294
295async function draw($: EngineInterface, e: RenderInput) {
296 {
297 const table = $.ui.resolve(e)
298 const { Box, Text, Button, Code } = table
299 const Svg = e.surface !== 'terminal' && 'Svg' in table ? table.Svg : undefined
300 const Client = 'Client' in table ? table.Client : undefined
301 const list = [...(await read($, files))].reverse()
302 const shown = await read($, selected)
303 const toggle = (path: string) => () => void update($, selected, now => (now === path ? null : path))
304
305 const rows = list.map(file => {
306 const { name, dir } = split(file.path)
307 const isOpen = shown === file.path
308 const badge = badgeOf(file)
309
310 // The desktop: a dark tile of its own, as the card's, lit under the pointer.
311 if (Svg && Client) {
312 return (
313 <Box
314 key={`tile-${keyOf(file.path)}`}
315 flexDirection="column"
316 gap={1}
317 paddingX={2}
318 paddingY={1}
319 borderStyle="round"
320 borderColor={isOpen ? '#0A84FF' : '#2C2C2E'}
321 backgroundColor="#1C1C1E"
322 hover={{ backgroundColor: '#242426', borderColor: isOpen ? '#0A84FF' : '#3A3A3C' }}
323 >
324 <Box flexDirection="row" alignItems="center" gap={2}>
325 <Text bold color={badge.color}>
326 {badge.letter}
327 </Text>
328 <Svg source={iconSvg(name)} alt="" width={36} height={36} />
329 <Client
330 key={keyOf(file.path)}
331 module="./row.tsx"
332 props={{ name, dir, isOpen, isDeleted: file.isDeleted === true }}
333 flexGrow={1}
334 />
335 <Svg source={barSvg(file.added, file.removed)} alt="" width={53} height={9} />
336 </Box>
337 {isOpen ? <Code source={file.diff} format="diff" path={file.path} /> : null}
338 </Box>
339 )
340 }
341
342 return null
343 })
344
345 if (Svg) {
346 return (
347 <Box flexDirection="column" gap={1}>
348 <Svg source={headerSvg(list)} alt={altOf(list)} />
349 {rows}
350 </Box>
351 )
352 }
353
354 // The terminal: Claude Code's own list, a ❯ on the focused row, each row
355 // a file's letter and path, its diff unfolded beneath it.
356 const cwd = await $.session.cwd().catch(() => '')
357 const at = await read($, cursor)
358 const added = list.reduce((sum, file) => sum + file.added, 0)
359 const removed = list.reduce((sum, file) => sum + file.removed, 0)
360
361 if (list.length === 0) {
362 return (
363 <Box flexDirection="column" paddingX={1}>
364 <Text bold>Fichiers modifiés</Text>
365 <Text dimColor>Aucune modification depuis votre dernier message.</Text>
366 </Box>
367 )
368 }
369
370 return (
371 <Box flexDirection="column" paddingX={1}>
372 <Box gap={1}>
373 <Text bold>Fichiers modifiés</Text>
374 <Text dimColor>·</Text>
375 <Text dimColor>{list.length === 1 ? '1 fichier' : `${list.length} fichiers`}</Text>
376 <Text color="green">{`+${added}`}</Text>
377 <Text color="red">{`-${removed}`}</Text>
378 </Box>
379 <Box flexDirection="column" marginTop={1}>
380 {list.map((file, index) => {
381 const key = keyOf(file.path)
382 const isOpen = shown === file.path
383 const isAt = at === key
384 const badge = badgeOf(file)
385 return (
386 <Box key={`row-${key}`} flexDirection="column">
387 <Box gap={1}>
388 <Text color="#0A84FF">{isAt ? '❯' : ' '}</Text>
389 <Text bold color={badge.color}>
390 {badge.letter}
391 </Text>
392 <Button key={key} plain dimColor={file.isDeleted === true ? true : undefined} autoFocus={index === 0 ? true : undefined} onPress={toggle(file.path)}>
393 {relativeTo(cwd, file.path)}
394 </Button>
395 <Text dimColor>{`+${file.added} -${file.removed}`}</Text>
396 </Box>
397 {isOpen ? (
398 <Box paddingLeft={4} marginBottom={1}>
399 <Code source={file.diff} format="diff" path={file.path} />
400 </Box>
401 ) : null}
402 </Box>
403 )
404 })}
405 </Box>
406 <Box marginTop={1}>
407 <Text dimColor>↑/↓ pour choisir · Entrée pour la diff · Échap pour revenir au prompt</Text>
408 </Box>
409 </Box>
410 )
411 }
412}
413hooks/card.ts 89 lines1/** The pane's header: a card of rounded tiles in the manner of Session Recap. Free of any engine call. */
2
3import type { ChangedFile } from '../types'
4
5const WIDTH = 600
6const EDGE = 12
7const GAP = 10
8const CORNER = 20
9const HEIGHT = 168
10
11const GROUND = '#000000'
12const SURFACE = '#1C1C1E'
13const INK = '#F5F5F7'
14const GREY = '#8E8E93'
15const ACCENT = '#0A84FF'
16const PLUS = '#30D158'
17const MINUS = '#FF453A'
18
19const FACE = "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', Helvetica, Arial, sans-serif"
20
21const REVEAL = { first: 0.05, step: 0.09, takes: 0.6, rise: 10 }
22
23type Type = { size: number; fill?: string; weight?: number; tracking?: number; opacity?: number }
24
25function text(x: number, y: number, content: string, { size, fill = INK, weight = 400, tracking = 0, opacity }: Type): string {
26 const extras = `${tracking ? ` letter-spacing="${tracking}"` : ''}${opacity === undefined ? '' : ` fill-opacity="${opacity}"`}`
27
28 return `<text x="${x}" y="${y}" font-size="${size}" font-weight="${weight}" fill="${fill}"${extras}>${content}</text>`
29}
30
31function reveal(order: number): string {
32 const waits = REVEAL.first + order * REVEAL.step
33 const ends = waits + REVEAL.takes
34 const timing = `keyTimes="0;${(waits / ends).toFixed(3)};1" dur="${ends.toFixed(2)}s" fill="freeze" calcMode="spline" keySplines="0 0 1 1;0.16 1 0.3 1"`
35
36 return (
37 `<animate attributeName="opacity" values="0;0;1" ${timing}/>` +
38 `<animateTransform attributeName="transform" type="translate" values="0 ${REVEAL.rise};0 ${REVEAL.rise};0 0" ${timing}/>`
39 )
40}
41
42function tile(order: number, x: number, width: number, fill: string, inside: string): string {
43 const height = HEIGHT - EDGE * 2
44
45 return `<g transform="translate(${x} ${EDGE})"><g>${reveal(order)}<rect width="${width}" height="${height}" rx="${CORNER}" fill="${fill}"/>${inside}</g></g>`
46}
47
48/** What a reader that cannot see the card is told. */
49export function altOf(files: readonly ChangedFile[]): string {
50 if (files.length === 0) return 'Aucune modification. Les fichiers modifiés par Claude apparaîtront ici.'
51
52 return `${files.length} fichier${files.length > 1 ? 's' : ''} modifié${files.length > 1 ? 's' : ''} depuis votre dernier message`
53}
54
55export function headerSvg(files: readonly ChangedFile[]): string {
56 const inner = HEIGHT - EDGE * 2
57 const open = `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}" font-family="${FACE}">`
58 const ground = `<rect width="${WIDTH}" height="${HEIGHT}" rx="${CORNER + EDGE / 2}" fill="${GROUND}"/>`
59
60 if (files.length === 0) {
61 const inside =
62 text(20, 38, 'Fichiers modifiés', { size: 15, fill: GREY, weight: 500 }) +
63 text(20, inner - 46, 'Aucune modification', { size: 28, weight: 600, tracking: -0.5 }) +
64 text(20, inner - 18, 'Les fichiers modifiés par Claude apparaîtront ici.', { size: 14, fill: GREY })
65
66 return `${open}${ground}${tile(0, EDGE, WIDTH - EDGE * 2, SURFACE, inside)}</svg>`
67 }
68
69 const added = files.reduce((sum, file) => sum + file.added, 0)
70 const removed = files.reduce((sum, file) => sum + file.removed, 0)
71 const first = Math.round((WIDTH - EDGE * 2 - GAP) * 0.55)
72 const second = WIDTH - EDGE * 2 - GAP - first
73
74 const count =
75 text(20, 38, 'Fichiers modifiés', { size: 15, fill: '#FFFFFF', weight: 500, opacity: 0.86 }) +
76 text(18, inner - 42, String(files.length), { size: 44, fill: '#FFFFFF', weight: 600, tracking: -1.4 }) +
77 text(20, inner - 18, 'depuis votre dernier message', { size: 13, fill: '#FFFFFF', opacity: 0.86 })
78 const lines =
79 text(20, 38, 'Lignes', { size: 15, fill: GREY, weight: 500 }) +
80 text(18, inner - 42, `<tspan fill="${PLUS}">+${added}</tspan> <tspan fill="${MINUS}" font-size="26">−${removed}</tspan>`, {
81 size: 44,
82 weight: 600,
83 tracking: -1.2,
84 }) +
85 text(20, inner - 18, 'cliquez un fichier pour sa diff', { size: 13, fill: GREY })
86
87 return `${open}${ground}${tile(0, EDGE, first, ACCENT, count)}${tile(1, EDGE + first + GAP, second, SURFACE, lines)}</svg>`
88}
89hooks/shell.ts 114 lines1/* What a shell command changes: Claude often writes, edits and deletes files through Bash. */
2
3/** Splits a command line into words, quotes honoured, and its commands at `&&`, `||`, `;` and `|`. */
4function commandsOf(line: string): string[][] {
5 const commands: string[][] = [[]]
6 let word = ''
7 let hasWord = false
8 let quote: '"' | "'" | null = null
9 const end = () => {
10 if (hasWord) commands[commands.length - 1]!.push(word)
11 word = ''
12 hasWord = false
13 }
14 for (let i = 0; i < line.length; i++) {
15 const char = line[i]!
16 if (quote) {
17 if (char === quote) quote = null
18 else if (char === '\\' && quote === '"' && i + 1 < line.length) word += line[++i]
19 else word += char
20 } else if (char === '"' || char === "'") {
21 quote = char
22 hasWord = true
23 } else if (char === '\\' && i + 1 < line.length) {
24 word += line[++i]
25 hasWord = true
26 } else if (/\s/.test(char)) {
27 end()
28 } else if (char === ';' || char === '|' || char === '&' || char === '\n') {
29 end()
30 if (commands[commands.length - 1]!.length > 0) commands.push([])
31 } else {
32 word += char
33 hasWord = true
34 }
35 }
36 end()
37
38 return commands.filter(words => words.length > 0)
39}
40
41function resolve(cwd: string, path: string): string {
42 const parts = (path.startsWith('/') ? path : `${cwd}/${path}`).split('/')
43 const out: string[] = []
44 for (const part of parts) {
45 if (part === '' || part === '.') continue
46 if (part === '..') out.pop()
47 else out.push(part)
48 }
49
50 return `/${out.join('/')}`
51}
52
53/** Each command of the line with the folder it runs in: `cd` moves the ones after it. */
54function placedOf(line: string, cwd: string): { words: string[]; dir: string }[] {
55 const placed: { words: string[]; dir: string }[] = []
56 let dir = cwd
57 for (const words of commandsOf(line)) {
58 // `cd`, `cd ~` or `cd -` lead where the line does not say: left as is.
59 if (words[0] === 'cd' && words[1] !== undefined && !/^[~-]/.test(words[1]) && !/[$*?[]/.test(words[1])) dir = resolve(dir, words[1])
60 placed.push({ words, dir })
61 }
62
63 return placed
64}
65
66/** The folders a command line runs in: `cwd`, then every one a `cd` names. */
67export function foldersOf(line: string, cwd: string): string[] {
68 return [...new Set([cwd, ...placedOf(line, cwd).map(({ dir }) => dir)])]
69}
70
71/**
72 * The paths a command line names for removal: the operands of `rm`, `unlink`
73 * and `git rm`, and the sources of `mv` and `git mv`, each from the folder a
74 * `cd` before it leads to. A pattern (`*.log`) is left out: only the shell
75 * knows what it matched.
76 */
77export function removalsOf(line: string, cwd: string): string[] {
78 const paths: string[] = []
79 for (let { words, dir } of placedOf(line, cwd)) {
80 while (words[0] === 'sudo' || words[0] === 'command') words = words.slice(1)
81 if (words[0] === 'git') words = words.slice(1)
82 const [name, ...rest] = words
83 if (name !== 'rm' && name !== 'unlink' && name !== 'mv') continue
84 const operands = rest.filter(word => !word.startsWith('-'))
85 const named = name === 'mv' ? operands.slice(0, -1) : operands
86 for (const path of named) if (!/[*?[]/.test(path)) paths.push(resolve(dir, path))
87 }
88
89 return [...new Set(paths)]
90}
91
92/**
93 * The files `git status --porcelain -z --untracked-files=all` lists, absolute
94 * from the repository's root, each with its two-letter code: changed, staged,
95 * untracked (`??`) or deleted. A rename lists its source as deleted.
96 */
97export function dirtyIn(status: string, root: string): Map<string, string> {
98 const dirty = new Map<string, string>()
99 const entries = status.split('\0')
100 for (let i = 0; i < entries.length; i++) {
101 const entry = entries[i]!
102 if (entry.length < 4) continue
103 const code = entry.slice(0, 2)
104 dirty.set(resolve(root, entry.slice(3)), code)
105 // A rename or copy names its source in the next entry.
106 if (code.includes('R') || code.includes('C')) {
107 if (code[0] === 'R') dirty.set(resolve(root, entries[i + 1] ?? ''), ' D')
108 i++
109 }
110 }
111
112 return dirty
113}
114hooks/designs.ts 60 lines1/* The file rows' small drawings: a document icon tinted by the file's kind. */
2
3export function split(path: string): { name: string; dir: string } {
4 const short = path.replace(/^\/Users\/[^/]+/, '~')
5 const at = short.lastIndexOf('/')
6 return at < 0 ? { name: short, dir: '' } : { name: short.slice(at + 1), dir: short.slice(0, at) || '/' }
7}
8
9/** The path as Claude Code writes it: from the session's folder when inside it. */
10export function relativeTo(cwd: string, path: string): string {
11 const base = cwd.endsWith('/') ? cwd : `${cwd}/`
12 if (cwd !== '' && path.startsWith(base)) return path.slice(base.length)
13 return split(path).dir === '' ? path : path.replace(/^\/Users\/[^/]+/, '~')
14}
15
16const SYSTEM_TINTS: Record<string, string> = {
17 ts: '#0A84FF', tsx: '#0A84FF', js: '#FFD60A', jsx: '#FFD60A', json: '#8E8E93', md: '#5E5CE6',
18 py: '#30D158', css: '#FF375F', html: '#FF9F0A', swift: '#FF9F0A', ipynb: '#FF9F0A',
19}
20
21function tintOf(name: string): string {
22 const at = name.lastIndexOf('.')
23 return (at > 0 && SYSTEM_TINTS[name.slice(at + 1).toLowerCase()]) || '#0A84FF'
24}
25
26export function iconSvg(name: string): string {
27 const tint = tintOf(name)
28 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
29 <rect width="32" height="32" rx="8" fill="${tint}"/>
30 <path d="M10 7 h8 l5 5 v13 a1.5 1.5 0 0 1 -1.5 1.5 h-11.5 a1.5 1.5 0 0 1 -1.5 -1.5 v-16.5 a1.5 1.5 0 0 1 1.5 -1.5 z" fill="#FFFFFF" opacity="0.95"/>
31 <path d="M18 7 v5 h5" fill="none" stroke="${tint}" stroke-width="1.2" opacity="0.7"/>
32 </svg>`
33}
34
35/** A Button key for a path: short, stable, no path characters. */
36export function keyOf(path: string): string {
37 let hash = 5381
38 for (let i = 0; i < path.length; i++) hash = ((hash << 5) + hash + path.charCodeAt(i)) >>> 0
39 return `file-${hash.toString(36)}`
40}
41
42/** GitHub's five squares: green for the share of added lines, red for removed, grey for the rest. */
43export function barSvg(added: number, removed: number): string {
44 const total = added + removed
45 const greens = total === 0 ? 0 : Math.round((5 * added) / total)
46 const reds = total === 0 ? 0 : Math.min(5 - greens, Math.max(removed > 0 ? 1 : 0, Math.round((5 * removed) / total)))
47 const squares = Array.from({ length: 5 }, (_, i) => {
48 const fill = i < greens ? '#30D158' : i < greens + reds ? '#FF453A' : '#48484A'
49 return `<rect x="${i * 11}" y="0" width="9" height="9" rx="2.5" fill="${fill}"/>`
50 }).join('')
51
52 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 53 9" width="53" height="9">${squares}</svg>`
53}
54
55/** Cursor's (VS Code's) source-control letters and their colours: A added, M modified, D deleted. */
56export function badgeOf(file: { isNew: boolean; isDeleted?: boolean }): { letter: 'A' | 'M' | 'D'; color: string; label: string } {
57 if (file.isDeleted) return { letter: 'D', color: '#C74E39', label: 'Supprimé' }
58 return file.isNew ? { letter: 'A', color: '#81B88B', label: 'Ajouté' } : { letter: 'M', color: '#E2C08D', label: 'Modifié' }
59}
60hooks/diff.ts 117 lines1/** A unified diff of two texts, line by line, free of any engine call. */
2
3export type Diff = { source: string; added: number; removed: number }
4
5// Past this many lines on each side of the changed middle, the middle is shown as replaced whole.
6const MOST = 3000
7const CONTEXT = 3
8// What the Code element draws at most.
9const ROOM = 10_000
10
11type Op = { kind: ' ' | '+' | '-'; text: string; a: number; b: number }
12
13const linesOf = (text: string | null) => (text === null || text === '' ? [] : text.replace(/\n$/, '').split('\n'))
14
15/** The edit script between two line lists: common head and tail, then the longest common subsequence of the middle. */
16function opsOf(a: string[], b: string[]): Op[] {
17 let head = 0
18 while (head < a.length && head < b.length && a[head] === b[head]) head++
19 let tail = 0
20 while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
21
22 const ops: Op[] = []
23 for (let i = 0; i < head; i++) ops.push({ kind: ' ', text: a[i] as string, a: i, b: i })
24
25 const midA = a.slice(head, a.length - tail)
26 const midB = b.slice(head, b.length - tail)
27 if (midA.length > MOST || midB.length > MOST) {
28 midA.forEach((text, i) => ops.push({ kind: '-', text, a: head + i, b: head }))
29 midB.forEach((text, j) => ops.push({ kind: '+', text, a: head + midA.length, b: head + j }))
30 } else {
31 const n = midA.length
32 const m = midB.length
33 const table: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1))
34 for (let i = n - 1; i >= 0; i--) {
35 const row = table[i] as Uint32Array
36 const below = table[i + 1] as Uint32Array
37 for (let j = m - 1; j >= 0; j--) row[j] = midA[i] === midB[j] ? (below[j + 1] as number) + 1 : Math.max(below[j] as number, row[j + 1] as number)
38 }
39 let i = 0
40 let j = 0
41 while (i < n || j < m) {
42 if (i < n && j < m && midA[i] === midB[j]) {
43 ops.push({ kind: ' ', text: midA[i] as string, a: head + i, b: head + j })
44 i++
45 j++
46 } else if (i < n && (j === m || (table[i + 1] as Uint32Array)[j]! >= (table[i] as Uint32Array)[j + 1]!)) {
47 // On a tie the removed line goes first, as git writes it.
48 ops.push({ kind: '-', text: midA[i] as string, a: head + i, b: head + j })
49 i++
50 } else {
51 ops.push({ kind: '+', text: midB[j] as string, a: head + i, b: head + j })
52 j++
53 }
54 }
55 }
56
57 for (let k = 0; k < tail; k++) ops.push({ kind: ' ', text: a[a.length - tail + k] as string, a: a.length - tail + k, b: b.length - tail + k })
58
59 return ops
60}
61
62/** The diff of `before` (null for a file that did not exist) to `after`, as unified hunks cut to what the Code element draws. */
63export function diffOf(before: string | null, after: string): Diff {
64 const ops = opsOf(linesOf(before), linesOf(after))
65 const added = ops.filter(op => op.kind === '+').length
66 const removed = ops.filter(op => op.kind === '-').length
67
68 const hunks: Op[][] = []
69 let k = 0
70 while (k < ops.length) {
71 if ((ops[k] as Op).kind === ' ') {
72 k++
73 continue
74 }
75 const start = Math.max(0, k - CONTEXT)
76 let end = k
77 // A hunk runs on while the next change is within two contexts of the last.
78 while (end < ops.length) {
79 let next = end
80 while (next < ops.length && (ops[next] as Op).kind !== ' ') next++
81 let gap = next
82 while (gap < ops.length && (ops[gap] as Op).kind === ' ') gap++
83 end = next
84 if (gap >= ops.length || gap - next > CONTEXT * 2) break
85 end = gap
86 }
87 const stop = Math.min(ops.length, end + CONTEXT)
88 hunks.push(ops.slice(start, stop))
89 k = stop
90 }
91
92 // Whole hunks while they fit; the first that does not is cut to the room
93 // left, its header counting the lines kept, so a long one still shows.
94 let source = ''
95 for (const slice of hunks) {
96 const lines = slice.map(op => `${op.kind}${op.text.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')}`)
97 let kept = lines.length
98 let length = lines.reduce((sum, line) => sum + line.length + 1, 0)
99 const room = ROOM - source.length - 40
100 if (length > room) {
101 length = 0
102 kept = 0
103 while (kept < lines.length && length + (lines[kept] as string).length + 1 <= room) length += (lines[kept++] as string).length + 1
104 if (kept === 0) break
105 }
106 const part = slice.slice(0, kept)
107 const first = part[0] as Op
108 const countA = part.filter(op => op.kind !== '+').length
109 const countB = part.filter(op => op.kind !== '-').length
110 const header = `@@ -${countA ? first.a + 1 : first.a},${countA} +${countB ? first.b + 1 : first.b},${countB} @@`
111 source += (source ? '\n' : '') + [header, ...lines.slice(0, kept)].join('\n')
112 if (kept < lines.length) break
113 }
114
115 return { source, added, removed }
116}
117hooks/row.tsx 36 lines1import type { ClientModule } from 'claude-code'
2
3/** What the pane hands one file's row. */
4export type RowProps = { name: string; dir: string; isOpen: boolean; isDeleted?: boolean }
5
6/**
7 * One file's row on the desktop: its name and where it lives.
8 * A click anywhere on it asks the hooks module to fold or unfold its diff.
9 */
10const Row: ClientModule<RowProps> = (props, surface) => {
11 const { Box, Text } = surface.elements
12 surface.onPointer(event => {
13 if (event.type === 'up' && (event.button ?? 'left') === 'left') surface.post({ toggle: true })
14 })
15
16 return (
17 <Box flexDirection="row" alignItems="center" gap={2} width="100%">
18 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
19 <Text
20 bold
21 strikethrough={props.isDeleted === true}
22 color={props.isOpen ? '#0A84FF' : props.isDeleted ? '#8E8E93' : '#F5F5F7'}
23 wrap="truncate-end"
24 >
25 {props.name}
26 </Text>
27 <Text color="#8E8E93" wrap="truncate-start">
28 {props.dir}
29 </Text>
30 </Box>
31 </Box>
32 )
33}
34
35export default Row
36types/index.d.ts 29 lines1export type ChangedFile = {
2 path: string
3 /** Created since your last message. */
4 isNew: boolean
5 /** Removed since your last message, by a shell command. */
6 isDeleted: boolean
7 added: number
8 removed: number
9 diff: string
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'changed-files': {
15 files: ChangedFile[]
16 /** Each file's text before this turn first changed it; null for a file that did not exist. */
17 originals: Record<string, string | null>
18 /** The file whose diff is unfolded. */
19 selected: string | null
20 /** Whether the terminal docks panes beside the transcript (fullscreen), as /changed-files last saw it. */
21 docks: boolean | null
22 /** The key of the terminal row the pane's focus ring is on. */
23 cursor: string | null
24 /** Whether /changed-files already said that Claude Code's diff panel covers this one. */
25 hinted: boolean
26 }
27 }
28}
29