import { relative } from "node:path";
import * as Diff from "diff";
import { Box } from "ink";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { colors } from "./colors";
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
* - Files outside cwd: full absolute path
*/
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
interface DiffLineProps {
lineNumber: number;
type: "add" | "remove";
content: string;
compareContent?: string; // The other version to compare against for word diff
columns: number;
showLineNumbers?: boolean; // Whether to show line numbers (default true)
}
function DiffLine({
lineNumber,
type,
content,
compareContent,
columns,
showLineNumbers = true,
}: DiffLineProps) {
const prefix = type === "add" ? "+" : "-";
const lineBg =
type === "add" ? colors.diff.addedLineBg : colors.diff.removedLineBg;
const wordBg =
type === "add" ? colors.diff.addedWordBg : colors.diff.removedWordBg;
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;
})}
);
}
// No comparison, just show the whole line with one background
return (
{" "}
{`${linePrefix}${content}`}
);
}
interface WriteRendererProps {
filePath: string;
content: string;
}
export function WriteRenderer({ filePath, content }: WriteRendererProps) {
const columns = useTerminalWidth();
const relativePath = formatDisplayPath(filePath);
const lines = content.split("\n");
const lineCount = lines.length;
const gutterWidth = 4; // " " indent to align with tool return prefix
const contentWidth = Math.max(0, columns - gutterWidth);
return (
{" "}
⎿
Wrote {lineCount} line
{lineCount !== 1 ? "s" : ""} to {relativePath}
{lines.map((line, i) => (
{" "}
{line}
))}
);
}
interface EditRendererProps {
filePath: string;
oldString: string;
newString: string;
showLineNumbers?: boolean; // Whether to show line numbers (default true)
}
export function EditRenderer({
filePath,
oldString,
newString,
showLineNumbers = true,
}: EditRendererProps) {
const columns = useTerminalWidth();
const relativePath = formatDisplayPath(filePath);
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;
const gutterWidth = 4; // " " indent to align with tool return prefix
const contentWidth = Math.max(0, columns - gutterWidth);
return (
{" "}
⎿
Updated {relativePath} with{" "}
{additions} addition
{additions !== 1 ? "s" : ""} and {removals}{" "}
removal
{removals !== 1 ? "s" : ""}
{/* Show removals */}
{oldLines.map((line, i) => (
))}
{/* Show additions */}
{newLines.map((line, i) => (
))}
);
}
interface MultiEditRendererProps {
filePath: string;
edits: Array<{
old_string: string;
new_string: string;
}>;
showLineNumbers?: boolean; // Whether to show line numbers (default true)
}
export function MultiEditRenderer({
filePath,
edits,
showLineNumbers = true,
}: MultiEditRendererProps) {
const columns = useTerminalWidth();
const relativePath = formatDisplayPath(filePath);
// Count total additions and removals
let totalAdditions = 0;
let totalRemovals = 0;
edits.forEach((edit) => {
totalAdditions += countLines(edit.new_string);
totalRemovals += countLines(edit.old_string);
});
const gutterWidth = 4; // " " indent to align with tool return prefix
const contentWidth = Math.max(0, columns - gutterWidth);
return (
{" "}
⎿
Updated {relativePath} with{" "}
{totalAdditions} addition
{totalAdditions !== 1 ? "s" : ""} and{" "}
{totalRemovals} removal
{totalRemovals !== 1 ? "s" : ""}
{/* 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;
return (
{oldLines.map((line, i) => (
))}
{newLines.map((line, i) => (
))}
);
})}
);
}