diff --git a/src/cli/components/AdvancedDiffRenderer.tsx b/src/cli/components/AdvancedDiffRenderer.tsx index 68ada1b..7c4b7ff 100644 --- a/src/cli/components/AdvancedDiffRenderer.tsx +++ b/src/cli/components/AdvancedDiffRenderer.tsx @@ -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 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 ( - - - - {padLeft(displayNo, gutterWidth)}{" "} - {symbol}{" "} + <> + {rows.map((row, ri) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: rows are static, never reorder + + {row.map((c, ci) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: chunks are static + + {c.text} + + ))} - - - {charParts ? ( - - {charParts.map((p, i) => { - // For '-' lines: render removed + unchanged; drop added - if (kind === "remove") { - if (p.removed) - return ( - - {p.value} - - ); - if (!p.added && !p.removed) - return ( - - {p.value} - - ); - return null; // skip added segments on '-' - } - // For '+' lines: render added + unchanged; drop removed - if (kind === "add") { - if (p.added) - return ( - - {p.value} - - ); - if (!p.added && !p.removed) - return ( - - {p.value} - - ); - return null; // skip removed segments on '+' - } - // Context (should not occur with charParts), fall back to full line - return ( - - {p.value} - - ); - })} - - ) : ( - - {text} - - )} - - + ))} + ); } @@ -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( ) : null} - {rows.map((r, idx) => - showHeader ? ( - - - {" "} - - - - ) : ( - - ), - )} + {rows.map((r, idx) => ( + + ))} ); } diff --git a/src/cli/components/DiffRenderer.tsx b/src/cli/components/DiffRenderer.tsx index 9507b57..259dd50 100644 --- a/src/cli/components/DiffRenderer.tsx +++ b/src/cli/components/DiffRenderer.tsx @@ -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 ( - - - {" "} - - - - - {linePrefix} - - {wordDiffs.map((part, i) => { - if (part.added && type === "add") { - // This part was added (show with brighter background, black text) - return ( - - {part.value} - - ); - } else if (part.removed && type === "remove") { - // This part was removed (show with brighter background, black text) - return ( - - {part.value} - - ); - } else if (!part.added && !part.removed) { - // Unchanged part (show with line background, white text) - return ( - - {part.value} - - ); - } - // Skip parts that don't belong in this line - return null; - })} - - - - ); + // 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 ( - - - {" "} - - - - {`${linePrefix}${content}`} + <> + {rows.map((row, ri) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: rows are static, never reorder + + {row.map((c, ci) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: chunks are static + + {c.text} + + ))} - - + ))} + ); } @@ -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({ - {/* Show removals */} {oldLines.map((line, i) => ( ))} - {/* Show additions */} {newLines.map((line, i) => ( ))} @@ -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({ - {/* 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 ( ))} {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} /> ))} diff --git a/src/cli/components/SyntaxHighlightedCommand.tsx b/src/cli/components/SyntaxHighlightedCommand.tsx index d85da1a..85e883f 100644 --- a/src/cli/components/SyntaxHighlightedCommand.tsx +++ b/src/cli/components/SyntaxHighlightedCommand.tsx @@ -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 = { + 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} {lineIdx === 0 && prefix ? prefix : null} - {spans.map((span) => ( - + {spans.map((span, si) => ( + {span.text} ))} diff --git a/src/cli/components/colors.ts b/src/cli/components/colors.ts index 43d0884..7d24c07 100644 --- a/src/cli/components/colors.ts +++ b/src/cli/components/colors.ts @@ -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",