Files
oh-my-pi/packages/coding-agent/src/core/tools/edit.ts
T
can1357 6e7ca2fc14 feat(coding-agent): added retry logic with exponential backoff
- Added retry logic with exponential backoff and model fallback for auto-compaction failures.
- Added support for pi/<role> model aliases and automatic model inheritance for subtasks.
- Enhanced error messages with retry-after timing from rate limit headers across all providers.
- Fixed image attachments being dropped when steering messages are queued during streaming.
- Changed edit tool to merge call and result displays into single block.
- Changed model override behavior to persist in settings when explicitly set via CLI.
2026-01-10 00:43:56 +01:00

323 lines
12 KiB
TypeScript

import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { Type } from "@sinclair/typebox";
import { getLanguageFromPath, type Theme } from "../../modes/interactive/theme/theme";
import editDescription from "../../prompts/tools/edit.md" with { type: "text" };
import type { RenderResultOptions } from "../custom-tools/types";
import {
DEFAULT_FUZZY_THRESHOLD,
detectLineEnding,
type EditDiffError,
type EditDiffResult,
EditMatchError,
findEditMatch,
generateDiffString,
normalizeToLF,
restoreLineEndings,
stripBom,
} from "./edit-diff";
import type { ToolSession } from "./index";
import { createLspWritethrough, type FileDiagnosticsResult } from "./lsp/index";
import { resolveToCwd } from "./path-utils";
import { createToolUIKit, getDiffStats, shortenPath, truncateDiffByHunk } from "./render-utils";
const editSchema = Type.Object({
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
oldText: Type.String({
description: "Text to find and replace (high-confidence fuzzy matching for whitespace/indentation is always on)",
}),
newText: Type.String({ description: "New text to replace the old text with" }),
all: Type.Optional(Type.Boolean({ description: "Replace all occurrences instead of requiring unique match" })),
});
export interface EditToolDetails {
/** Unified diff of the changes made */
diff: string;
/** Line number of the first change in the new file (for editor navigation) */
firstChangedLine?: number;
/** Diagnostic result (if available) */
diagnostics?: FileDiagnosticsResult;
}
export function createEditTool(session: ToolSession): AgentTool<typeof editSchema> {
const allowFuzzy = session.settings?.getEditFuzzyMatch() ?? true;
const enableDiagnostics = session.settings?.getLspDiagnosticsOnEdit() ?? false;
const enableFormat = session.settings?.getLspFormatOnWrite() ?? true;
const writethrough = createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics });
return {
name: "edit",
label: "Edit",
description: editDescription,
parameters: editSchema,
execute: async (
_toolCallId: string,
{ path, oldText, newText, all }: { path: string; oldText: string; newText: string; all?: boolean },
signal?: AbortSignal,
) => {
// Reject .ipynb files - use NotebookEdit tool instead
if (path.endsWith(".ipynb")) {
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
}
const absolutePath = resolveToCwd(path, session.cwd);
const file = Bun.file(absolutePath);
if (!(await file.exists())) {
throw new Error(`File not found: ${path}`);
}
const rawContent = await file.text();
// Strip BOM before matching (LLM won't include invisible BOM in oldText)
const { bom, text: content } = stripBom(rawContent);
const originalEnding = detectLineEnding(content);
const normalizedContent = normalizeToLF(content);
const normalizedOldText = normalizeToLF(oldText);
const normalizedNewText = normalizeToLF(newText);
let normalizedNewContent: string;
let replacementCount = 0;
if (all) {
// Replace all occurrences mode with fuzzy matching
normalizedNewContent = normalizedContent;
// First check: if exact matches exist, use simple replaceAll
const exactCount = normalizedContent.split(normalizedOldText).length - 1;
if (exactCount > 0) {
normalizedNewContent = normalizedContent.split(normalizedOldText).join(normalizedNewText);
replacementCount = exactCount;
} else {
// No exact matches - try fuzzy matching iteratively
while (true) {
const matchOutcome = findEditMatch(normalizedNewContent, normalizedOldText, {
allowFuzzy,
similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
});
// In all mode, use closest match if it passes threshold (even with multiple matches)
const match =
matchOutcome.match ||
(allowFuzzy && matchOutcome.closest && matchOutcome.closest.confidence >= DEFAULT_FUZZY_THRESHOLD
? matchOutcome.closest
: undefined);
if (!match) {
if (replacementCount === 0) {
throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, {
allowFuzzy,
similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
fuzzyMatches: matchOutcome.fuzzyMatches,
});
}
break;
}
normalizedNewContent =
normalizedNewContent.substring(0, match.startIndex) +
normalizedNewText +
normalizedNewContent.substring(match.startIndex + match.actualText.length);
replacementCount++;
}
}
} else {
// Single replacement mode with fuzzy matching
const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, {
allowFuzzy,
similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
});
if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
throw new Error(
`Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique, or use all: true to replace all.`,
);
}
if (!matchOutcome.match) {
throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, {
allowFuzzy,
similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
fuzzyMatches: matchOutcome.fuzzyMatches,
});
}
const match = matchOutcome.match;
normalizedNewContent =
normalizedContent.substring(0, match.startIndex) +
normalizedNewText +
normalizedContent.substring(match.startIndex + match.actualText.length);
replacementCount = 1;
}
// Verify the replacement actually changed something
if (normalizedContent === normalizedNewContent) {
throw new Error(
`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`,
);
}
const finalContent = bom + restoreLineEndings(normalizedNewContent, originalEnding);
const diagnostics = await writethrough(absolutePath, finalContent, signal, file);
const diffResult = generateDiffString(normalizedContent, normalizedNewContent);
// Build result text
let resultText =
replacementCount > 1
? `Successfully replaced ${replacementCount} occurrences in ${path}.`
: `Successfully replaced text in ${path}.`;
const messages = diagnostics?.messages;
if (messages && messages.length > 0) {
resultText += `\n\nLSP Diagnostics (${diagnostics.summary}):\n`;
resultText += messages.map((d) => ` ${d}`).join("\n");
}
return {
content: [
{
type: "text",
text: resultText,
},
],
details: {
diff: diffResult.diff,
firstChangedLine: diffResult.firstChangedLine,
diagnostics: diagnostics,
},
};
},
};
}
// =============================================================================
// TUI Renderer
// =============================================================================
interface EditRenderArgs {
path?: string;
file_path?: string;
oldText?: string;
newText?: string;
all?: boolean;
}
/** Extended context for edit tool rendering */
export interface EditRenderContext {
/** Pre-computed diff preview (computed before tool executes) */
editDiffPreview?: EditDiffResult | EditDiffError;
/** Function to render diff text with syntax highlighting */
renderDiff?: (diffText: string, options?: { filePath?: string }) => string;
}
const EDIT_DIFF_PREVIEW_HUNKS = 2;
const EDIT_DIFF_PREVIEW_LINES = 24;
function countLines(text: string): number {
if (!text) return 0;
return text.split("\n").length;
}
function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string {
const icon = uiTheme.getLangIcon(language);
if (lineCount !== null) {
return uiTheme.fg("dim", `${icon} ${lineCount} lines`);
}
return uiTheme.fg("dim", `${icon}`);
}
export const editToolRenderer = {
mergeCallAndResult: true,
renderCall(args: EditRenderArgs, uiTheme: Theme): Component {
const ui = createToolUIKit(uiTheme);
const rawPath = args.file_path || args.path || "";
const filePath = shortenPath(rawPath);
const editLanguage = getLanguageFromPath(rawPath) ?? "text";
const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage));
const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis);
const text = `${ui.title("Edit")} ${editIcon} ${pathDisplay}`;
return new Text(text, 0, 0);
},
renderResult(
result: { content: Array<{ type: string; text?: string }>; details?: EditToolDetails; isError?: boolean },
options: RenderResultOptions & { renderContext?: EditRenderContext },
uiTheme: Theme,
args?: EditRenderArgs,
): Component {
const ui = createToolUIKit(uiTheme);
const { expanded, renderContext } = options;
const rawPath = args?.file_path || args?.path || "";
const filePath = shortenPath(rawPath);
const editLanguage = getLanguageFromPath(rawPath) ?? "text";
const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage));
const editDiffPreview = renderContext?.editDiffPreview;
const renderDiffFn = renderContext?.renderDiff ?? ((t: string) => t);
// Build path display with line number if available
let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis);
const firstChangedLine =
(editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) ||
(result.details && !result.isError ? result.details.firstChangedLine : undefined);
if (firstChangedLine) {
pathDisplay += uiTheme.fg("warning", `:${firstChangedLine}`);
}
let text = `${uiTheme.fg("toolTitle", uiTheme.bold("Edit"))} ${editIcon} ${pathDisplay}`;
const editLineCount = countLines(args?.newText ?? args?.oldText ?? "");
text += `\n${formatMetadataLine(editLineCount, editLanguage, uiTheme)}`;
if (result.isError) {
// Show error from result
const errorText = result.content?.find((c) => c.type === "text")?.text ?? "";
if (errorText) {
text += `\n\n${uiTheme.fg("error", errorText)}`;
}
} else if (editDiffPreview) {
// Use cached diff preview (works both before and after execution)
if ("error" in editDiffPreview) {
text += `\n\n${uiTheme.fg("error", editDiffPreview.error)}`;
} else if (editDiffPreview.diff) {
const diffStats = getDiffStats(editDiffPreview.diff);
text += `\n${uiTheme.fg("dim", uiTheme.format.bracketLeft)}${ui.formatDiffStats(
diffStats.added,
diffStats.removed,
diffStats.hunks,
)}${uiTheme.fg("dim", uiTheme.format.bracketRight)}`;
const {
text: diffText,
hiddenHunks,
hiddenLines,
} = expanded
? { text: editDiffPreview.diff, hiddenHunks: 0, hiddenLines: 0 }
: truncateDiffByHunk(editDiffPreview.diff, EDIT_DIFF_PREVIEW_HUNKS, EDIT_DIFF_PREVIEW_LINES);
text += `\n\n${renderDiffFn(diffText, { filePath: rawPath })}`;
if (!expanded && (hiddenHunks > 0 || hiddenLines > 0)) {
const remainder: string[] = [];
if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`);
text += uiTheme.fg(
"toolOutput",
`\n${uiTheme.format.ellipsis} (${remainder.join(", ")}) ${ui.wrapBrackets("Ctrl+O to expand")}`,
);
}
}
}
// Show LSP diagnostics if available
if (result.details?.diagnostics) {
text += ui.formatDiagnostics(result.details.diagnostics, expanded, (fp: string) =>
uiTheme.getLangIcon(getLanguageFromPath(fp)),
);
}
return new Text(text, 0, 0);
},
};