Fold Edit and Write diffs into one line with a button to show the code

Claude Code prints the full diff under every Edit and Write call. A long session fills the screen with code you did not ask to read.
This mod replaces each diff with one line and a button that opens it:
<img alt="fold-edits: an edit folded to one line with a green show diff button, then opened with hide buttons above and below the diff" src="docs/fold-edits-light.svg">
Click show diff to open the diff. Click hide to fold it again. When the diff is open, a hide button sits at both the top and the bottom, so you do not have to scroll back up on a long diff.
sed -i or a heredoc. Claude Code shows the command's output and then the full diff. The mod keeps the output and folds the diff into one line, for example "Changed 2 files (+3 -1)".A diff longer than 10,000 characters, the limit for one code block, is split into several blocks. Line numbers stay correct across the split.
A plugin cannot set a button's text color, so the mod picks the green behind the text from your theme:
#116329, with light text on top.#aceebb, with dark text on top.If the mod cannot read the theme setting, it uses the dark green.
docs/make_svgs.py draws the two README images, one per theme. GitHub shows the one that matches the reader's theme. Run it again after a change to how the mod looks.
claude plugin test ~/.claude/mods/fold-edits
The tests cover the summary line, opening and closing the diff, both button colors, diff splitting, edit-only groups, shell commands that change files, and leaving other tools alone.
hooks/register.tsx 299 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { BashEditDiff, FileResult, Hunk } from '../types'
5
6const FOLDED_TOOLS = ['Edit', 'Write', 'NotebookEdit']
7// A Code element takes at most 10000 characters; leave room for the hunk header.
8const MAX_CHUNK_CHARS = 9000
9
10// The Button label takes the terminal's own text color, which a plugin cannot
11// set, so the green behind it follows the theme: deep on dark, pale on light.
12const PILL_DARK = '#116329'
13const PILL_LIGHT = '#aceebb'
14
15const isOpen = atom({ plugin: 'fold-edits', key: 'isOpen' } as const, false)
16
17function hunksOf(output: FileResult): Hunk[] {
18 const hunks = output.structuredPatch ?? []
19 if (hunks.length > 0 || output.type !== 'create' || output.content === undefined) {
20 return hunks
21 }
22 const lines = output.content.split('\n').map(line => `+${line}`)
23
24 return [{ oldStart: 0, oldLines: 0, newStart: 1, newLines: lines.length, lines }]
25}
26
27function header(oldStart: number, lines: string[], newStart: number): string {
28 const oldLines = lines.filter(line => line.startsWith(' ') || line.startsWith('-')).length
29 const newLines = lines.filter(line => line.startsWith(' ') || line.startsWith('+')).length
30
31 return `@@ -${oldStart},${oldLines} +${newStart},${newLines} @@`
32}
33
34// Splits the hunks into unified-diff pieces that each fit one Code element,
35// renumbering every piece so its line numbers stay right.
36export function diffChunks(hunks: Hunk[], max = MAX_CHUNK_CHARS): string[] {
37 const chunks: string[] = []
38 let current = ''
39 const push = (text: string) => {
40 if (current !== '' && current.length + 1 + text.length > max) {
41 chunks.push(current)
42 current = ''
43 }
44 current = current === '' ? text : `${current}\n${text}`
45 }
46
47 for (const hunk of hunks) {
48 let oldAt = hunk.oldStart
49 let newAt = hunk.newStart
50 let piece: string[] = []
51 let size = 0
52 let pieceOld = oldAt
53 let pieceNew = newAt
54 const flush = () => {
55 if (piece.length > 0) push([header(pieceOld, piece, pieceNew), ...piece].join('\n'))
56 piece = []
57 size = 0
58 pieceOld = oldAt
59 pieceNew = newAt
60 }
61 for (const raw of hunk.lines) {
62 const line = raw.length > max - 100 ? raw.slice(0, max - 100) : raw
63 if (size + line.length + 1 > max - 100) flush()
64 piece.push(line)
65 size += line.length + 1
66 if (line.startsWith(' ') || line.startsWith('-')) oldAt += 1
67 if (line.startsWith(' ') || line.startsWith('+')) newAt += 1
68 }
69 flush()
70 }
71 if (current !== '') chunks.push(current)
72
73 return chunks
74}
75
76export function summarize(output: FileResult): { verb: string; path: string; added: number; removed: number; chunks: string[] } {
77 const hunks = hunksOf(output)
78 let added = 0
79 let removed = 0
80 for (const hunk of hunks) {
81 for (const line of hunk.lines) {
82 if (line.startsWith('+')) added += 1
83 else if (line.startsWith('-')) removed += 1
84 }
85 }
86
87 return {
88 verb: output.type === 'create' ? 'Created' : 'Updated',
89 path: output.filePath ?? output.notebook_path ?? 'file',
90 added,
91 removed,
92 chunks: diffChunks(hunks),
93 }
94}
95
96// The session's folder, so a Bash diff names its files as the engine does.
97let cwd = ''
98
99function relative(path: string): string {
100 return cwd !== '' && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path
101}
102
103// A shell command that changed files carries their diff in `bashEditDiff`.
104export function bashDiff(output: unknown): BashEditDiff | null {
105 if (typeof output !== 'object' || output === null || !('bashEditDiff' in output)) return null
106 const diff = (output as { bashEditDiff?: BashEditDiff }).bashEditDiff
107 if (!diff || !Array.isArray(diff.files) || diff.files.length === 0) return null
108
109 return diff
110}
111
112export function summarizeBash(diff: BashEditDiff) {
113 return diff.files
114 .map(file => {
115 let added = 0
116 let removed = 0
117 for (const hunk of file.hunks) {
118 for (const line of hunk.lines) {
119 if (line.startsWith('+')) added += 1
120 else if (line.startsWith('-')) removed += 1
121 }
122 }
123 const verb = file.created ? 'Created' : file.deleted ? 'Deleted' : 'Updated'
124
125 return { verb, path: relative(file.filePath), added, removed, chunks: diffChunks(file.hunks) }
126 })
127 .filter(file => file.chunks.length > 0)
128}
129
130async function pillColor($: { config: { list: () => Promise<ReadonlyArray<{ key: string; value: unknown }>> } }): Promise<string> {
131 let theme: unknown
132 try {
133 theme = (await $.config.list()).find(row => row.key === 'theme')?.value
134 } catch {
135 // No settings to read: keep the dark-theme green.
136 }
137
138 return typeof theme === 'string' && theme.startsWith('light') ? PILL_LIGHT : PILL_DARK
139}
140
141function isFoldable(tool: string, isErrored: boolean, output: unknown): output is FileResult {
142 return FOLDED_TOOLS.includes(tool) && !isErrored && typeof output === 'object' && output !== null
143}
144
145export const register: Register = on => {
146 on('session.start', async ($, e, next) => {
147 cwd = e.cwd
148
149 return next(e)
150 })
151
152 // A shell command that wrote files (a script, sed, a heredoc): the engine
153 // draws its output and then the full diff. Keep the output, fold the diff.
154 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
155 const output = e.props.output
156 const diff = e.props.tool === 'Bash' && !e.props.isErrored ? bashDiff(output) : null
157 const files = diff ? summarizeBash(diff) : []
158 if (!diff || files.length === 0) {
159 return next(e)
160 }
161
162 const { bashEditDiff: _diff, ...rest } = output as Record<string, unknown>
163 const base = await next({ ...e, props: { ...e.props, output: rest } })
164
165 const open = memberOf(isOpen, e)
166 const isShown = await read($, open)
167 const green = await pillColor($)
168 const { Box, Button, Code, Text } = $.ui.resolve(e)
169 const toggle = () => update($, open, value => !value)
170 const added = files.reduce((sum, file) => sum + file.added, 0)
171 const removed = files.reduce((sum, file) => sum + file.removed, 0)
172 const what = files.length === 1 ? `${files[0]!.verb} ${files[0]!.path}` : `Changed ${files.length} files`
173 const more = diff.moreFiles > 0 ? `, ${diff.moreFiles} more not shown` : ''
174 const pill = (key: string) => (
175 <Box key={`${key}-pill`} backgroundColor={green} paddingX={1}>
176 <Button key={key} plain label={isShown ? '▾ hide' : '▸ show diff'} onPress={toggle} />
177 </Box>
178 )
179
180 return (
181 <Box flexDirection="column">
182 {base}
183 <Box>
184 <Text dimColor>
185 {what} (+{added} -{removed}{more}){' '}
186 </Text>
187 {pill('toggle')}
188 </Box>
189 {isShown &&
190 files.map(file => (
191 <Box flexDirection="column">
192 {files.length > 1 && (
193 <Text dimColor>
194 {file.verb} {file.path} (+{file.added} -{file.removed})
195 </Text>
196 )}
197 {file.chunks.map(source => (
198 <Code source={source} format="diff" path={file.path} />
199 ))}
200 </Box>
201 ))}
202 {isShown && <Box>{pill('toggle-bottom')}</Box>}
203 </Box>
204 )
205 })
206
207 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
208 const output = e.props.output
209 if (!isFoldable(e.props.tool, e.props.isErrored, output)) {
210 return next(e)
211 }
212
213 const { verb, path, added, removed, chunks } = summarize(output)
214 if (chunks.length === 0) {
215 return next(e)
216 }
217
218 const open = memberOf(isOpen, e)
219 const isShown = await read($, open)
220 const green = await pillColor($)
221 const { Box, Button, Code, Text } = $.ui.resolve(e)
222 const toggle = () => update($, open, value => !value)
223 const pill = (key: string) => (
224 <Box key={`${key}-pill`} backgroundColor={green} paddingX={1}>
225 <Button key={key} plain label={isShown ? '▾ hide' : '▸ show diff'} onPress={toggle} />
226 </Box>
227 )
228
229 return (
230 <Box flexDirection="column">
231 <Box>
232 <Text dimColor>
233 {verb} {path} (+{added} -{removed}){' '}
234 </Text>
235 {pill('toggle')}
236 </Box>
237 {isShown && chunks.map(source => <Code source={source} format="diff" path={path} />)}
238 {isShown && <Box>{pill('toggle-bottom')}</Box>}
239 </Box>
240 )
241 })
242
243 // The engine folds some edits (scratchpad ones) into one group line with no
244 // way to see the code; give an edits-only group the same pill.
245 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
246 const { calls } = e.props
247 const isEditsOnly =
248 !e.props.isExpanded &&
249 calls.length > 0 &&
250 calls.every(call => !call.isRunning && isFoldable(call.tool, call.isErrored, call.output))
251 if (!isEditsOnly) {
252 return next(e)
253 }
254
255 const files = calls.map(call => summarize(call.output as FileResult)).filter(file => file.chunks.length > 0)
256 if (files.length === 0) {
257 return next(e)
258 }
259
260 const open = memberOf(isOpen, e)
261 const isShown = await read($, open)
262 const green = await pillColor($)
263 const { Box, Button, Code, Text } = $.ui.resolve(e)
264 const toggle = () => update($, open, value => !value)
265 const added = files.reduce((sum, file) => sum + file.added, 0)
266 const removed = files.reduce((sum, file) => sum + file.removed, 0)
267 const pill = (key: string) => (
268 <Box key={`${key}-pill`} backgroundColor={green} paddingX={1}>
269 <Button key={key} plain label={isShown ? '▾ hide' : '▸ show diff'} onPress={toggle} />
270 </Box>
271 )
272
273 return (
274 <Box flexDirection="column">
275 <Box>
276 <Text dimColor>
277 Edited {files.length === 1 ? files[0]!.path : `${files.length} files`} (+{added} -{removed}){' '}
278 </Text>
279 {pill('toggle')}
280 </Box>
281 {isShown &&
282 files.map(file => (
283 <Box flexDirection="column">
284 {files.length > 1 && (
285 <Text dimColor>
286 {file.verb} {file.path} (+{file.added} -{file.removed})
287 </Text>
288 )}
289 {file.chunks.map(source => (
290 <Code source={source} format="diff" path={file.path} />
291 ))}
292 </Box>
293 ))}
294 {isShown && <Box>{pill('toggle-bottom')}</Box>}
295 </Box>
296 )
297 })
298}
299types/index.d.ts 28 lines1export type Hunk = {
2 oldStart: number
3 oldLines: number
4 newStart: number
5 newLines: number
6 lines: string[]
7}
8
9export type FileResult = {
10 filePath?: string
11 notebook_path?: string
12 type?: 'create' | 'update'
13 content?: string
14 structuredPatch?: Hunk[]
15}
16
17/** The part of a Bash result that holds the files the command changed. */
18export type BashEditDiff = {
19 files: { filePath: string; hunks: Hunk[]; created?: true; deleted?: true }[]
20 moreFiles: number
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 'fold-edits': { isOpen: StateFamily<boolean> }
26 }
27}
28