Files
grok-cli/src/tools/text-editor.ts
T
Joseph Cheek 2050f7ef8e Fix diff generation showing entire file as changed (fixes #83) (#98)
The diff preview was showing the entire file as changed when only a few
lines were actually modified. This was caused by a broken change detection
algorithm that couldn't properly handle insertions and deletions.

Replaced the naive line-matching algorithm with a proper LCS (Longest
Common Subsequence) based diff algorithm using dynamic programming. This
produces minimal, accurate diffs that match what git diff shows.

Changes:
- Added computeLCS(): Computes longest common subsequence between old/new lines
- Added extractChanges(): Extracts actual change regions from LCS table
- Updated generateDiff(): Uses LCS-based change detection instead of broken algorithm

Impact:
- Diff previews now show only actual changes (e.g., 4 lines instead of 260)
- Drastically reduces context pollution in conversation history
- Matches git diff output accuracy

🤖 Contributed by ZDS-AI (https://zds.group)
2025-11-27 15:53:18 +01:00

705 lines
20 KiB
TypeScript

import fs from "fs-extra";
import * as path from "path";
import { writeFile as writeFilePromise } from "fs/promises";
import { ToolResult, EditorCommand } from "../types/index.js";
import { ConfirmationService } from "../utils/confirmation-service.js";
export class TextEditorTool {
private editHistory: EditorCommand[] = [];
private confirmationService = ConfirmationService.getInstance();
async view(
filePath: string,
viewRange?: [number, number]
): Promise<ToolResult> {
try {
const resolvedPath = path.resolve(filePath);
if (await fs.pathExists(resolvedPath)) {
const stats = await fs.stat(resolvedPath);
if (stats.isDirectory()) {
const files = await fs.readdir(resolvedPath);
return {
success: true,
output: `Directory contents of ${filePath}:\n${files.join("\n")}`,
};
}
const content = await fs.readFile(resolvedPath, "utf-8");
const lines = content.split("\n");
if (viewRange) {
const [start, end] = viewRange;
const selectedLines = lines.slice(start - 1, end);
const numberedLines = selectedLines
.map((line, idx) => `${start + idx}: ${line}`)
.join("\n");
return {
success: true,
output: `Lines ${start}-${end} of ${filePath}:\n${numberedLines}`,
};
}
const totalLines = lines.length;
const displayLines = totalLines > 10 ? lines.slice(0, 10) : lines;
const numberedLines = displayLines
.map((line, idx) => `${idx + 1}: ${line}`)
.join("\n");
const additionalLinesMessage =
totalLines > 10 ? `\n... +${totalLines - 10} lines` : "";
return {
success: true,
output: `Contents of ${filePath}:\n${numberedLines}${additionalLinesMessage}`,
};
} else {
return {
success: false,
error: `File or directory not found: ${filePath}`,
};
}
} catch (error: any) {
return {
success: false,
error: `Error viewing ${filePath}: ${error.message}`,
};
}
}
async strReplace(
filePath: string,
oldStr: string,
newStr: string,
replaceAll: boolean = false
): Promise<ToolResult> {
try {
const resolvedPath = path.resolve(filePath);
if (!(await fs.pathExists(resolvedPath))) {
return {
success: false,
error: `File not found: ${filePath}`,
};
}
const content = await fs.readFile(resolvedPath, "utf-8");
if (!content.includes(oldStr)) {
if (oldStr.includes('\n')) {
const fuzzyResult = this.findFuzzyMatch(content, oldStr);
if (fuzzyResult) {
oldStr = fuzzyResult;
} else {
return {
success: false,
error: `String not found in file. For multi-line replacements, consider using line-based editing.`,
};
}
} else {
return {
success: false,
error: `String not found in file: "${oldStr}"`,
};
}
}
const occurrences = (content.match(new RegExp(oldStr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
const sessionFlags = this.confirmationService.getSessionFlags();
if (!sessionFlags.fileOperations && !sessionFlags.allOperations) {
const previewContent = replaceAll
? content.split(oldStr).join(newStr)
: content.replace(oldStr, newStr);
const oldLines = content.split("\n");
const newLines = previewContent.split("\n");
const diffContent = this.generateDiff(oldLines, newLines, filePath);
const confirmationResult =
await this.confirmationService.requestConfirmation(
{
operation: `Edit file${replaceAll && occurrences > 1 ? ` (${occurrences} occurrences)` : ''}`,
filename: filePath,
showVSCodeOpen: false,
content: diffContent,
},
"file"
);
if (!confirmationResult.confirmed) {
return {
success: false,
error: confirmationResult.feedback || "File edit cancelled by user",
};
}
}
const newContent = replaceAll
? content.split(oldStr).join(newStr)
: content.replace(oldStr, newStr);
await writeFilePromise(resolvedPath, newContent, "utf-8");
this.editHistory.push({
command: "str_replace",
path: filePath,
old_str: oldStr,
new_str: newStr,
});
const oldLines = content.split("\n");
const newLines = newContent.split("\n");
const diff = this.generateDiff(oldLines, newLines, filePath);
return {
success: true,
output: diff,
};
} catch (error: any) {
return {
success: false,
error: `Error replacing text in ${filePath}: ${error.message}`,
};
}
}
async create(filePath: string, content: string): Promise<ToolResult> {
try {
const resolvedPath = path.resolve(filePath);
// Check if user has already accepted file operations for this session
const sessionFlags = this.confirmationService.getSessionFlags();
if (!sessionFlags.fileOperations && !sessionFlags.allOperations) {
// Create a diff-style preview for file creation
const contentLines = content.split("\n");
const diffContent = [
`Created ${filePath}`,
`--- /dev/null`,
`+++ b/${filePath}`,
`@@ -0,0 +1,${contentLines.length} @@`,
...contentLines.map((line) => `+${line}`),
].join("\n");
const confirmationResult =
await this.confirmationService.requestConfirmation(
{
operation: "Write",
filename: filePath,
showVSCodeOpen: false,
content: diffContent,
},
"file"
);
if (!confirmationResult.confirmed) {
return {
success: false,
error:
confirmationResult.feedback || "File creation cancelled by user",
};
}
}
const dir = path.dirname(resolvedPath);
await fs.ensureDir(dir);
await writeFilePromise(resolvedPath, content, "utf-8");
this.editHistory.push({
command: "create",
path: filePath,
content,
});
// Generate diff output using the same method as str_replace
const oldLines: string[] = []; // Empty for new files
const newLines = content.split("\n");
const diff = this.generateDiff(oldLines, newLines, filePath);
return {
success: true,
output: diff,
};
} catch (error: any) {
return {
success: false,
error: `Error creating ${filePath}: ${error.message}`,
};
}
}
async replaceLines(
filePath: string,
startLine: number,
endLine: number,
newContent: string
): Promise<ToolResult> {
try {
const resolvedPath = path.resolve(filePath);
if (!(await fs.pathExists(resolvedPath))) {
return {
success: false,
error: `File not found: ${filePath}`,
};
}
const fileContent = await fs.readFile(resolvedPath, "utf-8");
const lines = fileContent.split("\n");
if (startLine < 1 || startLine > lines.length) {
return {
success: false,
error: `Invalid start line: ${startLine}. File has ${lines.length} lines.`,
};
}
if (endLine < startLine || endLine > lines.length) {
return {
success: false,
error: `Invalid end line: ${endLine}. Must be between ${startLine} and ${lines.length}.`,
};
}
const sessionFlags = this.confirmationService.getSessionFlags();
if (!sessionFlags.fileOperations && !sessionFlags.allOperations) {
const newLines = [...lines];
const replacementLines = newContent.split("\n");
newLines.splice(startLine - 1, endLine - startLine + 1, ...replacementLines);
const diffContent = this.generateDiff(lines, newLines, filePath);
const confirmationResult =
await this.confirmationService.requestConfirmation(
{
operation: `Replace lines ${startLine}-${endLine}`,
filename: filePath,
showVSCodeOpen: false,
content: diffContent,
},
"file"
);
if (!confirmationResult.confirmed) {
return {
success: false,
error: confirmationResult.feedback || "Line replacement cancelled by user",
};
}
}
const replacementLines = newContent.split("\n");
lines.splice(startLine - 1, endLine - startLine + 1, ...replacementLines);
const newFileContent = lines.join("\n");
await writeFilePromise(resolvedPath, newFileContent, "utf-8");
this.editHistory.push({
command: "str_replace",
path: filePath,
old_str: `lines ${startLine}-${endLine}`,
new_str: newContent,
});
const oldLines = fileContent.split("\n");
const diff = this.generateDiff(oldLines, lines, filePath);
return {
success: true,
output: diff,
};
} catch (error: any) {
return {
success: false,
error: `Error replacing lines in ${filePath}: ${error.message}`,
};
}
}
async insert(
filePath: string,
insertLine: number,
content: string
): Promise<ToolResult> {
try {
const resolvedPath = path.resolve(filePath);
if (!(await fs.pathExists(resolvedPath))) {
return {
success: false,
error: `File not found: ${filePath}`,
};
}
const fileContent = await fs.readFile(resolvedPath, "utf-8");
const lines = fileContent.split("\n");
lines.splice(insertLine - 1, 0, content);
const newContent = lines.join("\n");
await writeFilePromise(resolvedPath, newContent, "utf-8");
this.editHistory.push({
command: "insert",
path: filePath,
insert_line: insertLine,
content,
});
return {
success: true,
output: `Successfully inserted content at line ${insertLine} in ${filePath}`,
};
} catch (error: any) {
return {
success: false,
error: `Error inserting content in ${filePath}: ${error.message}`,
};
}
}
async undoEdit(): Promise<ToolResult> {
if (this.editHistory.length === 0) {
return {
success: false,
error: "No edits to undo",
};
}
const lastEdit = this.editHistory.pop()!;
try {
switch (lastEdit.command) {
case "str_replace":
if (lastEdit.path && lastEdit.old_str && lastEdit.new_str) {
const content = await fs.readFile(lastEdit.path, "utf-8");
const revertedContent = content.replace(
lastEdit.new_str,
lastEdit.old_str
);
await writeFilePromise(lastEdit.path, revertedContent, "utf-8");
}
break;
case "create":
if (lastEdit.path) {
await fs.remove(lastEdit.path);
}
break;
case "insert":
if (lastEdit.path && lastEdit.insert_line) {
const content = await fs.readFile(lastEdit.path, "utf-8");
const lines = content.split("\n");
lines.splice(lastEdit.insert_line - 1, 1);
await writeFilePromise(lastEdit.path, lines.join("\n"), "utf-8");
}
break;
}
return {
success: true,
output: `Successfully undid ${lastEdit.command} operation`,
};
} catch (error: any) {
return {
success: false,
error: `Error undoing edit: ${error.message}`,
};
}
}
private findFuzzyMatch(content: string, searchStr: string): string | null {
const functionMatch = searchStr.match(/function\s+(\w+)/);
if (!functionMatch) return null;
const functionName = functionMatch[1];
const contentLines = content.split('\n');
let functionStart = -1;
for (let i = 0; i < contentLines.length; i++) {
if (contentLines[i].includes(`function ${functionName}`) && contentLines[i].includes('{')) {
functionStart = i;
break;
}
}
if (functionStart === -1) return null;
let braceCount = 0;
let functionEnd = functionStart;
for (let i = functionStart; i < contentLines.length; i++) {
const line = contentLines[i];
for (const char of line) {
if (char === '{') braceCount++;
if (char === '}') braceCount--;
}
if (braceCount === 0 && i > functionStart) {
functionEnd = i;
break;
}
}
const actualFunction = contentLines.slice(functionStart, functionEnd + 1).join('\n');
const searchNormalized = this.normalizeForComparison(searchStr);
const actualNormalized = this.normalizeForComparison(actualFunction);
if (this.isSimilarStructure(searchNormalized, actualNormalized)) {
return actualFunction;
}
return null;
}
private normalizeForComparison(str: string): string {
return str
.replace(/["'`]/g, '"')
.replace(/\s+/g, ' ')
.replace(/{\s+/g, '{ ')
.replace(/\s+}/g, ' }')
.replace(/;\s*/g, ';')
.trim();
}
private isSimilarStructure(search: string, actual: string): boolean {
const extractTokens = (str: string) => {
const tokens = str.match(/\b(function|console\.log|return|if|else|for|while)\b/g) || [];
return tokens;
};
const searchTokens = extractTokens(search);
const actualTokens = extractTokens(actual);
if (searchTokens.length !== actualTokens.length) return false;
for (let i = 0; i < searchTokens.length; i++) {
if (searchTokens[i] !== actualTokens[i]) return false;
}
return true;
}
/**
* Compute Longest Common Subsequence using dynamic programming
* Returns array of indices in oldLines that are part of LCS
*/
private computeLCS(oldLines: string[], newLines: string[]): number[][] {
const m = oldLines.length;
const n = newLines.length;
const dp: number[][] = Array(m + 1).fill(0).map(() => Array(n + 1).fill(0));
// Build LCS length table
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (oldLines[i - 1] === newLines[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp;
}
/**
* Extract changes from LCS table
* Returns array of change regions
*/
private extractChanges(
oldLines: string[],
newLines: string[],
lcs: number[][]
): Array<{ oldStart: number; oldEnd: number; newStart: number; newEnd: number }> {
const changes: Array<{
oldStart: number;
oldEnd: number;
newStart: number;
newEnd: number;
}> = [];
let i = oldLines.length;
let j = newLines.length;
let oldEnd = i;
let newEnd = j;
let inChange = false;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
// Lines match - if we were in a change, close it
if (inChange) {
changes.unshift({
oldStart: i,
oldEnd: oldEnd,
newStart: j,
newEnd: newEnd
});
inChange = false;
}
i--;
j--;
} else if (j > 0 && (i === 0 || lcs[i][j - 1] >= lcs[i - 1][j])) {
// Insertion in new file
if (!inChange) {
oldEnd = i;
newEnd = j;
inChange = true;
}
j--;
} else if (i > 0) {
// Deletion from old file
if (!inChange) {
oldEnd = i;
newEnd = j;
inChange = true;
}
i--;
}
}
// Close any remaining change
if (inChange) {
changes.unshift({
oldStart: 0,
oldEnd: oldEnd,
newStart: 0,
newEnd: newEnd
});
}
return changes;
}
private generateDiff(
oldLines: string[],
newLines: string[],
filePath: string
): string {
const CONTEXT_LINES = 3;
// Use LCS-based diff algorithm to find actual changes
const lcs = this.computeLCS(oldLines, newLines);
const changes = this.extractChanges(oldLines, newLines, lcs);
const hunks: Array<{
oldStart: number;
oldCount: number;
newStart: number;
newCount: number;
lines: Array<{ type: '+' | '-' | ' '; content: string }>;
}> = [];
let accumulatedOffset = 0;
for (let changeIdx = 0; changeIdx < changes.length; changeIdx++) {
const change = changes[changeIdx];
let contextStart = Math.max(0, change.oldStart - CONTEXT_LINES);
let contextEnd = Math.min(oldLines.length, change.oldEnd + CONTEXT_LINES);
if (hunks.length > 0) {
const lastHunk = hunks[hunks.length - 1];
const lastHunkEnd = lastHunk.oldStart + lastHunk.oldCount;
if (lastHunkEnd >= contextStart) {
const oldHunkEnd = lastHunk.oldStart + lastHunk.oldCount;
const newContextEnd = Math.min(oldLines.length, change.oldEnd + CONTEXT_LINES);
for (let idx = oldHunkEnd; idx < change.oldStart; idx++) {
lastHunk.lines.push({ type: ' ', content: oldLines[idx] });
}
for (let idx = change.oldStart; idx < change.oldEnd; idx++) {
lastHunk.lines.push({ type: '-', content: oldLines[idx] });
}
for (let idx = change.newStart; idx < change.newEnd; idx++) {
lastHunk.lines.push({ type: '+', content: newLines[idx] });
}
for (let idx = change.oldEnd; idx < newContextEnd && idx < oldLines.length; idx++) {
lastHunk.lines.push({ type: ' ', content: oldLines[idx] });
}
lastHunk.oldCount = newContextEnd - lastHunk.oldStart;
lastHunk.newCount = lastHunk.oldCount + (change.newEnd - change.newStart) - (change.oldEnd - change.oldStart);
continue;
}
}
const hunk: typeof hunks[0] = {
oldStart: contextStart + 1,
oldCount: contextEnd - contextStart,
newStart: contextStart + 1 + accumulatedOffset,
newCount: contextEnd - contextStart + (change.newEnd - change.newStart) - (change.oldEnd - change.oldStart),
lines: []
};
for (let idx = contextStart; idx < change.oldStart; idx++) {
hunk.lines.push({ type: ' ', content: oldLines[idx] });
}
for (let idx = change.oldStart; idx < change.oldEnd; idx++) {
hunk.lines.push({ type: '-', content: oldLines[idx] });
}
for (let idx = change.newStart; idx < change.newEnd; idx++) {
hunk.lines.push({ type: '+', content: newLines[idx] });
}
for (let idx = change.oldEnd; idx < contextEnd && idx < oldLines.length; idx++) {
hunk.lines.push({ type: ' ', content: oldLines[idx] });
}
hunks.push(hunk);
accumulatedOffset += (change.newEnd - change.newStart) - (change.oldEnd - change.oldStart);
}
let addedLines = 0;
let removedLines = 0;
for (const hunk of hunks) {
for (const line of hunk.lines) {
if (line.type === '+') addedLines++;
if (line.type === '-') removedLines++;
}
}
let summary = `Updated ${filePath}`;
if (addedLines > 0 && removedLines > 0) {
summary += ` with ${addedLines} addition${
addedLines !== 1 ? "s" : ""
} and ${removedLines} removal${removedLines !== 1 ? "s" : ""}`;
} else if (addedLines > 0) {
summary += ` with ${addedLines} addition${addedLines !== 1 ? "s" : ""}`;
} else if (removedLines > 0) {
summary += ` with ${removedLines} removal${
removedLines !== 1 ? "s" : ""
}`;
} else if (changes.length === 0) {
return `No changes in ${filePath}`;
}
let diff = summary + "\n";
diff += `--- a/${filePath}\n`;
diff += `+++ b/${filePath}\n`;
for (const hunk of hunks) {
diff += `@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@\n`;
for (const line of hunk.lines) {
diff += `${line.type}${line.content}\n`;
}
}
return diff.trim();
}
getEditHistory(): EditorCommand[] {
return [...this.editHistory];
}
}