feat(tui): improved diff rendering with syntax highlighting (#1349)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-03-10 23:06:11 -07:00
committed by GitHub
parent bf53c6e364
commit 4c613d4238
4 changed files with 439 additions and 330 deletions

View File

@@ -1,5 +1,4 @@
import { relative } from "node:path"; import { relative } from "node:path";
import * as Diff from "diff";
import { Box } from "ink"; import { Box } from "ink";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
@@ -10,6 +9,11 @@ import {
import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { colors } from "./colors"; import { colors } from "./colors";
import { EditRenderer, MultiEditRenderer, WriteRenderer } from "./DiffRenderer"; import { EditRenderer, MultiEditRenderer, WriteRenderer } from "./DiffRenderer";
import {
highlightCode,
languageFromPath,
type StyledSpan,
} from "./SyntaxHighlightedCommand";
import { Text } from "./Text"; import { Text } from "./Text";
type EditItem = { type EditItem = {
@@ -54,38 +58,67 @@ function padLeft(n: number, width: number): string {
return s.length >= width ? s : " ".repeat(width - s.length) + s; return s.length >= width ? s : " ".repeat(width - s.length) + s;
} }
// Calculate word-level similarity between two strings (0-1) // A styled text chunk with optional color/dim for row-splitting.
// Used to decide whether to show word-level highlighting type StyledChunk = { text: string; color?: string; dimColor?: boolean };
function wordSimilarity(a: string, b: string): number {
const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(Boolean)); // Split styled chunks into rows of exactly `cols` characters, padding the last row.
const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(Boolean)); // Continuation rows start with a blank indent of `contIndent` characters
if (wordsA.size === 0 && wordsB.size === 0) return 1; // (matching Codex's empty-gutter + 2-space continuation, diff_render.rs:922-929).
if (wordsA.size === 0 || wordsB.size === 0) return 0; function buildPaddedRows(
const intersection = [...wordsA].filter((w) => wordsB.has(w)).length; chunks: StyledChunk[],
const union = new Set([...wordsA, ...wordsB]).size; cols: number,
return intersection / union; // Jaccard similarity contIndent: number,
): StyledChunk[][] {
if (cols <= 0) return [chunks];
const rows: StyledChunk[][] = [];
let row: StyledChunk[] = [];
let len = 0;
for (const chunk of chunks) {
let rem = chunk.text;
while (rem.length > 0) {
const space = cols - len;
if (rem.length <= space) {
row.push({ text: rem, color: chunk.color, dimColor: chunk.dimColor });
len += rem.length;
rem = "";
} else {
row.push({
text: rem.slice(0, space),
color: chunk.color,
dimColor: chunk.dimColor,
});
rows.push(row);
// Start continuation row with blank gutter indent
row = [{ text: " ".repeat(contIndent) }];
len = contIndent;
rem = rem.slice(space);
}
}
}
if (len < cols) row.push({ text: " ".repeat(cols - len) });
if (row.length > 0) rows.push(row);
return rows;
} }
// Threshold: only show word-level highlighting if lines share enough words // Render a single diff line split into full-width rows.
const WORD_SIMILARITY_THRESHOLD = 0.3; // Each visual row gets its own <Text> with backgroundColor so there are no gaps.
// See ~/dev/codex/codex-rs/tui/src/diff_render.rs lines 836-936.
// Render a single line with gutters and optional word-diff highlighting
function Line({ function Line({
kind, kind,
displayNo, displayNo,
text, text,
pairText, syntaxSpans,
gutterWidth, gutterWidth,
columns, columns,
enableWord, indent,
}: { }: {
kind: "context" | "remove" | "add"; kind: "context" | "remove" | "add";
displayNo: number; displayNo: number;
text: string; text: string;
pairText?: string; // when '-' followed by '+' to highlight words syntaxSpans?: StyledSpan[];
gutterWidth: number; gutterWidth: number;
columns: number; columns: number;
enableWord: boolean; indent: string;
}) { }) {
const symbol = kind === "add" ? "+" : kind === "remove" ? "-" : " "; const symbol = kind === "add" ? "+" : kind === "remove" ? "-" : " ";
const symbolColor = const symbolColor =
@@ -100,116 +133,40 @@ function Line({
: kind === "remove" : kind === "remove"
? colors.diff.removedLineBg ? colors.diff.removedLineBg
: colors.diff.contextLineBg; : colors.diff.contextLineBg;
const bgWord =
kind === "add"
? colors.diff.addedWordBg
: kind === "remove"
? colors.diff.removedWordBg
: undefined;
// Word-level diff only for '-' or '+' when pairText is present AND lines are similar enough // Build styled chunks: indent + gutter + sign + content
// If lines are too different, word-level highlighting becomes noise - show full-line colors instead const gutterStr = `${padLeft(displayNo, gutterWidth)} `;
const similarity = const chunks: StyledChunk[] = [];
enableWord && pairText ? wordSimilarity(text, pairText) : 0; if (indent) chunks.push({ text: indent });
const charParts: Array<{ chunks.push({ text: gutterStr, dimColor: kind === "context" });
value: string; chunks.push({ text: symbol, color: symbolColor });
added?: boolean; chunks.push({ text: " " });
removed?: boolean; if (syntaxSpans && syntaxSpans.length > 0) {
}> | null = for (const span of syntaxSpans) {
enableWord && chunks.push({ text: span.text, color: span.color });
pairText && }
(kind === "add" || kind === "remove") && } else {
pairText !== text && chunks.push({ text });
similarity >= WORD_SIMILARITY_THRESHOLD }
? kind === "add"
? Diff.diffWordsWithSpace(pairText, text)
: Diff.diffWordsWithSpace(text, pairText)
: null;
// Build prefix: " 1 + " (line number + symbol) // Continuation indent = indent + gutter + sign + space (blank, same width)
const linePrefix = `${padLeft(displayNo, gutterWidth)} ${symbol} `; const contIndent = indent.length + gutterStr.length + 1 + 1;
const prefixWidth = linePrefix.length; const rows = buildPaddedRows(chunks, columns, contIndent);
const contentWidth = Math.max(0, columns - prefixWidth);
return ( return (
<Box flexDirection="row"> <>
<Box width={prefixWidth} flexShrink={0}> {rows.map((row, ri) => (
<Text dimColor={kind === "context"}> // biome-ignore lint/suspicious/noArrayIndexKey: rows are static, never reorder
{padLeft(displayNo, gutterWidth)}{" "} <Text key={ri} backgroundColor={bgLine} dimColor={kind === "remove"}>
<Text color={symbolColor}>{symbol}</Text>{" "} {row.map((c, ci) => (
// biome-ignore lint/suspicious/noArrayIndexKey: chunks are static
<Text key={ci} color={c.color} dimColor={c.dimColor}>
{c.text}
</Text>
))}
</Text> </Text>
</Box> ))}
<Box flexGrow={1} width={contentWidth}> </>
{charParts ? (
<Text wrap="wrap" backgroundColor={bgLine}>
{charParts.map((p, i) => {
// For '-' lines: render removed + unchanged; drop added
if (kind === "remove") {
if (p.removed)
return (
<Text
key={`${kind}-${i}-${p.value.substring(0, 10)}`}
backgroundColor={bgWord}
color={colors.diff.textOnHighlight}
>
{p.value}
</Text>
);
if (!p.added && !p.removed)
return (
<Text
key={`${kind}-${i}-${p.value.substring(0, 10)}`}
backgroundColor={bgLine}
color={colors.diff.textOnDark}
>
{p.value}
</Text>
);
return null; // skip added segments on '-'
}
// For '+' lines: render added + unchanged; drop removed
if (kind === "add") {
if (p.added)
return (
<Text
key={`${kind}-${i}-${p.value.substring(0, 10)}`}
backgroundColor={bgWord}
color={colors.diff.textOnHighlight}
>
{p.value}
</Text>
);
if (!p.added && !p.removed)
return (
<Text
key={`${kind}-${i}-${p.value.substring(0, 10)}`}
backgroundColor={bgLine}
color={colors.diff.textOnDark}
>
{p.value}
</Text>
);
return null; // skip removed segments on '+'
}
// Context (should not occur with charParts), fall back to full line
return (
<Text key={`context-${i}-${p.value.substring(0, 10)}`}>
{p.value}
</Text>
);
})}
</Text>
) : (
<Text
wrap="wrap"
backgroundColor={bgLine}
color={kind === "context" ? undefined : colors.diff.textOnDark}
>
{text}
</Text>
)}
</Box>
</Box>
); );
} }
@@ -295,57 +252,62 @@ export function AdvancedDiffRenderer(
} }
const { hunks } = result; const { hunks } = result;
const relative = formatRelativePath((props as { filePath: string }).filePath); const filePath = (props as { filePath: string }).filePath;
const enableWord = props.kind !== "multi_edit"; const relative = formatRelativePath(filePath);
// Prepare display rows with shared-line-number behavior like the snippet. // Syntax-highlight all hunk content at once per hunk (preserves parser state
// across consecutive lines, like Codex's hunk-level highlighting approach).
const lang = languageFromPath(filePath);
const hunkSyntaxLines: (StyledSpan[] | undefined)[][] = [];
for (const h of hunks) {
// Concatenate all displayable lines in the hunk for a single highlight pass.
const textLines: string[] = [];
for (const line of h.lines) {
if (!line) continue;
const raw = line.raw || "";
if (raw.charAt(0) === "\\") continue; // skip meta
textLines.push(raw.slice(1));
}
const block = textLines.join("\n");
const highlighted = lang ? highlightCode(block, lang) : undefined;
// Map highlighted per-line spans back; undefined when highlighting failed.
hunkSyntaxLines.push(textLines.map((_, i) => highlighted?.[i]));
}
// Prepare display rows with shared-line-number behavior.
type Row = { type Row = {
kind: "context" | "remove" | "add"; kind: "context" | "remove" | "add";
displayNo: number; displayNo: number;
text: string; text: string;
pairText?: string; syntaxSpans?: StyledSpan[];
}; };
const rows: Row[] = []; const rows: Row[] = [];
for (const h of hunks) { for (let hIdx = 0; hIdx < hunks.length; hIdx++) {
const h = hunks[hIdx]!;
const syntaxForHunk = hunkSyntaxLines[hIdx] ?? [];
let oldNo = h.oldStart; let oldNo = h.oldStart;
let newNo = h.newStart; let newNo = h.newStart;
let lastRemovalNo: number | null = null; let lastRemovalNo: number | null = null;
let displayLineIdx = 0; // index into syntaxForHunk
for (let i = 0; i < h.lines.length; i++) { for (let i = 0; i < h.lines.length; i++) {
const line = h.lines[i]; const line = h.lines[i];
if (!line) continue; if (!line) continue;
const raw = line.raw || ""; const raw = line.raw || "";
const ch = raw.charAt(0); const ch = raw.charAt(0);
const body = raw.slice(1); const body = raw.slice(1);
// Skip meta lines (e.g., "\ No newline at end of file"): do not display, do not advance counters, // Skip meta lines (e.g., "\ No newline at end of file")
// and do not clear pairing state.
if (ch === "\\") continue; if (ch === "\\") continue;
// Helper to find next non-meta '+' index const spans = syntaxForHunk[displayLineIdx];
const findNextPlus = (start: number): string | undefined => { displayLineIdx++;
for (let j = start + 1; j < h.lines.length; j++) {
const nextLine = h.lines[j];
if (!nextLine) continue;
const r = nextLine.raw || "";
if (r.charAt(0) === "\\") continue; // skip meta
if (r.startsWith("+")) return r.slice(1);
break; // stop at first non-meta non-plus
}
return undefined;
};
// Helper to find previous non-meta '-' index
const findPrevMinus = (start: number): string | undefined => {
for (let k = start - 1; k >= 0; k--) {
const prevLine = h.lines[k];
if (!prevLine) continue;
const r = prevLine.raw || "";
if (r.charAt(0) === "\\") continue; // skip meta
if (r.startsWith("-")) return r.slice(1);
break; // stop at first non-meta non-minus
}
return undefined;
};
if (ch === " ") { if (ch === " ") {
rows.push({ kind: "context", displayNo: oldNo, text: body }); rows.push({
kind: "context",
displayNo: oldNo,
text: body,
syntaxSpans: spans,
});
oldNo++; oldNo++;
newNo++; newNo++;
lastRemovalNo = null; lastRemovalNo = null;
@@ -354,25 +316,22 @@ export function AdvancedDiffRenderer(
kind: "remove", kind: "remove",
displayNo: oldNo, displayNo: oldNo,
text: body, text: body,
pairText: findNextPlus(i), syntaxSpans: spans,
}); });
lastRemovalNo = oldNo; lastRemovalNo = oldNo;
oldNo++; oldNo++;
} else if (ch === "+") { } else if (ch === "+") {
// For insertions (no preceding '-'), use newNo for display number.
// For single-line replacements, share the old number from the '-' line.
const displayNo = lastRemovalNo !== null ? lastRemovalNo : newNo; const displayNo = lastRemovalNo !== null ? lastRemovalNo : newNo;
rows.push({ rows.push({ kind: "add", displayNo, text: body, syntaxSpans: spans });
kind: "add",
displayNo,
text: body,
pairText: findPrevMinus(i),
});
newNo++; newNo++;
lastRemovalNo = null; lastRemovalNo = null;
} else { } else {
// Unknown marker, treat as context rows.push({
rows.push({ kind: "context", displayNo: oldNo, text: raw }); kind: "context",
displayNo: oldNo,
text: raw,
syntaxSpans: spans,
});
oldNo++; oldNo++;
newNo++; newNo++;
lastRemovalNo = null; lastRemovalNo = null;
@@ -452,38 +411,18 @@ export function AdvancedDiffRenderer(
</Box> </Box>
</> </>
) : null} ) : null}
{rows.map((r, idx) => {rows.map((r, idx) => (
showHeader ? ( <Line
<Box key={`row-${idx}-${r.kind}-${r.displayNo || idx}`}
key={`row-${idx}-${r.kind}-${r.displayNo || idx}`} kind={r.kind}
flexDirection="row" displayNo={r.displayNo}
> text={r.text}
<Box width={toolResultGutter} flexShrink={0}> syntaxSpans={r.syntaxSpans}
<Text>{" "}</Text> gutterWidth={gutterWidth}
</Box> columns={columns}
<Line indent={showHeader ? " ".repeat(toolResultGutter) : ""}
kind={r.kind} />
displayNo={r.displayNo} ))}
text={r.text}
pairText={r.pairText}
gutterWidth={gutterWidth}
columns={columns - toolResultGutter}
enableWord={enableWord}
/>
</Box>
) : (
<Line
key={`row-${idx}-${r.kind}-${r.displayNo || idx}`}
kind={r.kind}
displayNo={r.displayNo}
text={r.text}
pairText={r.pairText}
gutterWidth={gutterWidth}
columns={columns}
enableWord={enableWord}
/>
),
)}
</Box> </Box>
); );
} }

View File

@@ -1,11 +1,14 @@
import { relative } from "node:path"; import { relative } from "node:path";
import * as Diff from "diff";
import { Box } from "ink"; import { Box } from "ink";
import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { colors } from "./colors"; import { colors } from "./colors";
import {
highlightCode,
languageFromPath,
type StyledSpan,
} from "./SyntaxHighlightedCommand";
import { Text } from "./Text"; import { Text } from "./Text";
// Helper to format path as relative with ../
/** /**
* Formats a file path for display (matches Claude Code style): * Formats a file path for display (matches Claude Code style):
* - Files within cwd: relative path without ./ prefix * - Files within cwd: relative path without ./ prefix
@@ -14,128 +17,116 @@ import { Text } from "./Text";
function formatDisplayPath(filePath: string): string { function formatDisplayPath(filePath: string): string {
const cwd = process.cwd(); const cwd = process.cwd();
const relativePath = relative(cwd, filePath); const relativePath = relative(cwd, filePath);
// If path goes outside cwd (starts with ..), show full absolute path
if (relativePath.startsWith("..")) { if (relativePath.startsWith("..")) {
return filePath; return filePath;
} }
return relativePath; return relativePath;
} }
// Helper to count lines in a string
function countLines(str: string): number { function countLines(str: string): number {
if (!str) return 0; if (!str) return 0;
return str.split("\n").length; return str.split("\n").length;
} }
// Helper to render a diff line with word-level highlighting // A styled text chunk with optional color/dim for row-splitting.
type StyledChunk = { text: string; color?: string; dimColor?: boolean };
// Split styled chunks into rows of exactly `cols` characters, padding the last row.
// Continuation rows start with a blank indent of `contIndent` characters
// (matching Codex's empty-gutter + 2-space continuation, diff_render.rs:922-929).
function buildPaddedRows(
chunks: StyledChunk[],
cols: number,
contIndent: number,
): StyledChunk[][] {
if (cols <= 0) return [chunks];
const rows: StyledChunk[][] = [];
let row: StyledChunk[] = [];
let len = 0;
for (const chunk of chunks) {
let rem = chunk.text;
while (rem.length > 0) {
const space = cols - len;
if (rem.length <= space) {
row.push({ text: rem, color: chunk.color, dimColor: chunk.dimColor });
len += rem.length;
rem = "";
} else {
row.push({
text: rem.slice(0, space),
color: chunk.color,
dimColor: chunk.dimColor,
});
rows.push(row);
// Start continuation row with blank gutter indent
row = [{ text: " ".repeat(contIndent) }];
len = contIndent;
rem = rem.slice(space);
}
}
}
if (len < cols) row.push({ text: " ".repeat(cols - len) });
if (row.length > 0) rows.push(row);
return rows;
}
// Render a single diff line split into full-width rows.
interface DiffLineProps { interface DiffLineProps {
lineNumber: number; lineNumber: number;
type: "add" | "remove"; type: "add" | "remove";
content: string; content: string;
compareContent?: string; // The other version to compare against for word diff syntaxSpans?: StyledSpan[];
showLineNumbers?: boolean;
columns: number; columns: number;
showLineNumbers?: boolean; // Whether to show line numbers (default true)
} }
function DiffLine({ function DiffLine({
lineNumber, lineNumber,
type, type,
content, content,
compareContent, syntaxSpans,
columns,
showLineNumbers = true, showLineNumbers = true,
columns,
}: DiffLineProps) { }: DiffLineProps) {
const prefix = type === "add" ? "+" : "-"; const symbolColor =
type === "add" ? colors.diff.symbolAdd : colors.diff.symbolRemove;
const lineBg = const lineBg =
type === "add" ? colors.diff.addedLineBg : colors.diff.removedLineBg; type === "add" ? colors.diff.addedLineBg : colors.diff.removedLineBg;
const wordBg = const prefix = type === "add" ? "+" : "-";
type === "add" ? colors.diff.addedWordBg : colors.diff.removedWordBg;
const gutterWidth = 4; // " " indent to align with tool return prefix // Build styled chunks for the full line.
const contentWidth = Math.max(0, columns - gutterWidth); const indent = " ";
const numStr = showLineNumbers ? `${lineNumber} ` : "";
// Build the line prefix (with or without line number) const chunks: StyledChunk[] = [{ text: indent }];
const linePrefix = showLineNumbers if (showLineNumbers) chunks.push({ text: numStr, dimColor: true });
? `${lineNumber} ${prefix} ` chunks.push({ text: prefix, color: symbolColor });
: `${prefix} `; chunks.push({ text: " " }); // gap after sign
if (syntaxSpans && syntaxSpans.length > 0) {
// If we have something to compare against, do word-level diff for (const span of syntaxSpans) {
if (compareContent !== undefined && content.trim() && compareContent.trim()) { chunks.push({ text: span.text, color: span.color });
const wordDiffs = }
type === "add" } else {
? Diff.diffWords(compareContent, content) chunks.push({ text: content });
: Diff.diffWords(content, compareContent);
return (
<Box flexDirection="row">
<Box width={gutterWidth} flexShrink={0}>
<Text>{" "}</Text>
</Box>
<Box flexGrow={1} width={contentWidth}>
<Text wrap="wrap">
<Text backgroundColor={lineBg} color={colors.diff.textOnDark}>
{linePrefix}
</Text>
{wordDiffs.map((part, i) => {
if (part.added && type === "add") {
// This part was added (show with brighter background, black text)
return (
<Text
key={`word-${i}-${part.value.substring(0, 10)}`}
backgroundColor={wordBg}
color={colors.diff.textOnHighlight}
>
{part.value}
</Text>
);
} else if (part.removed && type === "remove") {
// This part was removed (show with brighter background, black text)
return (
<Text
key={`word-${i}-${part.value.substring(0, 10)}`}
backgroundColor={wordBg}
color={colors.diff.textOnHighlight}
>
{part.value}
</Text>
);
} else if (!part.added && !part.removed) {
// Unchanged part (show with line background, white text)
return (
<Text
key={`word-${i}-${part.value.substring(0, 10)}`}
backgroundColor={lineBg}
color={colors.diff.textOnDark}
>
{part.value}
</Text>
);
}
// Skip parts that don't belong in this line
return null;
})}
</Text>
</Box>
</Box>
);
} }
// No comparison, just show the whole line with one background // Continuation indent = indent + lineNum + sign + gap (blank, same width)
const contIndent = indent.length + numStr.length + 1 + 2;
const rows = buildPaddedRows(chunks, columns, contIndent);
return ( return (
<Box flexDirection="row"> <>
<Box width={gutterWidth} flexShrink={0}> {rows.map((row, ri) => (
<Text>{" "}</Text> // biome-ignore lint/suspicious/noArrayIndexKey: rows are static, never reorder
</Box> <Text key={ri} backgroundColor={lineBg} dimColor={type === "remove"}>
<Box flexGrow={1} width={contentWidth}> {row.map((c, ci) => (
<Text // biome-ignore lint/suspicious/noArrayIndexKey: chunks are static
backgroundColor={lineBg} <Text key={ci} color={c.color} dimColor={c.dimColor}>
color={colors.diff.textOnDark} {c.text}
wrap="wrap" </Text>
> ))}
{`${linePrefix}${content}`}
</Text> </Text>
</Box> ))}
</Box> </>
); );
} }
@@ -201,16 +192,15 @@ export function EditRenderer({
const oldLines = oldString.split("\n"); const oldLines = oldString.split("\n");
const newLines = newString.split("\n"); const newLines = newString.split("\n");
// For the summary
const additions = newLines.length; const additions = newLines.length;
const removals = oldLines.length; const removals = oldLines.length;
// Try to match up lines for word-level diff // Highlight old and new blocks separately for syntax coloring.
// This is a simple approach - for single-line changes, compare directly const lang = languageFromPath(filePath);
// For multi-line, we could do more sophisticated matching const oldHighlighted = lang ? highlightCode(oldString, lang) : undefined;
const singleLineEdit = oldLines.length === 1 && newLines.length === 1; const newHighlighted = lang ? highlightCode(newString, lang) : undefined;
const gutterWidth = 4; // " " indent to align with tool return prefix const gutterWidth = 4;
const contentWidth = Math.max(0, columns - gutterWidth); const contentWidth = Math.max(0, columns - gutterWidth);
return ( return (
@@ -233,29 +223,27 @@ export function EditRenderer({
</Box> </Box>
</Box> </Box>
{/* Show removals */}
{oldLines.map((line, i) => ( {oldLines.map((line, i) => (
<DiffLine <DiffLine
key={`old-${i}-${line.substring(0, 20)}`} key={`old-${i}-${line.substring(0, 20)}`}
lineNumber={i + 1} lineNumber={i + 1}
type="remove" type="remove"
content={line} content={line}
compareContent={singleLineEdit ? newLines[0] : undefined} syntaxSpans={oldHighlighted?.[i]}
columns={columns}
showLineNumbers={showLineNumbers} showLineNumbers={showLineNumbers}
columns={columns}
/> />
))} ))}
{/* Show additions */}
{newLines.map((line, i) => ( {newLines.map((line, i) => (
<DiffLine <DiffLine
key={`new-${i}-${line.substring(0, 20)}`} key={`new-${i}-${line.substring(0, 20)}`}
lineNumber={i + 1} lineNumber={i + 1}
type="add" type="add"
content={line} content={line}
compareContent={singleLineEdit ? oldLines[0] : undefined} syntaxSpans={newHighlighted?.[i]}
columns={columns}
showLineNumbers={showLineNumbers} showLineNumbers={showLineNumbers}
columns={columns}
/> />
))} ))}
</Box> </Box>
@@ -279,7 +267,6 @@ export function MultiEditRenderer({
const columns = useTerminalWidth(); const columns = useTerminalWidth();
const relativePath = formatDisplayPath(filePath); const relativePath = formatDisplayPath(filePath);
// Count total additions and removals
let totalAdditions = 0; let totalAdditions = 0;
let totalRemovals = 0; let totalRemovals = 0;
@@ -288,7 +275,8 @@ export function MultiEditRenderer({
totalRemovals += countLines(edit.old_string); totalRemovals += countLines(edit.old_string);
}); });
const gutterWidth = 4; // " " indent to align with tool return prefix const lang = languageFromPath(filePath);
const gutterWidth = 4;
const contentWidth = Math.max(0, columns - gutterWidth); const contentWidth = Math.max(0, columns - gutterWidth);
return ( return (
@@ -311,11 +299,15 @@ export function MultiEditRenderer({
</Box> </Box>
</Box> </Box>
{/* For multi-edit, show each edit sequentially */}
{edits.map((edit, index) => { {edits.map((edit, index) => {
const oldLines = edit.old_string.split("\n"); const oldLines = edit.old_string.split("\n");
const newLines = edit.new_string.split("\n"); const newLines = edit.new_string.split("\n");
const singleLineEdit = oldLines.length === 1 && newLines.length === 1; const oldHighlighted = lang
? highlightCode(edit.old_string, lang)
: undefined;
const newHighlighted = lang
? highlightCode(edit.new_string, lang)
: undefined;
return ( return (
<Box <Box
@@ -328,11 +320,9 @@ export function MultiEditRenderer({
lineNumber={i + 1} lineNumber={i + 1}
type="remove" type="remove"
content={line} content={line}
compareContent={ syntaxSpans={oldHighlighted?.[i]}
singleLineEdit && i === 0 ? newLines[0] : undefined
}
columns={columns}
showLineNumbers={showLineNumbers} showLineNumbers={showLineNumbers}
columns={columns}
/> />
))} ))}
{newLines.map((line, i) => ( {newLines.map((line, i) => (
@@ -341,11 +331,9 @@ export function MultiEditRenderer({
lineNumber={i + 1} lineNumber={i + 1}
type="add" type="add"
content={line} content={line}
compareContent={ syntaxSpans={newHighlighted?.[i]}
singleLineEdit && i === 0 ? oldLines[0] : undefined
}
columns={columns}
showLineNumbers={showLineNumbers} showLineNumbers={showLineNumbers}
columns={columns}
/> />
))} ))}
</Box> </Box>

View File

@@ -19,7 +19,74 @@ type Props = {
type ShellSyntaxPalette = typeof colors.shellSyntax; type ShellSyntaxPalette = typeof colors.shellSyntax;
/** Styled text span with a resolved color. */ /** Styled text span with a resolved color. */
type StyledSpan = { text: string; color: string }; export type StyledSpan = { text: string; color: string };
/** Map file extension to a lowlight language name. */
const EXT_TO_LANG: Record<string, string> = {
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
mjs: "javascript",
cjs: "javascript",
py: "python",
rs: "rust",
go: "go",
java: "java",
rb: "ruby",
c: "c",
h: "c",
cpp: "cpp",
cc: "cpp",
cxx: "cpp",
cs: "csharp",
swift: "swift",
kt: "kotlin",
scala: "scala",
php: "php",
css: "css",
scss: "scss",
less: "less",
html: "xml",
htm: "xml",
xml: "xml",
svg: "xml",
json: "json",
yaml: "yaml",
yml: "yaml",
toml: "ini",
ini: "ini",
md: "markdown",
mdx: "markdown",
sql: "sql",
sh: "bash",
bash: "bash",
zsh: "bash",
fish: "bash",
makefile: "makefile",
dockerfile: "dockerfile",
r: "r",
lua: "lua",
perl: "perl",
pl: "perl",
diff: "diff",
graphql: "graphql",
gql: "graphql",
wasm: "wasm",
};
/** Resolve a lowlight language name from a file path, or undefined if unknown. */
export function languageFromPath(filePath: string): string | undefined {
const basename = filePath.split("/").pop() ?? filePath;
const lower = basename.toLowerCase();
// Handle dotfiles like "Makefile", "Dockerfile"
if (lower === "makefile") return "makefile";
if (lower === "dockerfile") return "dockerfile";
const dotIdx = basename.lastIndexOf(".");
if (dotIdx < 0) return undefined;
const ext = basename.slice(dotIdx + 1).toLowerCase();
return EXT_TO_LANG[ext];
}
function colorForClassName( function colorForClassName(
className: string, className: string,
@@ -68,7 +135,7 @@ function colorForClassName(
* Walk the HAST tree depth-first, collecting flat StyledSpan entries. * Walk the HAST tree depth-first, collecting flat StyledSpan entries.
* Newlines within text nodes are preserved so callers can split into lines. * Newlines within text nodes are preserved so callers can split into lines.
*/ */
function collectSpans( export function collectSpans(
node: RootContent | ElementContent, node: RootContent | ElementContent,
palette: ShellSyntaxPalette, palette: ShellSyntaxPalette,
spans: StyledSpan[], spans: StyledSpan[],
@@ -95,14 +162,90 @@ function collectSpans(
} }
} }
// Detect heredoc: first line ends with << 'MARKER', << "MARKER", or << MARKER.
const HEREDOC_RE = /<<-?\s*['"]?(\w+)['"]?\s*$/;
// Extract redirect target filename: > filepath or >> filepath before the <<.
const REDIRECT_FILE_RE = />>?\s+(\S+)/;
/** /**
* Highlight the full command at once (preserves heredoc/multi-line parser * Highlight a bash command, with special handling for heredocs.
* state), then split the flat span list at newline boundaries into per-line * When a heredoc is detected, the body is highlighted using the language
* arrays. * inferred from the redirect target filename (e.g. .ts -> typescript).
*/ */
function highlightCommand( function highlightCommand(
command: string, command: string,
palette: ShellSyntaxPalette, palette: ShellSyntaxPalette,
): StyledSpan[][] {
const allLines = command.split("\n");
const firstLine = allLines[0] ?? "";
const heredocMatch = HEREDOC_RE.exec(firstLine);
// If heredoc detected and there's body content, split highlighting.
if (heredocMatch && allLines.length > 2) {
const marker = heredocMatch[1] ?? "EOF";
// Find where the heredoc body ends (the marker terminator line).
let endIdx = allLines.length - 1;
for (let i = allLines.length - 1; i > 0; i--) {
if (allLines[i]?.trim() === marker) {
endIdx = i;
break;
}
}
const bodyLines = allLines.slice(1, endIdx);
const terminatorLine = allLines[endIdx] ?? marker;
// Highlight the first line as bash.
const bashSpans = highlightSingleLineBash(firstLine, palette);
// Determine language from redirect target filename.
const fileMatch = REDIRECT_FILE_RE.exec(
firstLine.slice(0, heredocMatch.index),
);
const targetFile = fileMatch?.[1];
const lang = targetFile ? languageFromPath(targetFile) : undefined;
// Highlight heredoc body with target language.
let bodySpanLines: StyledSpan[][];
if (lang) {
bodySpanLines =
highlightCode(bodyLines.join("\n"), lang) ??
bodyLines.map((l) => [{ text: l, color: palette.text }]);
} else {
bodySpanLines = bodyLines.map((l) => [{ text: l, color: palette.text }]);
}
// Highlight terminator as bash.
const termSpans = highlightSingleLineBash(terminatorLine, palette);
return [bashSpans, ...bodySpanLines, termSpans];
}
// No heredoc: highlight full command as bash.
return highlightFullBash(command, palette);
}
/** Highlight a single line as bash, returning a flat StyledSpan array. */
function highlightSingleLineBash(
line: string,
palette: ShellSyntaxPalette,
): StyledSpan[] {
try {
const root = lowlight.highlight(BASH_LANGUAGE, line);
const spans: StyledSpan[] = [];
for (const child of root.children) {
collectSpans(child, palette, spans);
}
return spans;
} catch {
return [{ text: line, color: palette.text }];
}
}
/** Highlight full multi-line text as bash, split at newline boundaries. */
function highlightFullBash(
command: string,
palette: ShellSyntaxPalette,
): StyledSpan[][] { ): StyledSpan[][] {
let spans: StyledSpan[]; let spans: StyledSpan[];
try { try {
@@ -112,13 +255,50 @@ function highlightCommand(
collectSpans(child, palette, spans); collectSpans(child, palette, spans);
} }
} catch { } catch {
// Fallback: plain text, split by newlines.
return command return command
.split("\n") .split("\n")
.map((line) => [{ text: line, color: palette.text }]); .map((line) => [{ text: line, color: palette.text }]);
} }
// Split spans at newline characters into separate lines. const lines: StyledSpan[][] = [[]];
for (const span of spans) {
const parts = span.text.split("\n");
for (let i = 0; i < parts.length; i++) {
if (i > 0) {
lines.push([]);
}
const part = parts[i];
if (part && part.length > 0) {
const currentLine = lines[lines.length - 1];
currentLine?.push({ text: part, color: span.color });
}
}
}
return lines;
}
/**
* Highlight code in any language, returning per-line StyledSpan arrays.
* Highlights the full text at once to preserve multi-line parser state,
* then splits at newline boundaries.
* Returns undefined when the language is not recognized.
*/
export function highlightCode(
code: string,
language: string,
): StyledSpan[][] | undefined {
const palette = colors.shellSyntax;
let spans: StyledSpan[];
try {
const root = lowlight.highlight(language, code);
spans = [];
for (const child of root.children) {
collectSpans(child, palette, spans);
}
} catch {
return undefined;
}
const lines: StyledSpan[][] = [[]]; const lines: StyledSpan[][] = [[]];
for (const span of spans) { for (const span of spans) {
const parts = span.text.split("\n"); const parts = span.text.split("\n");
@@ -154,8 +334,8 @@ export const SyntaxHighlightedCommand = memo(
) : null} ) : null}
<Text color={palette.text}> <Text color={palette.text}>
{lineIdx === 0 && prefix ? prefix : null} {lineIdx === 0 && prefix ? prefix : null}
{spans.map((span) => ( {spans.map((span, si) => (
<Text key={`${span.color}:${span.text}`} color={span.color}> <Text key={`${si}:${span.color}`} color={span.color}>
{span.text} {span.text}
</Text> </Text>
))} ))}

View File

@@ -209,13 +209,15 @@ const _colors = {
prompt: brandColors.primaryAccent, prompt: brandColors.primaryAccent,
}, },
// Diff rendering // Diff rendering (line bg palette matches Codex CLI dark theme, see
// ~/dev/codex/codex-rs/tui/src/diff_render.rs lines 60-61)
diff: { diff: {
addedLineBg: "#1a4d1a", addedLineBg: "#213A2B",
addedWordBg: "#2d7a2d", removedLineBg: "#4A221D",
removedLineBg: "#4d1a1a",
removedWordBg: "#7a2d2d",
contextLineBg: undefined, contextLineBg: undefined,
// Word-level highlight colors (used by MemoryDiffRenderer)
addedWordBg: "#2d7a2d",
removedWordBg: "#7a2d2d",
textOnDark: "white", textOnDark: "white",
textOnHighlight: "white", textOnHighlight: "white",
symbolAdd: "green", symbolAdd: "green",