import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; import * as Diff from "diff"; import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js"; /** * Parse diff line to extract prefix, line number, and content. * Supports both numbered lines ("+123 content") and standard unified diff lines ("+content"). */ function parseDiffLine(line: string): { prefix: string; lineNum: string; content: string } | null { const numberedMatch = line.match(/^([+-\s])(\s*\d+)\s(.*)$/); if (numberedMatch) { return { prefix: numberedMatch[1], lineNum: numberedMatch[2], content: numberedMatch[3] }; } if (line.startsWith("+++") || line.startsWith("---")) { return null; } if (line.startsWith("+")) { return { prefix: "+", lineNum: "", content: line.slice(1) }; } if (line.startsWith("-")) { return { prefix: "-", lineNum: "", content: line.slice(1) }; } if (line.startsWith(" ")) { return { prefix: " ", lineNum: "", content: line.slice(1) }; } return null; } /** * Replace tabs with spaces for consistent rendering. */ function replaceTabs(text: string): string { return text.replace(/\t/g, " "); } /** * Compute word-level diff and render with inverse on changed parts. * Uses diffWords which groups whitespace with adjacent words for cleaner highlighting. * Strips leading whitespace from inverse to avoid highlighting indentation. */ function renderIntraLineDiff(oldContent: string, newContent: string): { removedLine: string; addedLine: string } { const wordDiff = Diff.diffWords(oldContent, newContent); let removedLine = ""; let addedLine = ""; let isFirstRemoved = true; let isFirstAdded = true; for (const part of wordDiff) { if (part.removed) { let value = part.value; // Strip leading whitespace from the first removed part if (isFirstRemoved) { const leadingWs = value.match(/^(\s*)/)?.[1] || ""; value = value.slice(leadingWs.length); removedLine += leadingWs; isFirstRemoved = false; } if (value) { removedLine += theme.inverse(value); } } else if (part.added) { let value = part.value; // Strip leading whitespace from the first added part if (isFirstAdded) { const leadingWs = value.match(/^(\s*)/)?.[1] || ""; value = value.slice(leadingWs.length); addedLine += leadingWs; isFirstAdded = false; } if (value) { addedLine += theme.inverse(value); } } else { removedLine += part.value; addedLine += part.value; } } return { removedLine, addedLine }; } export interface RenderDiffOptions { /** File path (unused, kept for API compatibility) */ filePath?: string; } /** * Render a diff string with colored lines and intra-line change highlighting. * - Context lines: dim/gray * - Removed lines: red, with inverse on changed tokens * - Added lines: green, with inverse on changed tokens */ export function renderDiff(diffText: string, _options: RenderDiffOptions = {}): string { const lines = diffText.split("\n"); const result: string[] = []; let i = 0; while (i < lines.length) { const line = lines[i]; const parsed = parseDiffLine(line); if (!parsed) { result.push(theme.fg("toolDiffContext", line)); i++; continue; } if (parsed.prefix === "-") { // Collect consecutive removed lines const removedLines: { lineNum: string; content: string }[] = []; while (i < lines.length) { const p = parseDiffLine(lines[i]); if (!p || p.prefix !== "-") break; removedLines.push({ lineNum: p.lineNum, content: p.content }); i++; } // Collect consecutive added lines const addedLines: { lineNum: string; content: string }[] = []; while (i < lines.length) { const p = parseDiffLine(lines[i]); if (!p || p.prefix !== "+") break; addedLines.push({ lineNum: p.lineNum, content: p.content }); i++; } // Only do intra-line diffing when there's exactly one removed and one added line // (indicating a single line modification). Otherwise, show lines as-is. if (removedLines.length === 1 && addedLines.length === 1) { const removed = removedLines[0]; const added = addedLines[0]; const { removedLine, addedLine } = renderIntraLineDiff( replaceTabs(removed.content), replaceTabs(added.content), ); result.push(theme.fg("toolDiffRemoved", `-${removed.lineNum} ${removedLine}`)); result.push(theme.fg("toolDiffAdded", `+${added.lineNum} ${addedLine}`)); } else { // Show all removed lines first, then all added lines for (const removed of removedLines) { result.push(theme.fg("toolDiffRemoved", `-${removed.lineNum} ${replaceTabs(removed.content)}`)); } for (const added of addedLines) { result.push(theme.fg("toolDiffAdded", `+${added.lineNum} ${replaceTabs(added.content)}`)); } } } else if (parsed.prefix === "+") { // Standalone added line result.push(theme.fg("toolDiffAdded", `+${parsed.lineNum} ${replaceTabs(parsed.content)}`)); i++; } else { // Context line result.push(theme.fg("toolDiffContext", ` ${parsed.lineNum} ${replaceTabs(parsed.content)}`)); i++; } } return result.join("\n"); } type ThemeBg = Parameters[0]; type ThemeColor = Parameters[0]; // Rewrite full/background resets to foreground-only so a row's background block // survives the syntax-highlighted content. const BG_CLEARING_RESET = /\x1b\[(?:0|49)m/g; function keepBackground(highlighted: string): string { return highlighted.replace(BG_CLEARING_RESET, "\x1b[39m"); } function highlightContent(content: string, language: string | undefined): string { if (!content) { return ""; } if (language) { return keepBackground(highlightCode(content, language)[0] ?? content); } return theme.fg("mdCodeBlock", content); } interface DiffLineSpec { bg: ThemeBg; gutter: string; gutterFg: ThemeColor; content: string; language: string | undefined; width: number; /** Flat content color instead of syntax highlighting (256-color fallback). */ contentFg?: ThemeColor; /** Render content with dimmed intensity (faint). */ dim?: boolean; } function padToWidth(inner: string, width: number): string { if (visibleWidth(inner) > width) { inner = truncateToWidth(inner, width, ""); } // Pad after truncating too: a 2-cell character straddling the cutoff leaves // the result a cell short. const pad = width - visibleWidth(inner); return pad > 0 ? inner + " ".repeat(pad) : inner; } function dimHighlightedContent(highlighted: string): string { // Scale 24-bit truecolor RGB codes down to 50% luminance (half-brightness) let dimmed = highlighted.replace(/\x1b\[38;2;(\d+);(\d+);(\d+)m/g, (_match, r, g, b) => { const dr = Math.round(Number.parseInt(r, 10) * 0.5); const dg = Math.round(Number.parseInt(g, 10) * 0.5); const db = Math.round(Number.parseInt(b, 10) * 0.5); return `\x1b[38;2;${dr};${dg};${db}m`; }); // Wrap unstyled text and resets in muted gray so they don't bounce back to bright white const mutedAnsi = theme.fg("muted", ""); const mutedPrefix = mutedAnsi.replace(/\x1b\[39m/g, ""); dimmed = `${mutedPrefix}${dimmed.replace(/\x1b\[39m/g, mutedPrefix)}\x1b[39m`; return dimmed; } // One diff line as one-or-more full-width background rows. Content wider than the // row wraps onto continuation rows with a blank gutter, so nothing is truncated. function buildRichDiffLine(spec: DiffLineSpec): string[] { let renderedContent = spec.contentFg ? theme.fg(spec.contentFg, spec.content) : highlightContent(spec.content, spec.language); if (spec.dim) { renderedContent = dimHighlightedContent(renderedContent); } const gutterWidth = visibleWidth(spec.gutter); const contentWidth = Math.max(1, spec.width - gutterWidth); const contentRows = wrapTextWithAnsi(renderedContent, contentWidth); if (contentRows.length === 0) { contentRows.push(""); } const styledGutter = theme.fg(spec.gutterFg, spec.gutter); const styledCont = " ".repeat(gutterWidth); return contentRows.map((row, index) => { const gutter = index === 0 ? styledGutter : styledCont; return theme.bg(spec.bg, padToWidth(`${gutter}${row}\x1b[39m`, spec.width)); }); } export interface RichDiffOptions { /** Language id for syntax highlighting the diff content (e.g. "typescript"). */ language?: string; } /** A dim `⋮` row separating non-adjacent hunks of one file's diff. */ export function renderDiffSeparator(contentWidth: number): string { const width = Math.max(1, contentWidth); const marker = theme.fg("toolDiffContext", " ⋮"); const pad = Math.max(0, width - visibleWidth(marker)); return theme.bg("toolPanelBg", marker + " ".repeat(pad)); } /** Render a unified diff as full-width rows: green/red blocks, syntax-highlighted. */ export function renderRichDiff(diffText: string, contentWidth: number, options: RichDiffOptions = {}): string[] { const width = Math.max(1, contentWidth); let currentLanguage = options.language; const rows: string[] = []; for (const rawLine of diffText.split("\n")) { // File header: diff --git a/... b/... if (rawLine.startsWith("diff --git ")) { const match = rawLine.match(/diff --git a\/(.*?) b\/(.*)/); const fileName = match ? match[2] : rawLine.slice(11); currentLanguage = getLanguageFromPath(fileName) ?? options.language; const title = `─── ${fileName} `; const bar = "─".repeat(Math.max(0, width - visibleWidth(title) - 2)); rows.push( theme.bg( "toolPanelBg", padToWidth(` ${theme.fg("accent", theme.bold(title))}${theme.fg("dim", bar)}`, width), ), ); continue; } // Git index or mode lines if ( rawLine.startsWith("index ") || rawLine.startsWith("new file mode ") || rawLine.startsWith("deleted file mode ") ) { rows.push(theme.bg("toolPanelBg", padToWidth(` ${theme.fg("dim", rawLine)}`, width))); continue; } // File paths: --- a/... and +++ b/... if (rawLine.startsWith("--- ") || rawLine.startsWith("+++ ")) { const isAdd = rawLine.startsWith("+++ "); if (isAdd && rawLine.startsWith("+++ b/")) { currentLanguage = getLanguageFromPath(rawLine.slice(6).trim()) ?? currentLanguage; } const color = isAdd ? "toolDiffAdded" : "toolDiffRemoved"; rows.push(theme.bg("toolPanelBg", padToWidth(` ${theme.fg(color, rawLine)}`, width))); continue; } // Hunk headers: @@ -... +... @@ if (rawLine.startsWith("@@ ")) { rows.push(theme.bg("toolPanelBg", padToWidth(` ${theme.fg("accent", rawLine)}`, width))); continue; } // Commit metadata headers if ( rawLine.startsWith("commit ") || rawLine.startsWith("Author:") || rawLine.startsWith("AuthorDate:") || rawLine.startsWith("Commit:") || rawLine.startsWith("CommitDate:") ) { rows.push(theme.bg("toolPanelBg", padToWidth(` ${theme.fg("dim", rawLine)}`, width))); continue; } // Commit message body if (rawLine.startsWith(" ") && currentLanguage === undefined) { rows.push(theme.bg("toolPanelBg", padToWidth(` ${theme.fg("muted", rawLine)}`, width))); continue; } const parsed = parseDiffLine(rawLine); if (!parsed) { rows.push( ...buildRichDiffLine({ bg: "toolPanelBg", gutterFg: "toolDiffContext", gutter: " ", content: replaceTabs(rawLine), language: undefined, width, }), ); continue; } const { prefix, lineNum, content } = parsed; const gutter = lineNum ? ` ${lineNum.padStart(4)} ${prefix} ` : ` ${prefix} `; const text = replaceTabs(content); if (prefix === "+") { rows.push( ...buildRichDiffLine({ bg: "toolPanelBg", gutterFg: "toolDiffAdded", gutter, content: text, language: currentLanguage, width, contentFg: currentLanguage ? undefined : "toolDiffAdded", }), ); } else if (prefix === "-") { rows.push( ...buildRichDiffLine({ bg: "toolPanelBg", gutterFg: "toolDiffRemoved", gutter, content: text, language: currentLanguage, width, contentFg: currentLanguage ? undefined : "dim", dim: true, }), ); } else { rows.push( ...buildRichDiffLine({ bg: "toolPanelBg", gutterFg: "toolDiffContext", gutter, content: text, language: currentLanguage, width, }), ); } } return rows; }