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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user