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
4 changes: 2 additions & 2 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "cc-split-diff-view",
"version": "1.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. Adds /diffsplit, a pane that reviews all the uncommitted changes the same way",
"version": "1.2.0",
"description": "Draws Edit, Write and Bash file-update diffs in two side-by-side columns: removed lines on the left, added lines on the right, in Claude Code's own diff colors. Adds /diffsplit, a pane that reviews all the uncommitted changes the same way",
"types": "./types/index.d.ts",
"author": { "name": "oskarnrk", "url": "https://github.com/oskarnrk" },
"repository": "https://github.com/neteye-platform/cc-split-diff-view"
Expand Down
25 changes: 20 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,27 @@

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:
instead of one stacked `-`/`+` list. The files a `Bash` command updates (`sed -i`,
a formatter, `>>`) get the same drawing:

![Split diff of an Edit: removed lines on the left, added lines on the right, each with its own line numbers](docs/split-diff-view.png)

- 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
- a changed line sits next to the line that replaced it, with the words that
changed tinted; a pair too long for one row wraps instead (see
[Limitations](#limitations))
- 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), 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.
keep Claude Code's normal drawing. For `Bash`, the command's output keeps the
normal drawing too, and each updated file adds an `Updated path (+n -n)` row
with its split diff under it; created and deleted files stay as Claude Code
draws them.

## `/diffsplit`

Expand Down Expand Up @@ -101,8 +107,17 @@ 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.
- Only `Edit`, `Write` and the file updates of `Bash` are redrawn. The
permission prompt is not a render site, so a mod cannot change it.
- The `Bash` diff reads `bashEditDiff`, an internal field of the Bash result
(Claude Code's diff of the working tree before and after the command). It can
change in any release. When Claude Code could not tell what changed, the
default drawing stays.
- A changed line next to its replacement keeps the word tint only when both
lines fit on one row of their column. A longer pair wraps over several rows,
in the plain red and green of a removed and an added line. The mod estimates
the width from the size Claude Code reports for the screen (or the pane); when
that size is unknown, every changed pair wraps.
- 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.
Expand Down
Binary file modified docs/split-diff-view.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
145 changes: 145 additions & 0 deletions hooks/bash-diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import type { Hunk } from '../types'
import { TEXT_BUDGET, drawFit, fitHunks } from './draw.js'
import type { Kit } from './draw.js'

/** Rows drawn for all the files of one Bash result. */
const MAX_ROWS = 200

/** Files drawn for one Bash result; the others are counted. */
const MAX_FILES = 20

/** Characters kept for what the engine draws around the diffs (the command's output). */
const MARGIN = 5_000

/** Where the built-in draws the "Created" and "Updated" rows: under the response marker. */
const INDENT = 5

/** A file a Bash command updated, with the hunks Claude Code found. */
export type UpdatedFile = { path: string; hunks: Hunk[] }

/** What is left of a Bash result once its updated files are taken out. */
export type BashSplit = {
updated: UpdatedFile[]
/** The result without those files; the built-in draws what is left. */
rest: Record<string, unknown>
/** Updated files past the limit of files, plus the ones Claude Code did not list. */
hiddenFiles: number
}

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

const hunksOf = (value: unknown): Hunk[] =>
(Array.isArray(value) ? value : []).flatMap((hunk: unknown): Hunk[] => {
if (!isRecord(hunk)) return []
const { oldStart, newStart, lines } = hunk
if (typeof oldStart !== 'number' || typeof newStart !== 'number' || !Array.isArray(lines)) return []
return [{ oldStart, newStart, lines: lines.filter((line): line is string => typeof line === 'string') }]
})

/**
* Takes the files a Bash command updated out of its result. `bashEditDiff` is
* Claude Code's per-file diff of the working tree before and after the command
* (an internal field: it can change between releases).
*
* Created and deleted files stay in the result for the built-in to draw. So do
* all the files when Claude Code could not tell what changed (`unavailable`,
* `skipped`) or when another process may have changed them too (`shared`).
*
* @returns null when there is nothing to take out
*/
export function takeUpdatedFiles(output: unknown): BashSplit | null {
if (!isRecord(output)) return null
const diff = output.bashEditDiff
if (!isRecord(diff) || !Array.isArray(diff.files)) return null
if (diff.unavailable === true || diff.skipped === true || diff.shared === true) return null

const updated: UpdatedFile[] = []
const others: unknown[] = []
for (const file of diff.files) {
const hunks = isRecord(file) ? hunksOf(file.hunks) : []
if (isRecord(file) && typeof file.filePath === 'string' && file.created !== true && file.deleted !== true && hunks.length > 0) {
updated.push({ path: file.filePath, hunks })
} else {
others.push(file)
}
}
if (updated.length === 0) return null

const { bashEditDiff: _, ...rest } = output
const moreFiles = typeof diff.moreFiles === 'number' ? diff.moreFiles : 0
// Nothing left for the built-in to draw: leave the field out.
if (others.length > 0 || moreFiles > 0) rest.bashEditDiff = { ...diff, files: others }

return { updated: updated.slice(0, MAX_FILES), rest, hiddenFiles: Math.max(0, updated.length - MAX_FILES) + moreFiles }
}

/** True when the built-in has nothing of its own to draw for this result. */
export function isSilent(rest: Record<string, unknown>): boolean {
const empty = (value: unknown) => typeof value !== 'string' || value.trim() === ''
return (
empty(rest.stdout) &&
empty(rest.stderr) &&
rest.bashEditDiff === undefined &&
rest.interrupted !== true &&
rest.backgroundTaskId === undefined &&
rest.structuredContent === undefined
)
}

/** The path as a person reads it: from the working directory when it is inside. */
export function displayPath(path: string, cwd: string | undefined): string {
if (cwd === undefined || cwd === '' || cwd === '/') return path
return path.startsWith(cwd + '/') ? path.slice(cwd.length + 1) : path
}

const count = (hunks: readonly Hunk[], mark: '+' | '-') =>
hunks.reduce((total, hunk) => total + hunk.lines.filter(line => line.startsWith(mark)).length, 0)

/**
* The updated files of a Bash result, one after the other: an "Updated path
* (+3 -1)" row, then the diff in two columns. `columns` is the width of the
* surface, when known; the diff sits under the indent.
*/
export function drawUpdatedFiles(kit: Kit, split: BashSplit, cwd: string | undefined, columns?: number) {
const { Box, Text } = kit
let rowsLeft = MAX_ROWS
// The command's own output is part of the tree the engine draws, so it comes off the budget too.
const output = [split.rest.stdout, split.rest.stderr].reduce<number>(
(total, text) => total + (typeof text === 'string' ? text.length : 0),
0,
)
let charsLeft = Math.max(0, TEXT_BUDGET - MARGIN - output)

const files = split.updated.map(file => {
const fit = fitHunks(file.hunks, rowsLeft, charsLeft)
rowsLeft -= fit.count
charsLeft -= fit.chars
return Box({
flexDirection: 'column',
children: [
Box({
flexDirection: 'row',
marginLeft: INDENT,
children: [
Text({ children: ['Updated '] }),
Text({ bold: true, children: [displayPath(file.path, cwd)] }),
Text({ dimColor: true, children: [` (+${count(file.hunks, '+')} -${count(file.hunks, '-')})`] }),
],
}),
Box({ marginLeft: INDENT, flexDirection: 'column', children: [drawFit(kit, fit, file.path, columns === undefined ? undefined : columns - INDENT)] }),
],
})
})

return {
files,
hidden:
split.hiddenFiles > 0
? Text({
dimColor: true,
children: [' … ' + split.hiddenFiles + (split.hiddenFiles === 1 ? ' more file' : ' more files')],
})
: null,
}
}
6 changes: 3 additions & 3 deletions hooks/diff-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ function drawFile($: Engine, kit: Kit, file: FileDiff, isFolded: boolean, fit: F
rule(),
fileRow(kit, file, { key: 'file:' + file.path, label: (isFolded ? '▸ ' : '▾ ') + file.path, onPress: toggle }),
rule(),
isFolded ? null : body(kit, file, fit),
isFolded ? null : body(kit, file, fit, columns),
],
})
}
Expand All @@ -303,13 +303,13 @@ function counts(Text: Kit['Text'], file: FileDiff) {
]
}

function body(kit: Kit, file: FileDiff, fit: Fit | null) {
function body(kit: Kit, file: FileDiff, fit: Fit | null, columns: number) {
const { Text } = kit
if (file.isBinary) return Text({ dimColor: true, children: ['Binary file.'] })
if (fit === null) {
const empty = file.status === 'deleted' ? 'Empty file deleted.' : 'No changes to show.'
return Text({ dimColor: true, children: [empty] })
}

return drawFit(kit, fit, file.path)
return drawFit(kit, fit, file.path, columns)
}
Loading
Loading