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

A Claude Code mod that draws the diff of every Edit and Write in two side-by-side columns instead of one stacked -/+ list:
⋯ N unchanged linesdiffRemoved / diffAdded theme keys), so they follow your /themeCode element, the one the built-in /diff pane uses), with the language taken from the file's extensionErrors, edits held for review, brand-new files and anything without a diff keep Claude Code's normal drawing.
claude --version.--safe-mode or --bare, and not blocked by your organization's managed settings.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.
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
claude plugin uninstall cc-split-diff-view@neteye-platform
You can also turn it off without removing it from the Installed tab of /plugin.
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.
Edit and Write are redrawn. The permission prompt is not a render site, so a mod cannot change it.claude -p and cloud sessions, but the interface part only draws in the terminal and the Desktop app's Code tab.claude plugin validate . and check the debug log.Developed by Oscar Zambotti (@oskarnrk) in the NetEye Platform development team, with Claude Code.
hooks/register.ts 190 lines1import 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}
190hooks/split-rows.ts 141 lines1/** 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