diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b9942bf..4673471 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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" diff --git a/README.md b/README.md index 5e0c9bb..afc8260 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,16 @@ 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` @@ -19,7 +22,10 @@ instead of one stacked `-`/`+` list: 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` @@ -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. diff --git a/docs/split-diff-view.png b/docs/split-diff-view.png index a7c0b10..f37e0c8 100644 Binary files a/docs/split-diff-view.png and b/docs/split-diff-view.png differ diff --git a/hooks/bash-diff.ts b/hooks/bash-diff.ts new file mode 100644 index 0000000..d53696e --- /dev/null +++ b/hooks/bash-diff.ts @@ -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 + /** Updated files past the limit of files, plus the ones Claude Code did not list. */ + hiddenFiles: number +} + +const isRecord = (value: unknown): value is Record => + 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): 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( + (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, + } +} diff --git a/hooks/diff-command.ts b/hooks/diff-command.ts index a14dcb6..e6259c1 100644 --- a/hooks/diff-command.ts +++ b/hooks/diff-command.ts @@ -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), ], }) } @@ -303,7 +303,7 @@ 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) { @@ -311,5 +311,5 @@ function body(kit: Kit, file: FileDiff, fit: Fit | null) { return Text({ dimColor: true, children: [empty] }) } - return drawFit(kit, fit, file.path) + return drawFit(kit, fit, file.path, columns) } diff --git a/hooks/draw.ts b/hooks/draw.ts index 6f6b788..63c788b 100644 --- a/hooks/draw.ts +++ b/hooks/draw.ts @@ -11,10 +11,10 @@ export type Kit = Pick, 'Box' | 'Text' | 'Code'> * surrounds them differs. */ const LAYOUTS = { - /** Under a tool call in the transcript: the summary line with the built-in's response marker, and a frame. */ - 'tool-result': { lead: ' ⎿ ', indent: 5, isFramed: true }, - /** In the pane: no summary (the file's row has the counts), no frame, the rule between the columns alone. */ - pane: { lead: null, indent: 0, isFramed: false }, + /** Under a tool call in the transcript: the summary line with the built-in's response marker; the rows sit under it, indented. */ + 'tool-result': { lead: ' ⎿ ', indent: 5 }, + /** In the pane: no summary (the file's row has the counts), the rule between the columns alone. */ + pane: { lead: null, indent: 0 }, } as const export type DiffLayout = keyof typeof LAYOUTS @@ -29,6 +29,65 @@ export const TEXT_BUDGET = 80_000 /** Lines of the rule between the columns; a row that wraps past it shows a gap in the rule. */ const RULE_LENGTH = 16 +/** Columns the separator between the two columns takes. */ +const SEPARATOR_WIDTH = 1 + +/** + * Characters of text that count as fitting a column when the width is not + * known: none. The tinted pair is clipped to one row and cuts what does not + * fit with an ellipsis, so without a width every changed pair wraps. + */ +const UNKNOWN_ROOM = 0 + +/** + * Columns the engine's diff spends before a line's text: the line number, + * padded to the widest number of the diff, a space and the marker (`12 -text`, + * as the built-in's own rows show), plus one column as a margin. The margin + * makes this over-count by one, which only sends a line to the wrapping + * drawing a column early. + */ +const gutterWidth = (numberWidth: number) => numberWidth + 3 + +/** + * Characters of text that fit one row of a column, or a lower bound of it. + * + * `width` is the columns the rows have, the layout's indent already taken off. + * The two columns are 50% each of the row next to a one-column separator, so + * the room of each is half of what the separator leaves, rounded down; the + * engine's own rounding of the shrink can differ by a column, which the + * gutter's margin covers. Without a width no line is trusted to fit. + */ +export function textRoom(width: number | undefined, numberWidth: number): number { + if (width === undefined) return UNKNOWN_ROOM + const column = Math.floor((width - SEPARATOR_WIDTH) / 2) + return Math.max(0, column - gutterWidth(numberWidth)) +} + +/** + * Columns a line takes on the screen. East Asian wide characters and emoji + * take two; anything else counts one, which is right for the text a diff + * holds. Combining marks count one too, a harmless over-count. Tabs were laid + * out as spaces before the line got here. + */ +export function displayWidth(text: string): number { + let width = 0 + for (const char of text) { + const code = char.codePointAt(0) ?? 0 + const isWide = + (code >= 0x1100 && code <= 0x115f) || + (code >= 0x2e80 && code <= 0xa4cf) || + (code >= 0xac00 && code <= 0xd7a3) || + (code >= 0xf900 && code <= 0xfaff) || + (code >= 0xfe30 && code <= 0xfe6f) || + (code >= 0xff00 && code <= 0xff60) || + (code >= 0xffe0 && code <= 0xffe6) || + // Enclosed ideographs (1F200) and everything above: emoji, symbols, the CJK planes. + code >= 0x1f200 + width += isWide ? 2 : 1 + } + return width +} + /** Characters of a one-line hunk header (`@@ -12,1 +12,1 @@` and the mark). */ const HEADER_LENGTH = 22 @@ -68,6 +127,12 @@ export type DiffOptions = { /** Rows drawn before "… N more rows". */ maxRows: number layout: DiffLayout + /** + * Columns across the surface the diff is drawn into, the layout's indent + * included; it tells which changed lines fit one row of a column. Absent, + * every changed pair wraps. + */ + columns?: number } /** @@ -75,9 +140,11 @@ export type DiffOptions = { * right, with line numbers, under a "Added 3 lines, removed 1 line" summary * where the layout has one. Every place that draws a diff goes through here. */ -export function drawDiff(kit: Kit, diff: SplitDiff, { path, maxRows, layout }: DiffOptions) { +export function drawDiff(kit: Kit, diff: SplitDiff, { path, maxRows, layout, columns }: DiffOptions) { const { Box, Text } = kit - const { lead, indent, isFramed } = LAYOUTS[layout] + const { lead, indent } = LAYOUTS[layout] + // The rows start after the indent, and there is no frame to take columns of its own. + const width = columns === undefined ? undefined : columns - indent return Box({ flexDirection: 'column', @@ -91,8 +158,7 @@ export function drawDiff(kit: Kit, diff: SplitDiff, { path, maxRows, layout }: D Box({ flexDirection: 'column', marginLeft: indent, - ...(isFramed ? { borderStyle: 'round', borderDimColor: true } : {}), - children: drawRows(kit, diff, path, maxRows), + children: drawRows(kit, diff, path, maxRows, width), }), ], }) @@ -130,10 +196,13 @@ export function fitHunks(hunks: readonly Hunk[], maxRows: number, maxChars: numb /** * Draws a fit: the split diff in the pane's layout, or git's own diff, in the - * engine's drawing, with "… N more rows" after what was cut. + * engine's drawing, with "… N more rows" after what was cut. `columns` is the + * width the fit has, when the caller knows it (see `DiffOptions`). */ -export function drawFit(kit: Kit, fit: Fit, path: string | undefined) { - if (fit.kind === 'split') return drawDiff(kit, fit.diff, { path, maxRows: fit.count, layout: 'pane' }) +export function drawFit(kit: Kit, fit: Fit, path: string | undefined, columns?: number) { + if (fit.kind === 'split') { + return drawDiff(kit, fit.diff, { path, maxRows: fit.count, layout: 'pane', ...(columns === undefined ? {} : { columns }) }) + } const { Box, Text, Code } = kit const hidden = fit.total - fit.count @@ -150,18 +219,20 @@ export function drawFit(kit: Kit, fit: Fit, path: string | undefined) { /** * The rows of a split diff, ready to sit in a column, with "… N more rows" - * after the first `maxRows`. + * after the first `maxRows`. `width` is the columns the rows have, if known. */ -function drawRows(kit: Kit, diff: SplitDiff, path: string | undefined, maxRows: number) { +function drawRows(kit: Kit, diff: SplitDiff, path: string | undefined, maxRows: number, width: number | undefined) { const shown = diff.rows.slice(0, maxRows) const hidden = diff.rows.length - shown.length + const room = textRoom(width, diff.numberWidth) return [ - ...shown.map(row => drawRow(kit, row, path)), + ...shown.map(row => drawRow(kit, row, path, room)), hidden > 0 ? kit.Text({ dimColor: true, children: ['… ' + hidden + ' more rows'] }) : null, ] } -function drawRow(kit: Kit, row: SplitRow, path: string | undefined) { +/** `room` is the characters of text that fit one row of a column (see `textRoom`). */ +function drawRow(kit: Kit, row: SplitRow, path: string | undefined, room: number) { 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. @@ -173,7 +244,17 @@ function drawRow(kit: Kit, row: SplitRow, path: string | undefined) { children: [Box({ ...column, children: [label()] }), Box({ width: 1, flexShrink: 0 }), Box({ ...column, children: [label()] })], }) } - if (row.changed && row.left !== null && row.right !== null) { + // The tinted pair is clipped to one row, so only a pair that fits it, and whose numbers have as many digits, can use it; any other wraps in cells. + // The pair is one hunk, whose gutter is as wide as its largest number: when the two numbers differ in digits + // (9 and 10), the text of the left column would sit a column to the right of the other rows. + if ( + row.changed && + row.left !== null && + row.right !== null && + String(row.left.no).length === String(row.right.no).length && + fits(row.left, room) && + fits(row.right, room) + ) { return drawPair(kit, row.left, row.right, path) } return Box({ @@ -218,11 +299,16 @@ function drawCell( }) } +/** True when the line fits one row of a column, with `room` characters for its text. */ +const fits = (cell: Cell, room: number) => displayWidth(cell.text) <= room + /** * 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. + * then shows its own line of that hunk. A column clipped to one row cannot wrap + * and would cut a long line with an ellipsis, so `drawRow` calls this only for + * a pair that fits one row; a longer pair is drawn in wrapping cells, without + * the word tint. */ function drawPair(kit: Kit, left: Cell, right: Cell, path: string | undefined) { const { Box, Code } = kit diff --git a/hooks/register.ts b/hooks/register.ts index 7a28568..b6530bf 100644 --- a/hooks/register.ts +++ b/hooks/register.ts @@ -1,5 +1,6 @@ import type { On } from 'claude-code' +import { drawUpdatedFiles, isSilent, takeUpdatedFiles } from './bash-diff.js' import { registerDiffSplit } from './diff-command.js' import { TEXT_BUDGET, drawDiff, rowsWithin } from './draw.js' import type { Kit } from './draw.js' @@ -11,6 +12,9 @@ const TOOLS = ['Edit', 'Write'] /** Rows drawn before "… N more rows". */ const MAX_ROWS = 200 +/** The session's working directory, to show paths from it; undefined where the runtime has no `process`. */ +const cwd = () => (globalThis as { process?: { cwd?: () => string } }).process?.cwd?.() + const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null @@ -25,7 +29,20 @@ 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) + if (isErrored) return next(e) + + if (tool === 'Bash') { + // Files the command updated: the built-in draws its output, the diffs come under it. + const split = takeUpdatedFiles(output) + if (split === null) return next(e) + + const kit: Kit = $.ui.resolve(e) + const { files, hidden } = drawUpdatedFiles(kit, split, cwd(), e.viewport?.columns) + const base = isSilent(split.rest) ? null : await next({ ...e, props: { ...e.props, output: split.rest } }) + return kit.Box({ flexDirection: 'column', children: [base, ...files, hidden] }) + } + + if (!TOOLS.includes(tool) || !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) @@ -39,6 +56,7 @@ export function register(on: On) { // The same limits as the pane: rows, and the characters the engine draws of a tree. const { count } = rowsWithin(diff, MAX_ROWS, TEXT_BUDGET) - return drawDiff(kit, diff, { path, maxRows: count, layout: 'tool-result' }) + const columns = e.viewport?.columns + return drawDiff(kit, diff, { path, maxRows: count, layout: 'tool-result', ...(columns === undefined ? {} : { columns }) }) }) } diff --git a/tests/bash-diff.test.ts b/tests/bash-diff.test.ts new file mode 100644 index 0000000..204dd87 --- /dev/null +++ b/tests/bash-diff.test.ts @@ -0,0 +1,126 @@ +import { expect, test } from 'claude-code/testing' + +import { displayPath, isSilent, takeUpdatedFiles } from '../hooks/bash-diff.js' + +const hunk = { oldStart: 1, oldLines: 2, newStart: 1, newLines: 2, lines: [' keep', '-old', '+new'] } + +const result = (bashEditDiff: unknown, extra: Record = {}) => ({ + stdout: 'done', + stderr: '', + interrupted: false, + bashEditDiff, + ...extra, +}) + +type Ctx = Parameters[1]>[0] +type Register = Parameters[1]>[1] + +/** Mounts the result of a Bash call; a stand-in for the engine answers where the mod hands the drawing back. */ +const mount = ($: Ctx, on: Register, output: unknown) => { + on('ui.render', ($, e) => $.ui.resolve(e).Text({ children: ['engine drawing'] })) + return $.ui.mount({ + plugin: 'cc-split-diff-view', + component: 'ToolResult', + requestId: 'bash-1', + surface: 'terminal', + props: { tool_use_id: 'toolu_b', tool: 'Bash', isErrored: false, output }, + }) +} + +test('takes the updated files out and keeps the created ones for the built-in', async () => { + const split = takeUpdatedFiles( + result({ + files: [ + { filePath: '/w/a.txt', hunks: [hunk] }, + { filePath: '/w/new.txt', hunks: [hunk], created: true }, + { filePath: '/w/gone.txt', hunks: [hunk], deleted: true }, + ], + moreFiles: 0, + }), + ) + expect(split?.updated.map(file => file.path)).toEqual(['/w/a.txt']) + const left = (split?.rest.bashEditDiff as { files: { filePath: string }[] }).files + expect(left.map(file => file.filePath)).toEqual(['/w/new.txt', '/w/gone.txt']) + expect(split?.rest.stdout).toBe('done') +}) + +test('leaves the field out when every file was an update', async () => { + const split = takeUpdatedFiles(result({ files: [{ filePath: '/w/a.txt', hunks: [hunk] }], moreFiles: 0 })) + expect(split).not.toBeNull() + expect('bashEditDiff' in (split?.rest ?? {})).toBe(false) +}) + +test('counts the files Claude Code did not list', async () => { + const split = takeUpdatedFiles(result({ files: [{ filePath: '/w/a.txt', hunks: [hunk] }], moreFiles: 3 })) + expect(split?.hiddenFiles).toBe(3) + expect(split?.rest.bashEditDiff).toEqual({ files: [], moreFiles: 3 }) +}) + +test('does nothing without an update, or when Claude Code could not tell what changed', async () => { + expect(takeUpdatedFiles(undefined)).toBeNull() + expect(takeUpdatedFiles({ stdout: '' })).toBeNull() + expect(takeUpdatedFiles(result({ files: [{ filePath: '/w/n.txt', hunks: [hunk], created: true }], moreFiles: 0 }))).toBeNull() + for (const flag of ['unavailable', 'skipped', 'shared']) { + const files = [{ filePath: '/w/a.txt', hunks: [hunk] }] + expect(takeUpdatedFiles(result({ files, moreFiles: 0, [flag]: true }))).toBeNull() + } +}) + +test('is silent only when the built-in has nothing to draw', async () => { + expect(isSilent({ stdout: '', stderr: ' ', interrupted: false })).toBe(true) + expect(isSilent({ stdout: 'x', stderr: '', interrupted: false })).toBe(false) + expect(isSilent({ stdout: '', stderr: '', interrupted: true })).toBe(false) + expect(isSilent({ stdout: '', stderr: '', bashEditDiff: { files: [] } })).toBe(false) +}) + +test('shows a path from the working directory when it is inside', async () => { + expect(displayPath('/w/src/a.ts', '/w')).toBe('src/a.ts') + expect(displayPath('/other/a.ts', '/w')).toBe('/other/a.ts') + expect(displayPath('/wx/a.ts', '/w')).toBe('/wx/a.ts') + expect(displayPath('/w/a.ts', undefined)).toBe('/w/a.ts') +}) + +test('a Bash result with an update draws the file row and the split diff', async ($, on) => { + const ui = await mount($, on, result({ files: [{ filePath: '/w/a.txt', hunks: [hunk] }], moreFiles: 0 })) + expect(await ui.find({ type: 'Text', text: /^Updated $/ })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /^ \(\+1 -1\)$/ })).toBeDefined() + expect(await ui.find({ type: 'Code', text: /^@@ -\d+,\d+ \+\d+,\d+ @@/ })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /^│+$/ })).toBeDefined() + await ui.unmount() +}) + +test('a Bash result with only a created file is left to the built-in', async ($, on) => { + const ui = await mount($, on, result({ files: [{ filePath: '/w/new.txt', hunks: [hunk], created: true }], moreFiles: 0 })) + expect(await ui.find({ type: 'Text', text: /^Updated $/ })).toBeUndefined() + expect(await ui.find({ type: 'Text', text: /^│+$/ })).toBeUndefined() + await ui.unmount() +}) + +/** An update of 25 changed lines of 500 characters: about 51000 characters of diff, under the 75000 of budget. */ +const bigUpdate = () => ({ + files: [ + { + filePath: '/w/a.txt', + hunks: Array.from({ length: 25 }, (_, i) => ({ + oldStart: i * 10 + 1, + oldLines: 1, + newStart: i * 10 + 1, + newLines: 1, + lines: ['-' + 'a'.repeat(500), '+' + 'b'.repeat(500)], + })), + }, + ], + moreFiles: 0, +}) + +test('a short command output leaves the whole diff its text budget', async ($, on) => { + const ui = await mount($, on, result(bigUpdate(), { stdout: 'done' })) + expect(await ui.find({ type: 'Text', text: /more rows/ })).toBeUndefined() + await ui.unmount() +}) + +test('a long command output comes off the text budget of the diffs', async ($, on) => { + const ui = await mount($, on, result(bigUpdate(), { stdout: 'x'.repeat(40_000) })) + expect(await ui.find({ type: 'Text', text: /more rows/ })).toBeDefined() + await ui.unmount() +}) diff --git a/tests/wrap-lines.test.ts b/tests/wrap-lines.test.ts new file mode 100644 index 0000000..9f4d1ae --- /dev/null +++ b/tests/wrap-lines.test.ts @@ -0,0 +1,214 @@ +import { expect, test } from 'claude-code/testing' + +type Ctx = Parameters[1]>[0] +type Viewport = { columns: number; rows: number } + +const NARROW: Viewport = { columns: 60, rows: 40 } +const WIDE: Viewport = { columns: 200, rows: 40 } + +/** Mounts the result of an Edit that replaces `before` with `after` on one line. */ +const mountEdit = ($: Ctx, before: string, after: string, viewport?: Viewport) => + $.ui.mount({ + plugin: 'cc-split-diff-view', + component: 'ToolResult', + requestId: 'edit-wrap', + surface: 'terminal', + ...(viewport === undefined ? {} : { viewport }), + props: { + tool_use_id: 'toolu_w', + tool: 'Edit', + isErrored: false, + output: { + filePath: '/tmp/f.txt', + structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-' + before, '+' + after] }], + }, + }, + }) + +type Mounted = Awaited> + +/** The Code elements of the diff, with the wrap they ask for. */ +const codes = async (ui: Mounted) => + (await ui.findAll({ type: 'Code' })).map(code => ({ source: code.text, wrap: code.props.wrap })) + +/** True for the tinted pair: one Code holding both the removed and the added line. */ +const isPair = (source: string) => /\n-/.test(source) && /\n\+/.test(source) + +const text = (length: number, fill: string) => fill.repeat(length) + +test('a short pair keeps the tinted pair, one hunk in each column', async $ => { + const ui = await mountEdit($, 'const a = 1', 'const a = 2', NARROW) + const found = await codes(ui) + expect(found).toHaveLength(2) + for (const code of found) { + expect(isPair(code.source)).toBe(true) + expect(code.source).toContain('const a = 1') + expect(code.source).toContain('const a = 2') + } + await ui.unmount() +}) + +test('a pair too long for a narrow screen wraps in two cells', async $ => { + // 50 characters: more than a column of a 60-column screen holds. + const ui = await mountEdit($, text(50, 'a'), text(50, 'b'), NARROW) + const found = await codes(ui) + expect(found).toHaveLength(2) + expect(found.some(code => isPair(code.source))).toBe(false) + expect(found.map(code => code.wrap)).toEqual(['wrap', 'wrap']) + expect(found[0]?.source).toMatch(/\n-a{50}$/) + expect(found[1]?.source).toMatch(/\n\+b{50}$/) + await ui.unmount() +}) + +test('the same pair keeps the tint on a wide screen', async $ => { + const ui = await mountEdit($, text(50, 'a'), text(50, 'b'), WIDE) + const found = await codes(ui) + expect(found).toHaveLength(2) + expect(found.every(code => isPair(code.source))).toBe(true) + await ui.unmount() +}) + +test('a pair too long for a wide screen wraps too', async $ => { + // 150 characters against a column of about 97. + const ui = await mountEdit($, text(150, 'a'), text(150, 'b'), WIDE) + const found = await codes(ui) + expect(found.some(code => isPair(code.source))).toBe(false) + expect(found.map(code => code.wrap)).toEqual(['wrap', 'wrap']) + await ui.unmount() +}) + +test('one long line is enough to wrap the pair', async $ => { + const ui = await mountEdit($, 'short', text(150, 'b'), WIDE) + const found = await codes(ui) + expect(found.some(code => isPair(code.source))).toBe(false) + expect(found.map(code => code.wrap)).toEqual(['wrap', 'wrap']) + await ui.unmount() +}) + +test('wide characters take two columns', async $ => { + // 15 characters, 30 columns: a column of a 60-column screen holds 23. + const ui = await mountEdit($, text(15, '漢'), text(15, '字'), NARROW) + expect((await codes(ui)).some(code => isPair(code.source))).toBe(false) + await ui.unmount() +}) + +test('an unknown width wraps every changed pair, even a short one', async $ => { + for (const length of [1, 40, 41]) { + const ui = await mountEdit($, text(length, 'a'), text(length, 'b')) + const found = await codes(ui) + expect(found.some(code => isPair(code.source))).toBe(false) + expect(found.map(code => code.wrap)).toEqual(['wrap', 'wrap']) + await ui.unmount() + } +}) + +test('enclosed ideographs take two columns', async $ => { + // 15 of U+1F250 are 30 columns: more than a column of a 60-column screen holds after the gutter. + const ui = await mountEdit($, text(15, '\u{1F250}'), text(15, '\u{1F251}'), NARROW) + expect((await codes(ui)).some(code => isPair(code.source))).toBe(false) + await ui.unmount() +}) + +test('no code element truncates when the lines are long', async $ => { + for (const viewport of [NARROW, WIDE, undefined]) { + const ui = await mountEdit($, text(300, 'a'), text(300, 'b'), viewport) + expect((await codes(ui)).filter(code => code.wrap === 'truncate-end')).toEqual([]) + await ui.unmount() + } +}) + +test('the tool result has no frame', async $ => { + const ui = await mountEdit($, 'const a = 1', 'const a = 2', NARROW) + const boxes = await ui.findAll({ type: 'Box' }) + expect(boxes.length).toBeGreaterThan(0) + for (const box of boxes) { + expect(box.props).not.toHaveProperty('borderStyle') + expect(box.props).not.toHaveProperty('borderDimColor') + } + // The rows keep the indent that puts them under the response marker. + expect(boxes.some(box => box.props.marginLeft === 5)).toBe(true) + await ui.unmount() +}) + +test('the files a Bash command updated wrap long pairs too', async ($, on) => { + on('ui.render', ($, e) => $.ui.resolve(e).Text({ children: ['engine drawing'] })) + const mount = (viewport: Viewport) => + $.ui.mount({ + plugin: 'cc-split-diff-view', + component: 'ToolResult', + requestId: 'bash-wrap', + surface: 'terminal', + viewport, + props: { + tool_use_id: 'toolu_bw', + tool: 'Bash', + isErrored: false, + output: { + stdout: 'done', + stderr: '', + interrupted: false, + bashEditDiff: { + files: [{ filePath: '/w/a.txt', hunks: [{ oldStart: 1, newStart: 1, lines: ['-' + text(50, 'a'), '+' + text(50, 'b')] }] }], + moreFiles: 0, + }, + }, + }, + }) + + // 50 characters fit the columns of a 200-column screen, not of a 60-column one. + const wide = await mount(WIDE) + expect((await codes(wide)).every(code => isPair(code.source))).toBe(true) + await wide.unmount() + + const narrow = await mount(NARROW) + const found = await codes(narrow) + expect(found.some(code => isPair(code.source))).toBe(false) + expect(found.map(code => code.wrap)).toEqual(['wrap', 'wrap']) + await narrow.unmount() +}) + +// A tinted pair is one hunk, and the engine makes its gutter as wide as the largest number in it: +// with old line 9 against new line 10 the left text would sit a column right of the other rows. +test('a pair whose line numbers differ in digits is drawn in cells, so the columns line up', async $ => { + const ui = await $.ui.mount({ + plugin: 'cc-split-diff-view', + component: 'ToolResult', + requestId: 'edit-digits', + surface: 'terminal', + viewport: WIDE, + props: { + tool_use_id: 'toolu_d', + tool: 'Edit', + isErrored: false, + output: { + filePath: '/tmp/f.txt', + structuredPatch: [{ oldStart: 9, oldLines: 1, newStart: 10, newLines: 1, lines: ['-old', '+new'] }], + }, + }, + }) + const found = await codes(ui) + expect(found.some(code => isPair(code.source))).toBe(false) + expect(found).toHaveLength(2) + await ui.unmount() +}) + +test('a pair whose line numbers have the same digits keeps the tint', async $ => { + const ui = await $.ui.mount({ + plugin: 'cc-split-diff-view', + component: 'ToolResult', + requestId: 'edit-digits-same', + surface: 'terminal', + viewport: WIDE, + props: { + tool_use_id: 'toolu_e', + tool: 'Edit', + isErrored: false, + output: { + filePath: '/tmp/f.txt', + structuredPatch: [{ oldStart: 12, oldLines: 1, newStart: 22, newLines: 1, lines: ['-old', '+new'] }], + }, + }, + }) + expect((await codes(ui)).every(code => isPair(code.source))).toBe(true) + await ui.unmount() +})