diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx
index 5200db17d4..0ef6704f3b 100644
--- a/packages/cli/src/ui/components/Header.test.tsx
+++ b/packages/cli/src/ui/components/Header.test.tsx
@@ -128,7 +128,12 @@ describe('', () => {
},
background: {
primary: '',
- diff: { added: '', removed: '' },
+ diff: {
+ added: '',
+ addedHighlight: '',
+ removed: '',
+ removedHighlight: '',
+ },
},
border: {
default: '',
diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.tsx
index 83b205ac76..364e60a1a1 100644
--- a/packages/cli/src/ui/components/messages/DiffRenderer.tsx
+++ b/packages/cli/src/ui/components/messages/DiffRenderer.tsx
@@ -8,6 +8,7 @@ import type React from 'react';
import { useMemo } from 'react';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import crypto from 'node:crypto';
+import * as Diff from 'diff';
import { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { theme as semanticTheme } from '../../semantic-colors.js';
@@ -21,6 +22,42 @@ interface DiffLine {
content: string;
}
+interface DiffChangeGroup {
+ type: 'change';
+ removed: DiffLine[];
+ added: DiffLine[];
+}
+
+type GroupedDiffLine = DiffLine | DiffChangeGroup;
+
+function groupDiffLines(lines: DiffLine[]): GroupedDiffLine[] {
+ const grouped: GroupedDiffLine[] = [];
+ let i = 0;
+ while (i < lines.length) {
+ if (lines[i].type === 'del') {
+ const removed: DiffLine[] = [];
+ while (i < lines.length && lines[i].type === 'del') {
+ removed.push(lines[i]);
+ i++;
+ }
+ const added: DiffLine[] = [];
+ while (i < lines.length && lines[i].type === 'add') {
+ added.push(lines[i]);
+ i++;
+ }
+ if (added.length > 0) {
+ grouped.push({ type: 'change', removed, added });
+ } else {
+ grouped.push(...removed);
+ }
+ } else {
+ grouped.push(lines[i]);
+ i++;
+ }
+ }
+ return grouped;
+}
+
function parseDiffWithLineNumbers(diffContent: string): DiffLine[] {
const lines = diffContent.split('\n');
const result: DiffLine[] = [];
@@ -256,18 +293,27 @@ const renderDiffContent = (
? `diff-box-${filename}`
: `diff-box-${crypto.createHash('sha1').update(JSON.stringify(parsedLines)).digest('hex')}`;
+ const groupedLines = groupDiffLines(displayableLines);
+
let lastLineNumber: number | null = null;
const MAX_CONTEXT_LINES_WITHOUT_GAP = 5;
- const content = displayableLines.reduce(
- (acc, line, index) => {
- // Determine the relevant line number for gap calculation based on type
+ const content = groupedLines.reduce(
+ (acc, entry, index) => {
+ // Determine the relevant line number for gap calculation
let relevantLineNumberForGapCalc: number | null = null;
- if (line.type === 'add' || line.type === 'context') {
- relevantLineNumberForGapCalc = line.newLine ?? null;
- } else if (line.type === 'del') {
- // For deletions, the gap is typically in relation to the original file's line numbering
- relevantLineNumberForGapCalc = line.oldLine ?? null;
+ if ('type' in entry && entry.type === 'change') {
+ const firstLine = entry.removed[0] || entry.added[0];
+ relevantLineNumberForGapCalc =
+ (firstLine.type === 'add' ? firstLine.newLine : firstLine.oldLine) ??
+ null;
+ } else {
+ const line = entry;
+ if (line.type === 'add' || line.type === 'context') {
+ relevantLineNumberForGapCalc = line.newLine ?? null;
+ } else if (line.type === 'del') {
+ relevantLineNumberForGapCalc = line.oldLine ?? null;
+ }
}
if (
@@ -290,82 +336,102 @@ const renderDiffContent = (
);
}
- const lineKey = `diff-line-${index}`;
- let gutterNumStr = '';
- let prefixSymbol = ' ';
+ if ('type' in entry && entry.type === 'change') {
+ const removedText = entry.removed
+ .map((l) => l.content.substring(baseIndentation))
+ .join('\n');
+ const addedText = entry.added
+ .map((l) => l.content.substring(baseIndentation))
+ .join('\n');
+ const wordDiffs = Diff.diffWordsWithSpace(removedText, addedText);
- switch (line.type) {
- case 'add':
- gutterNumStr = (line.newLine ?? '').toString();
- prefixSymbol = '+';
- lastLineNumber = line.newLine ?? null;
- break;
- case 'del':
- gutterNumStr = (line.oldLine ?? '').toString();
- prefixSymbol = '-';
- // For deletions, update lastLineNumber based on oldLine if it's advancing.
- // This helps manage gaps correctly if there are multiple consecutive deletions
- // or if a deletion is followed by a context line far away in the original file.
+ // Render removed lines
+ const removedLinesParts = renderChangesForType(
+ 'del',
+ wordDiffs,
+ semanticTheme.background.diff.removedHighlight,
+ );
+ entry.removed.forEach((line, i) => {
+ const displayContentParts = removedLinesParts[i] || [];
+ acc.push(
+ renderLine(
+ line,
+ `del-${index}-${i}`,
+ gutterWidth,
+ '-',
+ semanticTheme.background.diff.removed,
+ displayContentParts.length > 0 ? displayContentParts : undefined,
+ baseIndentation,
+ language,
+ ),
+ );
if (line.oldLine !== undefined) {
lastLineNumber = line.oldLine;
}
- break;
- case 'context':
- gutterNumStr = (line.newLine ?? '').toString();
- prefixSymbol = ' ';
+ });
+
+ // Render added lines
+ const addedLinesParts = renderChangesForType(
+ 'add',
+ wordDiffs,
+ semanticTheme.background.diff.addedHighlight,
+ );
+ entry.added.forEach((line, i) => {
+ const displayContentParts = addedLinesParts[i] || [];
+ acc.push(
+ renderLine(
+ line,
+ `add-${index}-${i}`,
+ gutterWidth,
+ '+',
+ semanticTheme.background.diff.added,
+ displayContentParts.length > 0 ? displayContentParts : undefined,
+ baseIndentation,
+ language,
+ ),
+ );
lastLineNumber = line.newLine ?? null;
- break;
- default:
- return acc;
+ });
+ } else {
+ const line = entry;
+
+ let prefixSymbol = ' ';
+ let backgroundColor: string | undefined = undefined;
+
+ switch (line.type) {
+ case 'add':
+ prefixSymbol = '+';
+ backgroundColor = semanticTheme.background.diff.added;
+ lastLineNumber = line.newLine ?? null;
+ break;
+ case 'del':
+ prefixSymbol = '-';
+ backgroundColor = semanticTheme.background.diff.removed;
+ if (line.oldLine !== undefined) {
+ lastLineNumber = line.oldLine;
+ }
+ break;
+ case 'context':
+ prefixSymbol = ' ';
+ lastLineNumber = line.newLine ?? null;
+ break;
+ default:
+ break;
+ }
+
+ acc.push(
+ renderLine(
+ line,
+ `line-${index}`,
+ gutterWidth,
+ prefixSymbol,
+ backgroundColor,
+ undefined,
+ baseIndentation,
+ language,
+ ),
+ );
}
-
- const displayContent = line.content.substring(baseIndentation);
-
- const backgroundColor =
- line.type === 'add'
- ? semanticTheme.background.diff.added
- : line.type === 'del'
- ? semanticTheme.background.diff.removed
- : undefined;
- acc.push(
-
-
- {gutterNumStr}
-
- {line.type === 'context' ? (
- <>
- {prefixSymbol}
- {colorizeLine(displayContent, language)}
- >
- ) : (
-
-
- {prefixSymbol}
- {' '}
- {colorizeLine(displayContent, language)}
-
- )}
- ,
- );
return acc;
},
[],
@@ -382,6 +448,85 @@ const renderDiffContent = (
);
};
+const renderLine = (
+ line: DiffLine,
+ key: string,
+ gutterWidth: number,
+ prefixSymbol: string,
+ backgroundColor: string | undefined,
+ displayContentParts: React.ReactNode[] | undefined,
+ baseIndentation: number,
+ language: string | null,
+) => {
+ const gutterNumStr =
+ (line.type === 'add' || line.type === 'context'
+ ? line.newLine
+ : line.oldLine
+ )?.toString() || '';
+ const displayContent = line.content.substring(baseIndentation);
+
+ return (
+
+
+ {gutterNumStr}
+
+
+
+ {prefixSymbol}
+ {' '}
+ {displayContentParts
+ ? displayContentParts
+ : colorizeLine(displayContent, language)}
+
+
+ );
+};
+
+function renderChangesForType(
+ type: 'add' | 'del',
+ allChanges: Diff.Change[],
+ highlightColor: string | undefined,
+) {
+ const lines: React.ReactNode[][] = [[]];
+
+ allChanges.forEach((change, changeIndex) => {
+ if (type === 'add' && change.removed) return;
+ if (type === 'del' && change.added) return;
+
+ const isHighlighted =
+ (type === 'add' && change.added) || (type === 'del' && change.removed);
+ const color = isHighlighted ? highlightColor : undefined;
+
+ const parts = change.value.split('\n');
+ parts.forEach((part, partIndex) => {
+ if (partIndex > 0) lines.push([]);
+ lines[lines.length - 1].push(
+
+ {part}
+ ,
+ );
+ });
+ });
+ return lines;
+}
+
const getLanguageFromExtension = (extension: string): string | null => {
const languageMap: { [key: string]: string } = {
js: 'javascript',
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap
index 03f55105a7..2401c9e217 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/DiffRenderer.test.tsx.snap
@@ -13,8 +13,8 @@ exports[` > with useAlterna
'test';
21 + const anotherNew =
'test';
-22 console.log('end of second
- hunk');"
+22 console.log('end of
+ second hunk');"
`;
exports[` > with useAlternateBuffer = false > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height 6 1`] = `
@@ -94,8 +94,8 @@ exports[` > with useAlterna
'test';
21 + const anotherNew =
'test';
-22 console.log('end of second
- hunk');"
+22 console.log('end of
+ second hunk');"
`;
exports[` > with useAlternateBuffer = true > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height 6 1`] = `
diff --git a/packages/cli/src/ui/themes/github-dark.ts b/packages/cli/src/ui/themes/github-dark.ts
index 28c14f598d..aff0645540 100644
--- a/packages/cli/src/ui/themes/github-dark.ts
+++ b/packages/cli/src/ui/themes/github-dark.ts
@@ -19,7 +19,9 @@ const githubDarkColors: ColorsTheme = {
AccentYellow: '#FFAB70',
AccentRed: '#F97583',
DiffAdded: '#3C4636',
+ DiffAddedHighlight: '#5C6656',
DiffRemoved: '#502125',
+ DiffRemovedHighlight: '#704145',
Comment: '#6A737D',
Gray: '#6A737D',
DarkGray: interpolateColor('#6A737D', '#24292e', 0.5),
diff --git a/packages/cli/src/ui/themes/github-light.ts b/packages/cli/src/ui/themes/github-light.ts
index 264a9d7a88..33fbd854c5 100644
--- a/packages/cli/src/ui/themes/github-light.ts
+++ b/packages/cli/src/ui/themes/github-light.ts
@@ -19,7 +19,9 @@ const githubLightColors: ColorsTheme = {
AccentYellow: '#990073',
AccentRed: '#d14',
DiffAdded: '#C6EAD8',
+ DiffAddedHighlight: '#A2D9B1',
DiffRemoved: '#FFCCCC',
+ DiffRemovedHighlight: '#FFB3B3',
Comment: '#998',
Gray: '#999',
DarkGray: interpolateColor('#999', '#f8f8f8', 0.5),
diff --git a/packages/cli/src/ui/themes/no-color.ts b/packages/cli/src/ui/themes/no-color.ts
index 7c22e68b9a..8f7c633c56 100644
--- a/packages/cli/src/ui/themes/no-color.ts
+++ b/packages/cli/src/ui/themes/no-color.ts
@@ -38,7 +38,9 @@ const noColorSemanticColors: SemanticColors = {
primary: '',
diff: {
added: '',
+ addedHighlight: '',
removed: '',
+ removedHighlight: '',
},
},
border: {
diff --git a/packages/cli/src/ui/themes/semantic-tokens.ts b/packages/cli/src/ui/themes/semantic-tokens.ts
index 794ce745b6..98ec96cbbf 100644
--- a/packages/cli/src/ui/themes/semantic-tokens.ts
+++ b/packages/cli/src/ui/themes/semantic-tokens.ts
@@ -18,7 +18,9 @@ export interface SemanticColors {
primary: string;
diff: {
added: string;
+ addedHighlight: string;
removed: string;
+ removedHighlight: string;
};
};
border: {
@@ -50,7 +52,10 @@ export const lightSemanticColors: SemanticColors = {
primary: lightTheme.Background,
diff: {
added: lightTheme.DiffAdded,
+ addedHighlight: lightTheme.DiffAddedHighlight ?? lightTheme.DiffAdded,
removed: lightTheme.DiffRemoved,
+ removedHighlight:
+ lightTheme.DiffRemovedHighlight ?? lightTheme.DiffRemoved,
},
},
border: {
@@ -82,7 +87,9 @@ export const darkSemanticColors: SemanticColors = {
primary: darkTheme.Background,
diff: {
added: darkTheme.DiffAdded,
+ addedHighlight: darkTheme.DiffAddedHighlight ?? darkTheme.DiffAdded,
removed: darkTheme.DiffRemoved,
+ removedHighlight: darkTheme.DiffRemovedHighlight ?? darkTheme.DiffRemoved,
},
},
border: {
@@ -114,7 +121,9 @@ export const ansiSemanticColors: SemanticColors = {
primary: ansiTheme.Background,
diff: {
added: ansiTheme.DiffAdded,
+ addedHighlight: ansiTheme.DiffAddedHighlight ?? ansiTheme.DiffAdded,
removed: ansiTheme.DiffRemoved,
+ removedHighlight: ansiTheme.DiffRemovedHighlight ?? ansiTheme.DiffRemoved,
},
},
border: {
diff --git a/packages/cli/src/ui/themes/theme.ts b/packages/cli/src/ui/themes/theme.ts
index 5ba11cb32d..81d485fbd2 100644
--- a/packages/cli/src/ui/themes/theme.ts
+++ b/packages/cli/src/ui/themes/theme.ts
@@ -26,7 +26,9 @@ export interface ColorsTheme {
AccentYellow: string;
AccentRed: string;
DiffAdded: string;
+ DiffAddedHighlight?: string;
DiffRemoved: string;
+ DiffRemovedHighlight?: string;
Comment: string;
Gray: string;
DarkGray: string;
@@ -48,7 +50,9 @@ export interface CustomTheme {
primary?: string;
diff?: {
added?: string;
+ addedHighlight?: string;
removed?: string;
+ removedHighlight?: string;
};
};
border?: {
@@ -77,7 +81,9 @@ export interface CustomTheme {
AccentYellow?: string;
AccentRed?: string;
DiffAdded?: string;
+ DiffAddedHighlight?: string;
DiffRemoved?: string;
+ DiffRemovedHighlight?: string;
Comment?: string;
Gray?: string;
DarkGray?: string;
@@ -96,7 +102,9 @@ export const lightTheme: ColorsTheme = {
AccentYellow: '#D5A40A',
AccentRed: '#DD4C4C',
DiffAdded: '#C6EAD8',
+ DiffAddedHighlight: '#A2D9B1',
DiffRemoved: '#FFCCCC',
+ DiffRemovedHighlight: '#FFB3B3',
Comment: '#008000',
Gray: '#97a0b0',
DarkGray: interpolateColor('#97a0b0', '#FAFAFA', 0.5),
@@ -115,7 +123,9 @@ export const darkTheme: ColorsTheme = {
AccentYellow: '#F9E2AF',
AccentRed: '#F38BA8',
DiffAdded: '#28350B',
+ DiffAddedHighlight: '#435515',
DiffRemoved: '#430000',
+ DiffRemovedHighlight: '#700000',
Comment: '#6C7086',
Gray: '#6C7086',
DarkGray: interpolateColor('#6C7086', '#1E1E2E', 0.5),
@@ -134,7 +144,9 @@ export const ansiTheme: ColorsTheme = {
AccentYellow: 'yellow',
AccentRed: 'red',
DiffAdded: 'green',
+ DiffAddedHighlight: 'green',
DiffRemoved: 'red',
+ DiffRemovedHighlight: 'red',
Comment: 'gray',
Gray: 'gray',
DarkGray: 'gray',
@@ -177,7 +189,11 @@ export class Theme {
primary: this.colors.Background,
diff: {
added: this.colors.DiffAdded,
+ addedHighlight:
+ this.colors.DiffAddedHighlight ?? this.colors.DiffAdded,
removed: this.colors.DiffRemoved,
+ removedHighlight:
+ this.colors.DiffRemovedHighlight ?? this.colors.DiffRemoved,
},
},
border: {
@@ -275,8 +291,20 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
AccentRed: customTheme.status?.error ?? customTheme.AccentRed ?? '',
DiffAdded:
customTheme.background?.diff?.added ?? customTheme.DiffAdded ?? '',
+ DiffAddedHighlight:
+ customTheme.background?.diff?.addedHighlight ??
+ customTheme.DiffAddedHighlight ??
+ customTheme.background?.diff?.added ??
+ customTheme.DiffAdded ??
+ '',
DiffRemoved:
customTheme.background?.diff?.removed ?? customTheme.DiffRemoved ?? '',
+ DiffRemovedHighlight:
+ customTheme.background?.diff?.removedHighlight ??
+ customTheme.DiffRemovedHighlight ??
+ customTheme.background?.diff?.removed ??
+ customTheme.DiffRemoved ??
+ '',
Comment: customTheme.ui?.comment ?? customTheme.Comment ?? '',
Gray: customTheme.text?.secondary ?? customTheme.Gray ?? '',
DarkGray:
@@ -442,7 +470,15 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
primary: customTheme.background?.primary ?? colors.Background,
diff: {
added: customTheme.background?.diff?.added ?? colors.DiffAdded,
+ addedHighlight:
+ customTheme.background?.diff?.addedHighlight ??
+ colors.DiffAddedHighlight ??
+ colors.DiffAdded,
removed: customTheme.background?.diff?.removed ?? colors.DiffRemoved,
+ removedHighlight:
+ customTheme.background?.diff?.removedHighlight ??
+ colors.DiffRemovedHighlight ??
+ colors.DiffRemoved,
},
},
border: {