SLOPSHOPPER

fold-edits

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

newrows
v0.1.0no licenseupdated 2026-10-06Tatendaz/T-mods/fold-edits
A shopper browsing a rack in a slop shop
README

fold-edits

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.

What it changes

  • Edit, Write and NotebookEdit results. A failed edit is left as Claude Code draws it.
  • Groups that hold only edits, such as Claude Code's "Made 1 scratchpad edit" line. A group that mixes edits with reads or shell commands is left alone.
  • Shell commands that change files, such as a script, 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.

Button color

A plugin cannot set a button's text color, so the mod picks the green behind the text from your theme:

  • Dark themes get #116329, with light text on top.
  • Light themes get #aceebb, with dark text on top.

If the mod cannot read the theme setting, it uses the dark green.

Images

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.

Tests

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.

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