SLOPSHOPPER

cc-split-diff-view

Draws Edit and Write diffs in two side-by-side columns: removed lines on the left, added lines on the right, in Claude Code's own diff colors

newrows
★ 3v0.1.0Apache-2.0updated 2026-10-08neteye-platform/cc-split-diff-view
A shopper browsing a rack in a slop shop
README

Split diff view for Claude Code

A Claude Code mod that draws the diff of every Edit and Write in two side-by-side columns instead of one stacked -/+ list:

  • removed lines on the left, added lines on the right
  • each side keeps its real line numbers
  • a changed line sits next to the line that replaced it
  • long runs between hunks collapse to ⋯ N unchanged lines
  • colors are Claude Code's own diff colors (diffRemoved / diffAdded theme keys), so they follow your /theme
  • syntax highlighting comes from Claude Code's own highlighter (the Code element, the one the built-in /diff pane uses), with the language taken from the file's extension

Errors, edits held for review, brand-new files and anything without a diff keep Claude Code's normal drawing.

Requirements

  • Claude Code v2.1.287 or later (the version that introduced mods; they are on by default). Check with claude --version.
  • Mods must be allowed to load: not started with --safe-mode or --bare, and not blocked by your organization's managed settings.

Install

From a terminal:

claude plugin marketplace add neteye-platform/cc-split-diff-view
claude plugin install cc-split-diff-view@neteye-platform

Or from inside a Claude Code session:

/plugin marketplace add neteye-platform/cc-split-diff-view
/plugin install cc-split-diff-view@neteye-platform

No download or build step is needed: Claude Code fetches the repository and loads the TypeScript directly. Start a new session (or run /reload-plugins), then ask Claude to edit a file.

To check it loaded, run /plugin and look for cc-split-diff-view under Installed.

Update

Claude Code caches an installed plugin by version, so updates arrive when the version in .claude-plugin/plugin.json is bumped:

claude plugin marketplace update neteye-platform

Uninstall

claude plugin uninstall cc-split-diff-view@neteye-platform

You can also turn it off without removing it from the Installed tab of /plugin.

Try it for one session without installing

git clone https://github.com/neteye-platform/cc-split-diff-view.git
claude --plugin-dir ./cc-split-diff-view

The mod hot-reloads when you save a file in that directory.

Limitations

  • Only Edit and Write are redrawn. The permission prompt is not a render site, so a mod cannot change it.
  • Each line is highlighted on its own, so something that spans lines (a block comment or a template string) can be colored as plain code when only part of it is in the diff.
  • Hooks stay active in the VS Code extension, claude -p and cloud sessions, but the interface part only draws in the terminal and the Desktop app's Code tab.
  • Mod events and render-site props can change between Claude Code releases. If the diff suddenly looks like the default again, run claude plugin validate . and check the debug log.

Credits

Developed by Oscar Zambotti (@oskarnrk) in the NetEye Platform development team, with Claude Code.

Source 2 files
hooks/register.ts 190 lines
1import type { ElementTable, On } from 'claude-code'
2
3import { splitRows } from './split-rows.js'
4import type { Cell, SplitRow } from './split-rows.js'
5
6/** Tools whose result is a diff this mod redraws. */
7const TOOLS = ['Edit', 'Write']
8
9/** Rows drawn before "… N more rows". */
10const MAX_ROWS = 200
11
12type Kit = Pick<ElementTable<'terminal'>, 'Box' | 'Text' | 'Code'>
13
14const isRecord = (value: unknown): value is Record<string, unknown> =>
15  typeof value === 'object' && value !== null
16
17/**
18 * Draws the result block of an Edit or Write call as a split diff: removed
19 * lines in the left column and added lines in the right, with line numbers.
20 *
21 * @param on the engine's registrar
22 */
23export function register(on: On) {
24  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
25    const { tool, output, isErrored } = e.props
26    if (!TOOLS.includes(tool) || isErrored || !isRecord(output)) return next(e)
27    // An edit held for review wrote nothing, so there is no diff to show.
28    if (output.staged === true) return next(e)
29
30    const diff = splitRows(output.structuredPatch)
31    // No hunks (a new file, an identical write): leave Claude Code's drawing alone.
32    if (diff === null) return next(e)
33
34    const kit: Kit = $.ui.resolve(e)
35    const { Box, Text } = kit
36    const path = typeof output.filePath === 'string' ? output.filePath : undefined
37
38    const shown = diff.rows.slice(0, MAX_ROWS)
39    const hidden = diff.rows.length - shown.length
40
41    return Box({
42      flexDirection: 'column',
43      children: [
44        // The built-in summary line, with its response marker.
45        Box({
46          flexDirection: 'row',
47          children: [
48            Text({ dimColor: true, children: ['  ⎿  '] }),
49            ...summary(Text, diff.added, diff.removed),
50          ],
51        }),
52        // The rows sit in a frame under the summary text, as the built-in diff does.
53        Box({
54          flexDirection: 'column',
55          marginLeft: 5,
56          borderStyle: 'round',
57          borderDimColor: true,
58          children: [
59            ...shown.map(row => drawRow(kit, row, path)),
60            hidden > 0 ? Text({ dimColor: true, children: ['… ' + hidden + ' more rows'] }) : null,
61          ],
62        }),
63      ],
64    })
65  })
66}
67
68function drawRow(kit: Kit, row: SplitRow, path: string | undefined) {
69  const { Box, Text } = kit
70  if (row.kind === 'gap') {
71    // The label sits in both columns, so a wide screen doesn't leave the right one blank.
72    const label = () => Text({ dimColor: true, children: ['⋯ ' + row.hidden + ' unchanged lines'] })
73    // A blank line above and below sets the label apart; an empty 1-wide Box takes the separator's place so the columns line up.
74    const column = { width: '50%', flexShrink: 1, paddingY: 1 } as const
75    return Box({
76      flexDirection: 'row',
77      children: [Box({ ...column, children: [label()] }), Box({ width: 1, flexShrink: 0 }), Box({ ...column, children: [label()] })],
78    })
79  }
80  if (row.changed && row.left !== null && row.right !== null) {
81    return drawPair(kit, row.left, row.right, path)
82  }
83  return Box({
84    flexDirection: 'row',
85    alignItems: 'stretch',
86    children: [
87      drawCell(kit, row.left, row.changed ? 'removed' : 'same', path),
88      separator(kit),
89      drawCell(kit, row.right, row.changed ? 'added' : 'same', path),
90    ],
91  })
92}
93
94function drawCell(
95  { Box, Text, Code }: Kit,
96  cell: Cell | null,
97  kind: 'removed' | 'added' | 'same',
98  path: string | undefined,
99) {
100  const column = { width: '50%', flexShrink: 1, flexDirection: 'row' } as const
101
102  // Nothing on this side of the row: keep the column, draw nothing in it.
103  if (cell === null) return Box({ ...column, children: [Text({ children: [' '] })] })
104
105  // One-line hunk through the engine's diff renderer, so gutter, marker and
106  // add/remove colors are exactly the built-in diff's.
107  const n = cell.no
108  const header =
109    kind === 'removed' ? `@@ -${n},1 +${n},0 @@` : kind === 'added' ? `@@ -${n},0 +${n},1 @@` : `@@ -${n},1 +${n},1 @@`
110  const sign = kind === 'removed' ? '-' : kind === 'added' ? '+' : ' '
111
112  return Box({
113    ...column,
114    children: [
115      Code({
116        source: header + '\n' + sign + cell.text,
117        format: 'diff',
118        wrap: 'wrap',
119        ...(path === undefined ? {} : { path }),
120      }),
121    ],
122  })
123}
124
125/**
126 * A removed line next to the line that replaced it. The engine draws the two as
127 * one hunk, which is what makes it tint the words that changed; each column
128 * then shows its own line of that hunk. Long lines are cut with an ellipsis, as
129 * a column clipped to one row cannot wrap.
130 */
131function drawPair(kit: Kit, left: Cell, right: Cell, path: string | undefined) {
132  const { Box, Code } = kit
133  const hunk = () =>
134    Code({
135      source: `@@ -${left.no},1 +${right.no},1 @@\n-${left.text}\n+${right.text}`,
136      format: 'diff',
137      wrap: 'truncate-end',
138      ...(path === undefined ? {} : { path }),
139    })
140  const column = { width: '50%', flexShrink: 1, height: 1, overflow: 'hidden' } as const
141  return Box({
142    flexDirection: 'row',
143    children: [
144      Box({ ...column, children: [hunk()] }),
145      separator(kit),
146      Box({
147        ...column,
148        children: [Box({ position: 'absolute', top: -1, left: 0, right: 0, children: [hunk()] })],
149      }),
150    ],
151  })
152}
153
154/**
155 * A dim vertical rule between the columns. It stretches to the row's height
156 * (wrapped lines make rows taller), so it draws a tall line out of flow and
157 * lets the clipped box cut it to the row.
158 */
159function separator({ Box, Text }: Kit) {
160  return Box({
161    width: 1,
162    flexShrink: 0,
163    overflow: 'hidden',
164    children: [
165      Box({
166        position: 'absolute',
167        top: 0,
168        bottom: 0,
169        flexDirection: 'column',
170        children: Array.from({ length: 20 }, () => Text({ dimColor: true, children: ['│'] })),
171      }),
172    ],
173  })
174}
175
176/** "Added 3 lines, removed 1 line", the counts bold, as the built-in diff words it. */
177function summary(Text: Kit['Text'], added: number, removed: number) {
178  const noun = (n: number) => (n > 1 ? ' lines' : ' line')
179  const parts: ReturnType<Kit['Text']>[] = []
180  if (added > 0) {
181    parts.push(Text({ children: ['Added '] }), Text({ bold: true, children: [String(added)] }))
182    parts.push(Text({ children: [noun(added) + (removed > 0 ? ', ' : '')] }))
183  }
184  if (removed > 0) {
185    parts.push(Text({ children: [added === 0 ? 'Removed ' : 'removed '] }))
186    parts.push(Text({ bold: true, children: [String(removed)] }), Text({ children: [noun(removed)] }))
187  }
188  return parts
189}
190
hooks/split-rows.ts 141 lines
1/** One side of a split row: the line's number in its file and its text. */
2export type Cell = { no: number; text: string }
3
4export type SplitRow =
5  | {
6      kind: 'row'
7      /** The removed (or unchanged) line; null when this row only adds. */
8      left: Cell | null
9      /** The added (or unchanged) line; null when this row only removes. */
10      right: Cell | null
11      changed: boolean
12    }
13  | { kind: 'gap'; hidden: number }
14
15export type SplitDiff = {
16  rows: SplitRow[]
17  added: number
18  removed: number
19  /** Digits of the largest line number, for the number gutters. */
20  numberWidth: number
21}
22
23type Hunk = { oldStart: number; newStart: number; lines: string[] }
24
25const TAB = '    '
26
27/** The most characters the engine takes in one drawn leaf. */
28const MAX_TEXT = 10_000
29
30/**
31 * A line made safe to draw: tabs laid out as spaces, every control, format and
32 * invisible character dropped (a stray '\r' from a CRLF file would otherwise
33 * get the whole tree refused), and cut to the engine's cap.
34 */
35const drawable = (text: string) =>
36  text
37    .replace(/\t/g, TAB)
38    .replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '')
39    .slice(0, MAX_TEXT)
40
41const isRecord = (value: unknown): value is Record<string, unknown> =>
42  typeof value === 'object' && value !== null
43
44/** A tool result's `structuredPatch`, keeping only entries shaped like a hunk. */
45const hunksOf = (patch: unknown): Hunk[] =>
46  (Array.isArray(patch) ? patch : []).flatMap((hunk: unknown): Hunk[] => {
47    if (!isRecord(hunk)) return []
48    const { oldStart, newStart, lines } = hunk
49    if (typeof oldStart !== 'number' || typeof newStart !== 'number') return []
50    if (!Array.isArray(lines)) return []
51    return [
52      {
53        oldStart,
54        newStart,
55        lines: lines.filter((line): line is string => typeof line === 'string'),
56      },
57    ]
58  })
59
60/**
61 * Turns a tool result's `structuredPatch` (unified hunks whose lines start with
62 * ' ', '-' or '+') into side-by-side rows. Each run of removed lines is paired
63 * with the added lines that follow it. A side that is null has no line in that
64 * row; a cell with empty text is a real blank line.
65 *
66 * @returns null when the patch holds no hunks, or when one side lacks a
67 * trailing newline the other has (the default renderer shows that marker)
68 */
69export function splitRows(patch: unknown): SplitDiff | null {
70  const hunks = hunksOf(patch)
71  if (hunks.length === 0) return null
72
73  const rows: SplitRow[] = []
74  let added = 0
75  let removed = 0
76  let largest = 0
77  let previousOldEnd: number | null = null
78
79  for (const hunk of hunks) {
80    if (previousOldEnd !== null) {
81      const hidden = hunk.oldStart - previousOldEnd
82      if (hidden > 0) rows.push({ kind: 'gap', hidden })
83    }
84
85    let oldNo = hunk.oldStart
86    let newNo = hunk.newStart
87    let dels: Cell[] = []
88    let adds: Cell[] = []
89    let previous = ''
90    let oldLacksNewline = false
91    let newLacksNewline = false
92    const flush = () => {
93      const count = Math.max(dels.length, adds.length)
94      for (let i = 0; i < count; i++) {
95        rows.push({
96          kind: 'row',
97          left: dels[i] ?? null,
98          right: adds[i] ?? null,
99          changed: true,
100        })
101      }
102      dels = []
103      adds = []
104    }
105
106    for (const line of hunk.lines) {
107      const mark = line.charAt(0)
108      const text = drawable(line.slice(1))
109      if (mark === '\\') {
110        // "\ No newline at end of file" refers to the line before it
111        if (previous !== '+') oldLacksNewline = true
112        if (previous !== '-') newLacksNewline = true
113        continue
114      }
115      previous = mark
116      if (mark === '-') {
117        dels.push({ no: oldNo++, text })
118        removed++
119      } else if (mark === '+') {
120        adds.push({ no: newNo++, text })
121        added++
122      } else {
123        flush()
124        rows.push({
125          kind: 'row',
126          left: { no: oldNo++, text },
127          right: { no: newNo++, text },
128          changed: false,
129        })
130      }
131    }
132    flush()
133    if (oldLacksNewline !== newLacksNewline) return null
134
135    previousOldEnd = oldNo
136    largest = Math.max(largest, oldNo, newNo)
137  }
138
139  return { rows, added, removed, numberWidth: String(largest).length }
140}
141