Archived
feat: improve edit tool display in web UI
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
import { diffLines } from "diff";
|
||||
|
||||
export interface EditReplacement {
|
||||
oldText: string;
|
||||
newText: string;
|
||||
}
|
||||
|
||||
export type EditPreviewResult =
|
||||
| { diff: string; firstChangedLine?: number }
|
||||
| { error: string };
|
||||
|
||||
export async function computeEditPreview(path: string, edits: EditReplacement[], cwd: string): Promise<EditPreviewResult> {
|
||||
const absolutePath = isAbsolute(path) ? path : resolve(cwd, path);
|
||||
try {
|
||||
try {
|
||||
await access(absolutePath, constants.R_OK);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && "code" in error ? `Error code: ${String(error.code)}` : String(error);
|
||||
return { error: `Could not edit file: ${path}. ${message}.` };
|
||||
}
|
||||
|
||||
const rawContent = await readFile(absolutePath, "utf8");
|
||||
const { text: content } = stripBom(rawContent);
|
||||
const normalizedContent = normalizeToLF(content);
|
||||
const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
|
||||
return generateDiffString(baseContent, newContent);
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeToLF(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
}
|
||||
|
||||
function stripBom(content: string): { bom: string; text: string } {
|
||||
return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
|
||||
}
|
||||
|
||||
function normalizeForFuzzyMatch(text: string): string {
|
||||
return text
|
||||
.normalize("NFKC")
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.join("\n")
|
||||
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
|
||||
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
|
||||
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
|
||||
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
|
||||
}
|
||||
|
||||
function fuzzyFindText(content: string, oldText: string): { found: boolean; index: number; matchLength: number; usedFuzzyMatch: boolean } {
|
||||
const exactIndex = content.indexOf(oldText);
|
||||
if (exactIndex !== -1) return { found: true, index: exactIndex, matchLength: oldText.length, usedFuzzyMatch: false };
|
||||
|
||||
const fuzzyContent = normalizeForFuzzyMatch(content);
|
||||
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
|
||||
const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText);
|
||||
if (fuzzyIndex === -1) return { found: false, index: -1, matchLength: 0, usedFuzzyMatch: false };
|
||||
return { found: true, index: fuzzyIndex, matchLength: fuzzyOldText.length, usedFuzzyMatch: true };
|
||||
}
|
||||
|
||||
function countOccurrences(content: string, oldText: string): number {
|
||||
const fuzzyContent = normalizeForFuzzyMatch(content);
|
||||
const fuzzyOldText = normalizeForFuzzyMatch(oldText);
|
||||
if (fuzzyOldText === "") return 0;
|
||||
return fuzzyContent.split(fuzzyOldText).length - 1;
|
||||
}
|
||||
|
||||
function applyEditsToNormalizedContent(normalizedContent: string, edits: EditReplacement[], path: string): { baseContent: string; newContent: string } {
|
||||
const normalizedEdits = edits.map((edit) => ({ oldText: normalizeToLF(edit.oldText), newText: normalizeToLF(edit.newText) }));
|
||||
for (let index = 0; index < normalizedEdits.length; index++) {
|
||||
if ((normalizedEdits[index]?.oldText ?? "") === "") throw editError("empty", path, index, normalizedEdits.length);
|
||||
}
|
||||
|
||||
const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText));
|
||||
const baseContent = initialMatches.some((match) => match.usedFuzzyMatch) ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent;
|
||||
|
||||
const matchedEdits: { editIndex: number; matchIndex: number; matchLength: number; newText: string }[] = [];
|
||||
for (let index = 0; index < normalizedEdits.length; index++) {
|
||||
const edit = normalizedEdits[index];
|
||||
if (edit === undefined) continue;
|
||||
const match = fuzzyFindText(baseContent, edit.oldText);
|
||||
if (!match.found) throw editError("missing", path, index, normalizedEdits.length);
|
||||
const occurrences = countOccurrences(baseContent, edit.oldText);
|
||||
if (occurrences > 1) throw editError("duplicate", path, index, normalizedEdits.length, occurrences);
|
||||
matchedEdits.push({ editIndex: index, matchIndex: match.index, matchLength: match.matchLength, newText: edit.newText });
|
||||
}
|
||||
|
||||
matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);
|
||||
for (let index = 1; index < matchedEdits.length; index++) {
|
||||
const previous = matchedEdits[index - 1];
|
||||
const current = matchedEdits[index];
|
||||
if (previous !== undefined && current !== undefined && previous.matchIndex + previous.matchLength > current.matchIndex) {
|
||||
throw new Error(`edits[${String(previous.editIndex)}] and edits[${String(current.editIndex)}] overlap in ${path}. Merge them into one edit or target disjoint regions.`);
|
||||
}
|
||||
}
|
||||
|
||||
let newContent = baseContent;
|
||||
for (let index = matchedEdits.length - 1; index >= 0; index--) {
|
||||
const edit = matchedEdits[index];
|
||||
if (edit === undefined) continue;
|
||||
newContent = `${newContent.slice(0, edit.matchIndex)}${edit.newText}${newContent.slice(edit.matchIndex + edit.matchLength)}`;
|
||||
}
|
||||
if (baseContent === newContent) throw editError("nochange", path, 0, normalizedEdits.length);
|
||||
return { baseContent, newContent };
|
||||
}
|
||||
|
||||
function editError(kind: "missing" | "duplicate" | "empty" | "nochange", path: string, editIndex: number, totalEdits: number, occurrences?: number): Error {
|
||||
const prefix = totalEdits === 1 ? "" : `edits[${String(editIndex)}].`;
|
||||
if (kind === "empty") return new Error(totalEdits === 1 ? `oldText must not be empty in ${path}.` : `${prefix}oldText must not be empty in ${path}.`);
|
||||
if (kind === "missing") return new Error(totalEdits === 1 ? `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.` : `Could not find edits[${String(editIndex)}] in ${path}. The oldText must match exactly including all whitespace and newlines.`);
|
||||
if (kind === "duplicate") return new Error(totalEdits === 1 ? `Found ${String(occurrences)} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.` : `Found ${String(occurrences)} occurrences of edits[${String(editIndex)}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`);
|
||||
return new Error(totalEdits === 1 ? `No changes made to ${path}. The replacement produced identical content.` : `No changes made to ${path}. The replacements produced identical content.`);
|
||||
}
|
||||
|
||||
function generateDiffString(oldContent: string, newContent: string, contextLines = 4): { diff: string; firstChangedLine?: number } {
|
||||
const parts = diffLines(oldContent, newContent);
|
||||
const output: string[] = [];
|
||||
const oldLines = oldContent.split("\n");
|
||||
const newLines = newContent.split("\n");
|
||||
const maxLineNum = Math.max(oldLines.length, newLines.length);
|
||||
const lineNumWidth = String(maxLineNum).length;
|
||||
let oldLineNum = 1;
|
||||
let newLineNum = 1;
|
||||
let lastWasChange = false;
|
||||
let firstChangedLine: number | undefined;
|
||||
|
||||
for (let index = 0; index < parts.length; index++) {
|
||||
const part = parts[index];
|
||||
if (part === undefined) continue;
|
||||
const raw = part.value.split("\n");
|
||||
if (raw.at(-1) === "") raw.pop();
|
||||
|
||||
if (part.added || part.removed) {
|
||||
firstChangedLine ??= newLineNum;
|
||||
for (const line of raw) {
|
||||
if (part.added) {
|
||||
output.push(`+${String(newLineNum).padStart(lineNumWidth, " ")} ${line}`);
|
||||
newLineNum++;
|
||||
} else {
|
||||
output.push(`-${String(oldLineNum).padStart(lineNumWidth, " ")} ${line}`);
|
||||
oldLineNum++;
|
||||
}
|
||||
}
|
||||
lastWasChange = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextPart = parts[index + 1];
|
||||
const nextPartIsChange = (nextPart?.added ?? false) || (nextPart?.removed ?? false);
|
||||
const hasLeadingChange = lastWasChange;
|
||||
const hasTrailingChange = nextPartIsChange;
|
||||
|
||||
if (hasLeadingChange && hasTrailingChange) {
|
||||
if (raw.length <= contextLines * 2) {
|
||||
appendContextLines(output, raw, lineNumWidth, () => oldLineNum++, () => newLineNum++);
|
||||
} else {
|
||||
const leadingLines = raw.slice(0, contextLines);
|
||||
const trailingLines = raw.slice(raw.length - contextLines);
|
||||
const skippedLines = raw.length - leadingLines.length - trailingLines.length;
|
||||
appendContextLines(output, leadingLines, lineNumWidth, () => oldLineNum++, () => newLineNum++);
|
||||
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
||||
oldLineNum += skippedLines;
|
||||
newLineNum += skippedLines;
|
||||
appendContextLines(output, trailingLines, lineNumWidth, () => oldLineNum++, () => newLineNum++);
|
||||
}
|
||||
} else if (hasLeadingChange) {
|
||||
const shownLines = raw.slice(0, contextLines);
|
||||
const skippedLines = raw.length - shownLines.length;
|
||||
appendContextLines(output, shownLines, lineNumWidth, () => oldLineNum++, () => newLineNum++);
|
||||
if (skippedLines > 0) {
|
||||
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
||||
oldLineNum += skippedLines;
|
||||
newLineNum += skippedLines;
|
||||
}
|
||||
} else if (hasTrailingChange) {
|
||||
const skippedLines = Math.max(0, raw.length - contextLines);
|
||||
if (skippedLines > 0) {
|
||||
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
|
||||
oldLineNum += skippedLines;
|
||||
newLineNum += skippedLines;
|
||||
}
|
||||
appendContextLines(output, raw.slice(skippedLines), lineNumWidth, () => oldLineNum++, () => newLineNum++);
|
||||
} else {
|
||||
oldLineNum += raw.length;
|
||||
newLineNum += raw.length;
|
||||
}
|
||||
lastWasChange = false;
|
||||
}
|
||||
|
||||
return { diff: output.join("\n"), ...(firstChangedLine === undefined ? {} : { firstChangedLine }) };
|
||||
}
|
||||
|
||||
function appendContextLines(output: string[], lines: string[], lineNumWidth: number, nextOldLine: () => number, nextNewLine: () => number): void {
|
||||
for (const line of lines) {
|
||||
const oldLine = nextOldLine();
|
||||
nextNewLine();
|
||||
output.push(` ${String(oldLine).padStart(lineNumWidth, " ")} ${line}`);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,13 @@ import {
|
||||
createAgentSessionFromServices,
|
||||
createAgentSessionRuntime,
|
||||
createAgentSessionServices,
|
||||
createEditToolDefinition,
|
||||
defineTool,
|
||||
getAgentDir,
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
type CreateAgentSessionRuntimeFactory,
|
||||
type EditToolDetails,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { ClientCommand, ClientCommandResult, ClientMessagePage, ClientSession, ClientSessionModel, ClientSessionStatus, ClientThinkingLevel, SessionUiEvent } from "../types.js";
|
||||
import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
|
||||
@@ -19,6 +22,7 @@ import { SessionArchiveStore, type ArchivedSessionRecord, type ArchiveSessionInp
|
||||
import type { ActiveSession } from "./sessionRuntimeStore.js";
|
||||
import type { AuthChange } from "./authService.js";
|
||||
import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
|
||||
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
|
||||
|
||||
function noop(): void {
|
||||
// Intentionally empty default unsubscribe callback.
|
||||
@@ -118,14 +122,39 @@ function defaultCreateAgentRuntime(createRuntime: CreateAgentSessionRuntimeFacto
|
||||
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance): CreateAgentSessionRuntimeFactory {
|
||||
return async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
|
||||
const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
|
||||
const customTools = [createPiWebEditToolDefinition(cwd)];
|
||||
const options = sessionStartEvent === undefined
|
||||
? { services, sessionManager }
|
||||
: { services, sessionManager, sessionStartEvent };
|
||||
? { services, sessionManager, customTools }
|
||||
: { services, sessionManager, sessionStartEvent, customTools };
|
||||
const result = await createAgentSessionFromServices(options);
|
||||
return { ...result, services, diagnostics: services.diagnostics };
|
||||
};
|
||||
}
|
||||
|
||||
type PiWebEditToolDetails = EditToolDetails | { preview: EditPreviewResult } | undefined;
|
||||
|
||||
function createPiWebEditToolDefinition(cwd: string) {
|
||||
const editTool = createEditToolDefinition(cwd);
|
||||
return defineTool<typeof editTool.parameters, PiWebEditToolDetails>({
|
||||
name: editTool.name,
|
||||
label: editTool.label,
|
||||
description: editTool.description,
|
||||
...(editTool.promptSnippet === undefined ? {} : { promptSnippet: editTool.promptSnippet }),
|
||||
...(editTool.promptGuidelines === undefined ? {} : { promptGuidelines: editTool.promptGuidelines }),
|
||||
parameters: editTool.parameters,
|
||||
...(editTool.renderShell === undefined ? {} : { renderShell: editTool.renderShell }),
|
||||
...(editTool.prepareArguments === undefined ? {} : { prepareArguments: editTool.prepareArguments }),
|
||||
...(editTool.executionMode === undefined ? {} : { executionMode: editTool.executionMode }),
|
||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
||||
const preview = await computeEditPreview(params.path, params.edits, cwd);
|
||||
if (signal?.aborted !== true) {
|
||||
onUpdate?.({ content: [{ type: "text", text: "Edit preview computed." }], details: { preview } });
|
||||
}
|
||||
return editTool.execute(toolCallId, params, signal, onUpdate, ctx);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface PiSessionServiceDependencies {
|
||||
archiveStore?: SessionArchiveRepository;
|
||||
agentDir?: string;
|
||||
@@ -777,9 +806,13 @@ function toClientEvent(event: unknown): SessionUiEvent {
|
||||
const args = getProperty(event, "args");
|
||||
return { type: "tool.start", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", summary: summarizeToolArgs(args), args };
|
||||
}
|
||||
if (eventType === "tool_execution_update") {
|
||||
const partialResult = getProperty(event, "partialResult");
|
||||
return { type: "tool.update", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", text: stringifyToolResult(partialResult), content: toolResultContent(partialResult), details: toolResultDetails(partialResult) };
|
||||
}
|
||||
if (eventType === "tool_execution_end") {
|
||||
const result = getProperty(event, "result");
|
||||
return { type: "tool.end", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", text: stringifyToolResult(result), content: toolResultContent(result), isError: getBoolean(event, "isError") === true };
|
||||
return { type: "tool.end", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", text: stringifyToolResult(result), content: toolResultContent(result), details: toolResultDetails(result), isError: getBoolean(event, "isError") === true };
|
||||
}
|
||||
if (eventType === "agent_start") return { type: "agent.start" };
|
||||
if (eventType === "agent_end") return { type: "agent.end" };
|
||||
@@ -822,6 +855,10 @@ function toolResultContent(result: unknown): unknown {
|
||||
return result;
|
||||
}
|
||||
|
||||
function toolResultDetails(result: unknown): unknown {
|
||||
return isRecord(result) ? getProperty(result, "details") : undefined;
|
||||
}
|
||||
|
||||
function stringifyToolResult(result: unknown): string {
|
||||
if (typeof result === "string") return result;
|
||||
if (Array.isArray(result)) return result.map(stringifyToolResult).filter((text) => text !== "").join("\n");
|
||||
|
||||
Reference in New Issue
Block a user