diff --git a/packages/coding-agent/src/modes/interactive/components/diff.ts b/packages/coding-agent/src/modes/interactive/components/diff.ts new file mode 100644 index 00000000..d575d63e --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/diff.ts @@ -0,0 +1,147 @@ +import * as Diff from "diff"; +import { theme } from "../theme/theme.js"; + +/** + * Parse diff line to extract prefix, line number, and content. + * Format: "+123 content" or "-123 content" or " 123 content" or " ..." + */ +function parseDiffLine(line: string): { prefix: string; lineNum: string; content: string } | null { + const match = line.match(/^([+-\s])(\s*\d*)\s(.*)$/); + if (!match) return null; + return { prefix: match[1], lineNum: match[2], content: match[3] }; +} + +/** + * 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"); +} diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 61e0113d..98dec495 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -13,6 +13,7 @@ import stripAnsi from "strip-ansi"; import type { CustomAgentTool } from "../../../core/custom-tools/types.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js"; import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js"; +import { renderDiff } from "./diff.js"; /** * Convert absolute path to tilde notation if it's in home directory @@ -358,7 +359,8 @@ export class ToolExecutionComponent extends Container { } } } else if (this.toolName === "edit") { - const path = shortenPath(this.args?.file_path || this.args?.path || ""); + const rawPath = this.args?.file_path || this.args?.path || ""; + const path = shortenPath(rawPath); text = theme.fg("toolTitle", theme.bold("edit")) + " " + @@ -371,17 +373,7 @@ export class ToolExecutionComponent extends Container { text += "\n\n" + theme.fg("error", errorText); } } else if (this.result.details?.diff) { - const diffLines = this.result.details.diff.split("\n"); - const coloredLines = diffLines.map((line: string) => { - if (line.startsWith("+")) { - return theme.fg("toolDiffAdded", line); - } else if (line.startsWith("-")) { - return theme.fg("toolDiffRemoved", line); - } else { - return theme.fg("toolDiffContext", line); - } - }); - text += "\n\n" + coloredLines.join("\n"); + text += "\n\n" + renderDiff(this.result.details.diff, { filePath: rawPath }); } } } else if (this.toolName === "ls") { diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index f02018a1..885f2c01 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -351,6 +351,10 @@ export class Theme { return chalk.underline(text); } + inverse(text: string): string { + return chalk.inverse(text); + } + getFgAnsi(color: ThemeColor): string { const ansi = this.fgColors.get(color); if (!ansi) throw new Error(`Unknown theme color: ${color}`);