feat(tui): improved diff rendering with syntax highlighting (#1349)
Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { relative } from "node:path";
|
||||
import * as Diff from "diff";
|
||||
import { Box } from "ink";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
@@ -10,6 +9,11 @@ import {
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { colors } from "./colors";
|
||||
import { EditRenderer, MultiEditRenderer, WriteRenderer } from "./DiffRenderer";
|
||||
import {
|
||||
highlightCode,
|
||||
languageFromPath,
|
||||
type StyledSpan,
|
||||
} from "./SyntaxHighlightedCommand";
|
||||
import { Text } from "./Text";
|
||||
|
||||
type EditItem = {
|
||||
@@ -54,38 +58,67 @@ function padLeft(n: number, width: number): string {
|
||||
return s.length >= width ? s : " ".repeat(width - s.length) + s;
|
||||
}
|
||||
|
||||
// Calculate word-level similarity between two strings (0-1)
|
||||
// Used to decide whether to show word-level highlighting
|
||||
function wordSimilarity(a: string, b: string): number {
|
||||
const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(Boolean));
|
||||
const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(Boolean));
|
||||
if (wordsA.size === 0 && wordsB.size === 0) return 1;
|
||||
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
||||
const intersection = [...wordsA].filter((w) => wordsB.has(w)).length;
|
||||
const union = new Set([...wordsA, ...wordsB]).size;
|
||||
return intersection / union; // Jaccard similarity
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Threshold: only show word-level highlighting if lines share enough words
|
||||
const WORD_SIMILARITY_THRESHOLD = 0.3;
|
||||
|
||||
// Render a single line with gutters and optional word-diff highlighting
|
||||
// Render a single diff line split into full-width rows.
|
||||
// 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.
|
||||
function Line({
|
||||
kind,
|
||||
displayNo,
|
||||
text,
|
||||
pairText,
|
||||
syntaxSpans,
|
||||
gutterWidth,
|
||||
columns,
|
||||
enableWord,
|
||||
indent,
|
||||
}: {
|
||||
kind: "context" | "remove" | "add";
|
||||
displayNo: number;
|
||||
text: string;
|
||||
pairText?: string; // when '-' followed by '+' to highlight words
|
||||
syntaxSpans?: StyledSpan[];
|
||||
gutterWidth: number;
|
||||
columns: number;
|
||||
enableWord: boolean;
|
||||
indent: string;
|
||||
}) {
|
||||
const symbol = kind === "add" ? "+" : kind === "remove" ? "-" : " ";
|
||||
const symbolColor =
|
||||
@@ -100,116 +133,40 @@ function Line({
|
||||
: kind === "remove"
|
||||
? colors.diff.removedLineBg
|
||||
: 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
|
||||
// If lines are too different, word-level highlighting becomes noise - show full-line colors instead
|
||||
const similarity =
|
||||
enableWord && pairText ? wordSimilarity(text, pairText) : 0;
|
||||
const charParts: Array<{
|
||||
value: string;
|
||||
added?: boolean;
|
||||
removed?: boolean;
|
||||
}> | null =
|
||||
enableWord &&
|
||||
pairText &&
|
||||
(kind === "add" || kind === "remove") &&
|
||||
pairText !== text &&
|
||||
similarity >= WORD_SIMILARITY_THRESHOLD
|
||||
? kind === "add"
|
||||
? Diff.diffWordsWithSpace(pairText, text)
|
||||
: Diff.diffWordsWithSpace(text, pairText)
|
||||
: null;
|
||||
// Build styled chunks: indent + gutter + sign + content
|
||||
const gutterStr = `${padLeft(displayNo, gutterWidth)} `;
|
||||
const chunks: StyledChunk[] = [];
|
||||
if (indent) chunks.push({ text: indent });
|
||||
chunks.push({ text: gutterStr, dimColor: kind === "context" });
|
||||
chunks.push({ text: symbol, color: symbolColor });
|
||||
chunks.push({ text: " " });
|
||||
if (syntaxSpans && syntaxSpans.length > 0) {
|
||||
for (const span of syntaxSpans) {
|
||||
chunks.push({ text: span.text, color: span.color });
|
||||
}
|
||||
} else {
|
||||
chunks.push({ text });
|
||||
}
|
||||
|
||||
// Build prefix: " 1 + " (line number + symbol)
|
||||
const linePrefix = `${padLeft(displayNo, gutterWidth)} ${symbol} `;
|
||||
const prefixWidth = linePrefix.length;
|
||||
const contentWidth = Math.max(0, columns - prefixWidth);
|
||||
// Continuation indent = indent + gutter + sign + space (blank, same width)
|
||||
const contIndent = indent.length + gutterStr.length + 1 + 1;
|
||||
const rows = buildPaddedRows(chunks, columns, contIndent);
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth} flexShrink={0}>
|
||||
<Text dimColor={kind === "context"}>
|
||||
{padLeft(displayNo, gutterWidth)}{" "}
|
||||
<Text color={symbolColor}>{symbol}</Text>{" "}
|
||||
<>
|
||||
{rows.map((row, ri) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: rows are static, never reorder
|
||||
<Text key={ri} backgroundColor={bgLine} dimColor={kind === "remove"}>
|
||||
{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>
|
||||
</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 relative = formatRelativePath((props as { filePath: string }).filePath);
|
||||
const enableWord = props.kind !== "multi_edit";
|
||||
const filePath = (props as { filePath: string }).filePath;
|
||||
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 = {
|
||||
kind: "context" | "remove" | "add";
|
||||
displayNo: number;
|
||||
text: string;
|
||||
pairText?: string;
|
||||
syntaxSpans?: StyledSpan[];
|
||||
};
|
||||
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 newNo = h.newStart;
|
||||
let lastRemovalNo: number | null = null;
|
||||
let displayLineIdx = 0; // index into syntaxForHunk
|
||||
for (let i = 0; i < h.lines.length; i++) {
|
||||
const line = h.lines[i];
|
||||
if (!line) continue;
|
||||
const raw = line.raw || "";
|
||||
const ch = raw.charAt(0);
|
||||
const body = raw.slice(1);
|
||||
// Skip meta lines (e.g., "\ No newline at end of file"): do not display, do not advance counters,
|
||||
// and do not clear pairing state.
|
||||
// Skip meta lines (e.g., "\ No newline at end of file")
|
||||
if (ch === "\\") continue;
|
||||
|
||||
// Helper to find next non-meta '+' index
|
||||
const findNextPlus = (start: number): string | undefined => {
|
||||
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;
|
||||
};
|
||||
const spans = syntaxForHunk[displayLineIdx];
|
||||
displayLineIdx++;
|
||||
|
||||
if (ch === " ") {
|
||||
rows.push({ kind: "context", displayNo: oldNo, text: body });
|
||||
rows.push({
|
||||
kind: "context",
|
||||
displayNo: oldNo,
|
||||
text: body,
|
||||
syntaxSpans: spans,
|
||||
});
|
||||
oldNo++;
|
||||
newNo++;
|
||||
lastRemovalNo = null;
|
||||
@@ -354,25 +316,22 @@ export function AdvancedDiffRenderer(
|
||||
kind: "remove",
|
||||
displayNo: oldNo,
|
||||
text: body,
|
||||
pairText: findNextPlus(i),
|
||||
syntaxSpans: spans,
|
||||
});
|
||||
lastRemovalNo = oldNo;
|
||||
oldNo++;
|
||||
} 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;
|
||||
rows.push({
|
||||
kind: "add",
|
||||
displayNo,
|
||||
text: body,
|
||||
pairText: findPrevMinus(i),
|
||||
});
|
||||
rows.push({ kind: "add", displayNo, text: body, syntaxSpans: spans });
|
||||
newNo++;
|
||||
lastRemovalNo = null;
|
||||
} else {
|
||||
// Unknown marker, treat as context
|
||||
rows.push({ kind: "context", displayNo: oldNo, text: raw });
|
||||
rows.push({
|
||||
kind: "context",
|
||||
displayNo: oldNo,
|
||||
text: raw,
|
||||
syntaxSpans: spans,
|
||||
});
|
||||
oldNo++;
|
||||
newNo++;
|
||||
lastRemovalNo = null;
|
||||
@@ -452,38 +411,18 @@ export function AdvancedDiffRenderer(
|
||||
</Box>
|
||||
</>
|
||||
) : null}
|
||||
{rows.map((r, idx) =>
|
||||
showHeader ? (
|
||||
<Box
|
||||
key={`row-${idx}-${r.kind}-${r.displayNo || idx}`}
|
||||
flexDirection="row"
|
||||
>
|
||||
<Box width={toolResultGutter} flexShrink={0}>
|
||||
<Text>{" "}</Text>
|
||||
</Box>
|
||||
<Line
|
||||
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}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{rows.map((r, idx) => (
|
||||
<Line
|
||||
key={`row-${idx}-${r.kind}-${r.displayNo || idx}`}
|
||||
kind={r.kind}
|
||||
displayNo={r.displayNo}
|
||||
text={r.text}
|
||||
syntaxSpans={r.syntaxSpans}
|
||||
gutterWidth={gutterWidth}
|
||||
columns={columns}
|
||||
indent={showHeader ? " ".repeat(toolResultGutter) : ""}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { relative } from "node:path";
|
||||
import * as Diff from "diff";
|
||||
import { Box } from "ink";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { colors } from "./colors";
|
||||
import {
|
||||
highlightCode,
|
||||
languageFromPath,
|
||||
type StyledSpan,
|
||||
} from "./SyntaxHighlightedCommand";
|
||||
import { Text } from "./Text";
|
||||
|
||||
// Helper to format path as relative with ../
|
||||
/**
|
||||
* Formats a file path for display (matches Claude Code style):
|
||||
* - Files within cwd: relative path without ./ prefix
|
||||
@@ -14,128 +17,116 @@ import { Text } from "./Text";
|
||||
function formatDisplayPath(filePath: string): string {
|
||||
const cwd = process.cwd();
|
||||
const relativePath = relative(cwd, filePath);
|
||||
// If path goes outside cwd (starts with ..), show full absolute path
|
||||
if (relativePath.startsWith("..")) {
|
||||
return filePath;
|
||||
}
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
// Helper to count lines in a string
|
||||
function countLines(str: string): number {
|
||||
if (!str) return 0;
|
||||
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 {
|
||||
lineNumber: number;
|
||||
type: "add" | "remove";
|
||||
content: string;
|
||||
compareContent?: string; // The other version to compare against for word diff
|
||||
syntaxSpans?: StyledSpan[];
|
||||
showLineNumbers?: boolean;
|
||||
columns: number;
|
||||
showLineNumbers?: boolean; // Whether to show line numbers (default true)
|
||||
}
|
||||
|
||||
function DiffLine({
|
||||
lineNumber,
|
||||
type,
|
||||
content,
|
||||
compareContent,
|
||||
columns,
|
||||
syntaxSpans,
|
||||
showLineNumbers = true,
|
||||
columns,
|
||||
}: DiffLineProps) {
|
||||
const prefix = type === "add" ? "+" : "-";
|
||||
const symbolColor =
|
||||
type === "add" ? colors.diff.symbolAdd : colors.diff.symbolRemove;
|
||||
const lineBg =
|
||||
type === "add" ? colors.diff.addedLineBg : colors.diff.removedLineBg;
|
||||
const wordBg =
|
||||
type === "add" ? colors.diff.addedWordBg : colors.diff.removedWordBg;
|
||||
const prefix = type === "add" ? "+" : "-";
|
||||
|
||||
const gutterWidth = 4; // " " indent to align with tool return prefix
|
||||
const contentWidth = Math.max(0, columns - gutterWidth);
|
||||
|
||||
// Build the line prefix (with or without line number)
|
||||
const linePrefix = showLineNumbers
|
||||
? `${lineNumber} ${prefix} `
|
||||
: `${prefix} `;
|
||||
|
||||
// If we have something to compare against, do word-level diff
|
||||
if (compareContent !== undefined && content.trim() && compareContent.trim()) {
|
||||
const wordDiffs =
|
||||
type === "add"
|
||||
? Diff.diffWords(compareContent, 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>
|
||||
);
|
||||
// Build styled chunks for the full line.
|
||||
const indent = " ";
|
||||
const numStr = showLineNumbers ? `${lineNumber} ` : "";
|
||||
const chunks: StyledChunk[] = [{ text: indent }];
|
||||
if (showLineNumbers) chunks.push({ text: numStr, dimColor: true });
|
||||
chunks.push({ text: prefix, color: symbolColor });
|
||||
chunks.push({ text: " " }); // gap after sign
|
||||
if (syntaxSpans && syntaxSpans.length > 0) {
|
||||
for (const span of syntaxSpans) {
|
||||
chunks.push({ text: span.text, color: span.color });
|
||||
}
|
||||
} else {
|
||||
chunks.push({ text: content });
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Box flexDirection="row">
|
||||
<Box width={gutterWidth} flexShrink={0}>
|
||||
<Text>{" "}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text
|
||||
backgroundColor={lineBg}
|
||||
color={colors.diff.textOnDark}
|
||||
wrap="wrap"
|
||||
>
|
||||
{`${linePrefix}${content}`}
|
||||
<>
|
||||
{rows.map((row, ri) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: rows are static, never reorder
|
||||
<Text key={ri} backgroundColor={lineBg} dimColor={type === "remove"}>
|
||||
{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>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,16 +192,15 @@ export function EditRenderer({
|
||||
const oldLines = oldString.split("\n");
|
||||
const newLines = newString.split("\n");
|
||||
|
||||
// For the summary
|
||||
const additions = newLines.length;
|
||||
const removals = oldLines.length;
|
||||
|
||||
// Try to match up lines for word-level diff
|
||||
// This is a simple approach - for single-line changes, compare directly
|
||||
// For multi-line, we could do more sophisticated matching
|
||||
const singleLineEdit = oldLines.length === 1 && newLines.length === 1;
|
||||
// Highlight old and new blocks separately for syntax coloring.
|
||||
const lang = languageFromPath(filePath);
|
||||
const oldHighlighted = lang ? highlightCode(oldString, lang) : undefined;
|
||||
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);
|
||||
|
||||
return (
|
||||
@@ -233,29 +223,27 @@ export function EditRenderer({
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Show removals */}
|
||||
{oldLines.map((line, i) => (
|
||||
<DiffLine
|
||||
key={`old-${i}-${line.substring(0, 20)}`}
|
||||
lineNumber={i + 1}
|
||||
type="remove"
|
||||
content={line}
|
||||
compareContent={singleLineEdit ? newLines[0] : undefined}
|
||||
columns={columns}
|
||||
syntaxSpans={oldHighlighted?.[i]}
|
||||
showLineNumbers={showLineNumbers}
|
||||
columns={columns}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Show additions */}
|
||||
{newLines.map((line, i) => (
|
||||
<DiffLine
|
||||
key={`new-${i}-${line.substring(0, 20)}`}
|
||||
lineNumber={i + 1}
|
||||
type="add"
|
||||
content={line}
|
||||
compareContent={singleLineEdit ? oldLines[0] : undefined}
|
||||
columns={columns}
|
||||
syntaxSpans={newHighlighted?.[i]}
|
||||
showLineNumbers={showLineNumbers}
|
||||
columns={columns}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
@@ -279,7 +267,6 @@ export function MultiEditRenderer({
|
||||
const columns = useTerminalWidth();
|
||||
const relativePath = formatDisplayPath(filePath);
|
||||
|
||||
// Count total additions and removals
|
||||
let totalAdditions = 0;
|
||||
let totalRemovals = 0;
|
||||
|
||||
@@ -288,7 +275,8 @@ export function MultiEditRenderer({
|
||||
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);
|
||||
|
||||
return (
|
||||
@@ -311,11 +299,15 @@ export function MultiEditRenderer({
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* For multi-edit, show each edit sequentially */}
|
||||
{edits.map((edit, index) => {
|
||||
const oldLines = edit.old_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 (
|
||||
<Box
|
||||
@@ -328,11 +320,9 @@ export function MultiEditRenderer({
|
||||
lineNumber={i + 1}
|
||||
type="remove"
|
||||
content={line}
|
||||
compareContent={
|
||||
singleLineEdit && i === 0 ? newLines[0] : undefined
|
||||
}
|
||||
columns={columns}
|
||||
syntaxSpans={oldHighlighted?.[i]}
|
||||
showLineNumbers={showLineNumbers}
|
||||
columns={columns}
|
||||
/>
|
||||
))}
|
||||
{newLines.map((line, i) => (
|
||||
@@ -341,11 +331,9 @@ export function MultiEditRenderer({
|
||||
lineNumber={i + 1}
|
||||
type="add"
|
||||
content={line}
|
||||
compareContent={
|
||||
singleLineEdit && i === 0 ? oldLines[0] : undefined
|
||||
}
|
||||
columns={columns}
|
||||
syntaxSpans={newHighlighted?.[i]}
|
||||
showLineNumbers={showLineNumbers}
|
||||
columns={columns}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -19,7 +19,74 @@ type Props = {
|
||||
type ShellSyntaxPalette = typeof colors.shellSyntax;
|
||||
|
||||
/** 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(
|
||||
className: string,
|
||||
@@ -68,7 +135,7 @@ function colorForClassName(
|
||||
* Walk the HAST tree depth-first, collecting flat StyledSpan entries.
|
||||
* Newlines within text nodes are preserved so callers can split into lines.
|
||||
*/
|
||||
function collectSpans(
|
||||
export function collectSpans(
|
||||
node: RootContent | ElementContent,
|
||||
palette: ShellSyntaxPalette,
|
||||
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
|
||||
* state), then split the flat span list at newline boundaries into per-line
|
||||
* arrays.
|
||||
* Highlight a bash command, with special handling for heredocs.
|
||||
* When a heredoc is detected, the body is highlighted using the language
|
||||
* inferred from the redirect target filename (e.g. .ts -> typescript).
|
||||
*/
|
||||
function highlightCommand(
|
||||
command: string,
|
||||
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[][] {
|
||||
let spans: StyledSpan[];
|
||||
try {
|
||||
@@ -112,13 +255,50 @@ function highlightCommand(
|
||||
collectSpans(child, palette, spans);
|
||||
}
|
||||
} catch {
|
||||
// Fallback: plain text, split by newlines.
|
||||
return command
|
||||
.split("\n")
|
||||
.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[][] = [[]];
|
||||
for (const span of spans) {
|
||||
const parts = span.text.split("\n");
|
||||
@@ -154,8 +334,8 @@ export const SyntaxHighlightedCommand = memo(
|
||||
) : null}
|
||||
<Text color={palette.text}>
|
||||
{lineIdx === 0 && prefix ? prefix : null}
|
||||
{spans.map((span) => (
|
||||
<Text key={`${span.color}:${span.text}`} color={span.color}>
|
||||
{spans.map((span, si) => (
|
||||
<Text key={`${si}:${span.color}`} color={span.color}>
|
||||
{span.text}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
@@ -209,13 +209,15 @@ const _colors = {
|
||||
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: {
|
||||
addedLineBg: "#1a4d1a",
|
||||
addedWordBg: "#2d7a2d",
|
||||
removedLineBg: "#4d1a1a",
|
||||
removedWordBg: "#7a2d2d",
|
||||
addedLineBg: "#213A2B",
|
||||
removedLineBg: "#4A221D",
|
||||
contextLineBg: undefined,
|
||||
// Word-level highlight colors (used by MemoryDiffRenderer)
|
||||
addedWordBg: "#2d7a2d",
|
||||
removedWordBg: "#7a2d2d",
|
||||
textOnDark: "white",
|
||||
textOnHighlight: "white",
|
||||
symbolAdd: "green",
|
||||
|
||||
Reference in New Issue
Block a user