Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "neteye-platform",
"description": "Claude Code plugins and mods by NetEye Platform developers",
"owner": {
"name": "NetEye Platform",
"url": "https://github.com/neteye-platform"
},
"plugins": [
{
"name": "cc-split-diff-view",
"source": "./",
"description": "Edit and Write diffs in two side-by-side columns instead of +/- lines"
}
]
}
7 changes: 7 additions & 0 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "cc-split-diff-view",
"version": "0.1.0",
"description": "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",
"author": { "name": "oskarnrk", "url": "https://github.com/oskarnrk" },
"repository": "https://github.com/neteye-platform/cc-split-diff-view"
}
23 changes: 23 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Tests

on:
pull_request:
branches:
- "**"

permissions:
contents: read

jobs:
tests:
name: Tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Install Claude Code
run: npm install -g @anthropic-ai/claude-code

- name: Run tests
run: claude plugin test
94 changes: 94 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Split diff view for Claude Code

A [Claude Code mod](https://code.claude.com/docs/en/plugins/mods/overview) 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:

```bash
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:

```text
/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:

```bash
claude plugin marketplace update neteye-platform
```

## Uninstall

```bash
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

```bash
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](https://github.com/oskarnrk)) in the
NetEye Platform development team, with Claude Code.
4 changes: 4 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"description": "Split diff view: Edit and Write diffs drawn in two columns",
"modules": ["./register.ts"]
}
189 changes: 189 additions & 0 deletions hooks/register.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import type { ElementTable, On } from 'claude-code'

import { splitRows } from './split-rows.js'
import type { Cell, SplitRow } from './split-rows.js'

/** Tools whose result is a diff this mod redraws. */
const TOOLS = ['Edit', 'Write']

/** Rows drawn before "… N more rows". */
const MAX_ROWS = 200

type Kit = Pick<ElementTable<'terminal'>, 'Box' | 'Text' | 'Code'>

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null

/**
* Draws the result block of an Edit or Write call as a split diff: removed
* lines in the left column and added lines in the right, with line numbers.
*
* @param on the engine's registrar
*/
export function register(on: On) {
on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
const { tool, output, isErrored } = e.props
if (!TOOLS.includes(tool) || isErrored || !isRecord(output)) return next(e)
// An edit held for review wrote nothing, so there is no diff to show.
if (output.staged === true) return next(e)

const diff = splitRows(output.structuredPatch)
// No hunks (a new file, an identical write): leave Claude Code's drawing alone.
if (diff === null) return next(e)

const kit: Kit = $.ui.resolve(e)
const { Box, Text } = kit
const path = typeof output.filePath === 'string' ? output.filePath : undefined

const shown = diff.rows.slice(0, MAX_ROWS)
const hidden = diff.rows.length - shown.length

return Box({
flexDirection: 'column',
children: [
// The built-in summary line, with its response marker.
Box({
flexDirection: 'row',
children: [
Text({ dimColor: true, children: [' ⎿ '] }),
...summary(Text, diff.added, diff.removed),
],
}),
// The rows sit in a frame under the summary text, as the built-in diff does.
Box({
flexDirection: 'column',
marginLeft: 5,
borderStyle: 'round',
borderDimColor: true,
children: [
...shown.map(row => drawRow(kit, row, path)),
hidden > 0 ? Text({ dimColor: true, children: ['… ' + hidden + ' more rows'] }) : null,
],
}),
],
})
})
}

function drawRow(kit: Kit, row: SplitRow, path: string | undefined) {
const { Box, Text } = kit
if (row.kind === 'gap') {
// The label sits in both columns, so a wide screen doesn't leave the right one blank.
const label = () => Text({ dimColor: true, children: ['⋯ ' + row.hidden + ' unchanged lines'] })
// 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.
const column = { width: '50%', flexShrink: 1, paddingY: 1 } as const
return Box({
flexDirection: 'row',
children: [Box({ ...column, children: [label()] }), Box({ width: 1, flexShrink: 0 }), Box({ ...column, children: [label()] })],
})
}
if (row.changed && row.left !== null && row.right !== null) {
return drawPair(kit, row.left, row.right, path)
}
return Box({
flexDirection: 'row',
alignItems: 'stretch',
children: [
drawCell(kit, row.left, row.changed ? 'removed' : 'same', path),
separator(kit),
drawCell(kit, row.right, row.changed ? 'added' : 'same', path),
],
})
}

function drawCell(
{ Box, Text, Code }: Kit,
cell: Cell | null,
kind: 'removed' | 'added' | 'same',
path: string | undefined,
) {
const column = { width: '50%', flexShrink: 1, flexDirection: 'row' } as const

// Nothing on this side of the row: keep the column, draw nothing in it.
if (cell === null) return Box({ ...column, children: [Text({ children: [' '] })] })

// One-line hunk through the engine's diff renderer, so gutter, marker and
// add/remove colors are exactly the built-in diff's.
const n = cell.no
const header =
kind === 'removed' ? `@@ -${n},1 +${n},0 @@` : kind === 'added' ? `@@ -${n},0 +${n},1 @@` : `@@ -${n},1 +${n},1 @@`
const sign = kind === 'removed' ? '-' : kind === 'added' ? '+' : ' '

return Box({
...column,
children: [
Code({
source: header + '\n' + sign + cell.text,
format: 'diff',
wrap: 'wrap',
...(path === undefined ? {} : { path }),
}),
],
})
}

/**
* A removed line next to the line that replaced it. The engine draws the two as
* one hunk, which is what makes it tint the words that changed; each column
* then shows its own line of that hunk. Long lines are cut with an ellipsis, as
* a column clipped to one row cannot wrap.
*/
function drawPair(kit: Kit, left: Cell, right: Cell, path: string | undefined) {
const { Box, Code } = kit
const hunk = () =>
Code({
source: `@@ -${left.no},1 +${right.no},1 @@\n-${left.text}\n+${right.text}`,
format: 'diff',
wrap: 'truncate-end',
...(path === undefined ? {} : { path }),
})
const column = { width: '50%', flexShrink: 1, height: 1, overflow: 'hidden' } as const
return Box({
flexDirection: 'row',
children: [
Box({ ...column, children: [hunk()] }),
separator(kit),
Box({
...column,
children: [Box({ position: 'absolute', top: -1, left: 0, right: 0, children: [hunk()] })],
}),
],
})
}

/**
* A dim vertical rule between the columns. It stretches to the row's height
* (wrapped lines make rows taller), so it draws a tall line out of flow and
* lets the clipped box cut it to the row.
*/
function separator({ Box, Text }: Kit) {
return Box({
width: 1,
flexShrink: 0,
overflow: 'hidden',
children: [
Box({
position: 'absolute',
top: 0,
bottom: 0,
flexDirection: 'column',
children: Array.from({ length: 20 }, () => Text({ dimColor: true, children: ['│'] })),
}),
],
})
}

/** "Added 3 lines, removed 1 line", the counts bold, as the built-in diff words it. */
function summary(Text: Kit['Text'], added: number, removed: number) {
const noun = (n: number) => (n > 1 ? ' lines' : ' line')
const parts: ReturnType<Kit['Text']>[] = []
if (added > 0) {
parts.push(Text({ children: ['Added '] }), Text({ bold: true, children: [String(added)] }))
parts.push(Text({ children: [noun(added) + (removed > 0 ? ', ' : '')] }))
}
if (removed > 0) {
parts.push(Text({ children: [added === 0 ? 'Removed ' : 'removed '] }))
parts.push(Text({ bold: true, children: [String(removed)] }), Text({ children: [noun(removed)] }))
}
return parts
}
Loading
Loading