6224c18239
- Normalized chunk edit parsing now detected and warned on entries with multiple write/replace/insert operations, selected one operation by precedence, and ignored the rest. - Execution now appended those normalization warnings and included them when a chunk edit produced no content changes. - Updated chunk-edit and vim tool guidance plus vim rendering to document stricter edit usage and expose the current cursor position in output.
700 lines
22 KiB
TypeScript
700 lines
22 KiB
TypeScript
import * as fs from "node:fs/promises";
|
|
import * as nodePath from "node:path";
|
|
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
|
import { StringEnum } from "@oh-my-pi/pi-coding-agent";
|
|
import {
|
|
ChunkAnchorStyle,
|
|
ChunkEditOp,
|
|
type ChunkInfo,
|
|
ChunkReadStatus,
|
|
type ChunkReadTarget,
|
|
ChunkRegion,
|
|
ChunkState,
|
|
type EditOperation as NativeEditOperation,
|
|
} from "@oh-my-pi/pi-natives";
|
|
import { $envpos } from "@oh-my-pi/pi-utils";
|
|
import { type Static, Type } from "@sinclair/typebox";
|
|
import type { BunFile } from "bun";
|
|
import { LRUCache } from "lru-cache";
|
|
import type { Settings } from "../../config/settings";
|
|
import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
|
|
import { getLanguageFromPath } from "../../modes/theme/theme";
|
|
import type { ToolSession } from "../../tools";
|
|
import { assertEditableFileContent } from "../../tools/auto-generated-guard";
|
|
import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
|
|
import { outputMeta } from "../../tools/output-meta";
|
|
import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
|
|
import { generateUnifiedDiffString } from "../diff";
|
|
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
|
|
import type { EditToolDetails, LspBatchRequest } from "../renderer";
|
|
|
|
export type { ChunkReadTarget };
|
|
|
|
export type ChunkEditOperation =
|
|
| { op: "put"; sel?: string; content: string }
|
|
| { op: "replace"; sel?: string; content: string; find: string }
|
|
| { op: "delete"; sel?: string }
|
|
| { op: "before"; sel?: string; content: string }
|
|
| { op: "after"; sel?: string; content: string }
|
|
| { op: "prepend"; sel?: string; content: string }
|
|
| { op: "append"; sel?: string; content: string };
|
|
|
|
type ChunkEditResult = {
|
|
diffSourceBefore: string;
|
|
diffSourceAfter: string;
|
|
responseText: string;
|
|
changed: boolean;
|
|
parseValid: boolean;
|
|
touchedPaths: string[];
|
|
warnings: string[];
|
|
};
|
|
|
|
export type ParsedChunkReadPath = {
|
|
filePath: string;
|
|
selector?: string;
|
|
};
|
|
|
|
type ChunkCacheEntry = {
|
|
mtimeMs: number;
|
|
size: number;
|
|
source: string;
|
|
state: ChunkState;
|
|
};
|
|
|
|
const validAnchorStyles: Record<string, ChunkAnchorStyle> = {
|
|
full: ChunkAnchorStyle.Full,
|
|
kind: ChunkAnchorStyle.Kind,
|
|
bare: ChunkAnchorStyle.Bare,
|
|
};
|
|
|
|
export function resolveChunkAutoIndent(rawValue = Bun.env.PI_CHUNK_AUTOINDENT): boolean {
|
|
if (!rawValue) return true;
|
|
const normalized = rawValue.trim().toLowerCase();
|
|
switch (normalized) {
|
|
case "1":
|
|
case "true":
|
|
case "yes":
|
|
case "on":
|
|
return true;
|
|
case "0":
|
|
case "false":
|
|
case "no":
|
|
case "off":
|
|
return false;
|
|
default:
|
|
throw new Error(`Invalid PI_CHUNK_AUTOINDENT: ${rawValue}`);
|
|
}
|
|
}
|
|
|
|
function getChunkRenderIndentOptions(): {
|
|
normalizeIndent: boolean;
|
|
tabReplacement: string;
|
|
} {
|
|
return resolveChunkAutoIndent()
|
|
? { normalizeIndent: true, tabReplacement: " " }
|
|
: { normalizeIndent: false, tabReplacement: "\t" };
|
|
}
|
|
|
|
export function resolveAnchorStyle(settings?: Settings): ChunkAnchorStyle {
|
|
const envStyle = Bun.env.PI_ANCHOR_STYLE;
|
|
return (
|
|
(envStyle && validAnchorStyles[envStyle]) ||
|
|
(settings?.get("read.anchorstyle") as ChunkAnchorStyle | undefined) ||
|
|
ChunkAnchorStyle.Full
|
|
);
|
|
}
|
|
|
|
const chunkStateCache = new LRUCache<string, ChunkCacheEntry>({
|
|
max: $envpos("PI_CHUNK_CACHE_MAX_ENTRIES", 200),
|
|
});
|
|
|
|
export function invalidateChunkCache(filePath: string): void {
|
|
chunkStateCache.delete(filePath);
|
|
}
|
|
|
|
type ChunkSourceContext = {
|
|
resolvedPath: string;
|
|
sourceFile: BunFile;
|
|
sourceExists: boolean;
|
|
rawContent: string;
|
|
chunkLanguage: string | undefined;
|
|
};
|
|
|
|
function normalizeLanguage(language: string | undefined): string {
|
|
return language?.trim().toLowerCase() || "";
|
|
}
|
|
|
|
function normalizeChunkSource(text: string): string {
|
|
return normalizeToLF(stripBom(text).text);
|
|
}
|
|
|
|
function displayPathForFile(filePath: string, cwd: string): string {
|
|
const relative = nodePath.relative(cwd, filePath).replace(/\\/g, "/");
|
|
return relative && !relative.startsWith("..") ? relative : filePath.replace(/\\/g, "/");
|
|
}
|
|
|
|
function fileLanguageTag(filePath: string, language?: string): string | undefined {
|
|
const normalizedLanguage = normalizeLanguage(language);
|
|
if (normalizedLanguage.length > 0) return normalizedLanguage;
|
|
const ext = nodePath.extname(filePath).replace(/^\./, "").toLowerCase();
|
|
return ext.length > 0 ? ext : undefined;
|
|
}
|
|
|
|
async function resolveChunkSourceContext(session: ToolSession, path: string): Promise<ChunkSourceContext> {
|
|
const resolvedPath = resolvePlanPath(session, path);
|
|
const sourceFile = Bun.file(resolvedPath);
|
|
const sourceExists = await sourceFile.exists();
|
|
enforcePlanModeWrite(session, path, { op: sourceExists ? "update" : "create" });
|
|
|
|
let rawContent = "";
|
|
if (sourceExists) {
|
|
rawContent = await sourceFile.text();
|
|
assertEditableFileContent(rawContent, path);
|
|
}
|
|
|
|
return {
|
|
resolvedPath,
|
|
sourceFile,
|
|
sourceExists,
|
|
rawContent,
|
|
chunkLanguage: getLanguageFromPath(resolvedPath),
|
|
};
|
|
}
|
|
|
|
function normalizeChunkRegionSyntax(text: string): string {
|
|
return text.replaceAll("@body", "~").replaceAll("@head", "^");
|
|
}
|
|
|
|
function buildChunkEditResult(result: {
|
|
diffBefore: string;
|
|
diffAfter: string;
|
|
responseText: string;
|
|
changed: boolean;
|
|
parseValid: boolean;
|
|
touchedPaths: string[];
|
|
warnings: string[];
|
|
}): ChunkEditResult {
|
|
return {
|
|
diffSourceBefore: result.diffBefore,
|
|
diffSourceAfter: result.diffAfter,
|
|
responseText: result.responseText,
|
|
changed: result.changed,
|
|
parseValid: result.parseValid,
|
|
touchedPaths: result.touchedPaths,
|
|
warnings: result.warnings.map(normalizeChunkRegionSyntax),
|
|
};
|
|
}
|
|
|
|
function chunkReadPathSeparatorIndex(readPath: string): number {
|
|
if (/^[a-zA-Z]:[/\\]/.test(readPath)) {
|
|
return readPath.indexOf(":", 2);
|
|
}
|
|
return readPath.indexOf(":");
|
|
}
|
|
|
|
export function parseChunkSelector(selector: string | undefined): { selector?: string } {
|
|
if (!selector || selector.length === 0) {
|
|
return {};
|
|
}
|
|
return { selector };
|
|
}
|
|
|
|
/** Split a combined `file:selector` path into file path and chunk selector. */
|
|
export function parseChunkEditPath(editPath: string | undefined): { filePath: string; selector?: string } {
|
|
if (!editPath) return { filePath: "" };
|
|
const colonIndex = chunkReadPathSeparatorIndex(editPath);
|
|
if (colonIndex === -1) {
|
|
return { filePath: editPath };
|
|
}
|
|
const sel = editPath.slice(colonIndex + 1) || undefined;
|
|
return { filePath: editPath.slice(0, colonIndex), selector: sel };
|
|
}
|
|
|
|
export function parseChunkReadPath(readPath: string): ParsedChunkReadPath {
|
|
const colonIndex = chunkReadPathSeparatorIndex(readPath);
|
|
if (colonIndex === -1) {
|
|
return { filePath: readPath };
|
|
}
|
|
const parsedSelector = parseChunkSelector(readPath.slice(colonIndex + 1) || undefined);
|
|
return {
|
|
filePath: readPath.slice(0, colonIndex),
|
|
selector: parsedSelector.selector,
|
|
};
|
|
}
|
|
|
|
export function isChunkReadablePath(readPath: string): boolean {
|
|
return parseChunkReadPath(readPath).selector !== undefined;
|
|
}
|
|
|
|
export async function loadChunkStateForFile(filePath: string, language: string | undefined): Promise<ChunkCacheEntry> {
|
|
const file = Bun.file(filePath);
|
|
const stat = await file.stat();
|
|
const cached = chunkStateCache.get(filePath);
|
|
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
return cached;
|
|
}
|
|
|
|
const source = normalizeChunkSource(await file.text());
|
|
const state = ChunkState.parse(source, normalizeLanguage(language));
|
|
const entry = { mtimeMs: stat.mtimeMs, size: stat.size, source, state };
|
|
chunkStateCache.set(filePath, entry);
|
|
return entry;
|
|
}
|
|
|
|
export async function formatChunkedRead(params: {
|
|
filePath: string;
|
|
readPath: string;
|
|
cwd: string;
|
|
language?: string;
|
|
omitChecksum?: boolean;
|
|
anchorStyle?: ChunkAnchorStyle;
|
|
absoluteLineRange?: { startLine: number; endLine?: number };
|
|
}): Promise<{ text: string; resolvedPath?: string; chunk?: ChunkReadTarget }> {
|
|
const { filePath, readPath, cwd, language, omitChecksum = false, anchorStyle, absoluteLineRange } = params;
|
|
const normalizedLanguage = normalizeLanguage(language);
|
|
const { state } = await loadChunkStateForFile(filePath, normalizedLanguage);
|
|
const displayPath = displayPathForFile(filePath, cwd);
|
|
const renderIndentOptions = getChunkRenderIndentOptions();
|
|
const result = state.renderRead({
|
|
readPath,
|
|
displayPath,
|
|
languageTag: fileLanguageTag(filePath, normalizedLanguage),
|
|
omitChecksum,
|
|
anchorStyle,
|
|
absoluteLineRange: absoluteLineRange
|
|
? { startLine: absoluteLineRange.startLine, endLine: absoluteLineRange.endLine ?? absoluteLineRange.startLine }
|
|
: undefined,
|
|
tabReplacement: renderIndentOptions.tabReplacement,
|
|
normalizeIndent: renderIndentOptions.normalizeIndent,
|
|
});
|
|
return { text: result.text, resolvedPath: filePath, chunk: result.chunk };
|
|
}
|
|
|
|
export type ChunkedGrepMatch = {
|
|
displayPath: string;
|
|
fileLineCount: number;
|
|
chunkPath?: string;
|
|
chunkChecksum?: string;
|
|
lineNumber: number;
|
|
line: string;
|
|
};
|
|
|
|
export async function describeChunkedGrepMatch(params: {
|
|
filePath: string;
|
|
lineNumber: number;
|
|
line: string;
|
|
cwd: string;
|
|
language?: string;
|
|
}): Promise<ChunkedGrepMatch> {
|
|
const { filePath, lineNumber, line, cwd, language } = params;
|
|
const { state } = await loadChunkStateForFile(filePath, language);
|
|
const chunkPath = state.lineToContainingChunkPath(lineNumber) || undefined;
|
|
const chunkInfo = chunkPath ? state.chunk(chunkPath) : null;
|
|
return {
|
|
displayPath: displayPathForFile(filePath, cwd),
|
|
fileLineCount: state.lineCount,
|
|
chunkPath,
|
|
chunkChecksum: chunkInfo?.checksum,
|
|
lineNumber,
|
|
line,
|
|
};
|
|
}
|
|
|
|
const CHUNK_CHECKSUM_ALPHABET = "ZPMQVRWSNKTXJBYH";
|
|
type NativeChunkRegion = "head" | "body";
|
|
|
|
function isChunkChecksumToken(value: string): boolean {
|
|
return value.length === 4 && Array.from(value).every(ch => CHUNK_CHECKSUM_ALPHABET.includes(ch.toUpperCase()));
|
|
}
|
|
|
|
function parseChunkEditSelector(selector: string | undefined): {
|
|
selector?: string;
|
|
crc?: string;
|
|
region?: NativeChunkRegion;
|
|
} {
|
|
if (!selector) {
|
|
return {};
|
|
}
|
|
|
|
let trimmed = selector.trim();
|
|
if (trimmed.length === 0) {
|
|
return {};
|
|
}
|
|
|
|
let region: NativeChunkRegion | undefined;
|
|
const suffix = trimmed.at(-1);
|
|
if (suffix === "~" || suffix === "^") {
|
|
region = suffix === "~" ? "body" : "head";
|
|
trimmed = trimmed.slice(0, -1).trimEnd();
|
|
}
|
|
|
|
let selectorPart = trimmed;
|
|
let crc: string | undefined;
|
|
const hashIndex = selectorPart.lastIndexOf("#");
|
|
if (hashIndex >= 0) {
|
|
const suffix = selectorPart.slice(hashIndex + 1).trim();
|
|
if (isChunkChecksumToken(suffix)) {
|
|
crc = suffix.toUpperCase();
|
|
selectorPart = selectorPart.slice(0, hashIndex).trimEnd();
|
|
}
|
|
} else if (isChunkChecksumToken(selectorPart)) {
|
|
crc = selectorPart.toUpperCase();
|
|
selectorPart = "";
|
|
}
|
|
|
|
return { selector: selectorPart || undefined, crc, region };
|
|
}
|
|
|
|
type NativeChunkRegionEncoding = "named" | "symbolic";
|
|
|
|
function toNativeEditRegion(
|
|
region: NativeChunkRegion | undefined,
|
|
encoding: NativeChunkRegionEncoding,
|
|
): NativeEditOperation["region"] | undefined {
|
|
if (!region) {
|
|
return undefined;
|
|
}
|
|
if (encoding === "symbolic") {
|
|
return region === "body" ? ChunkRegion.Body : ChunkRegion.Head;
|
|
}
|
|
return region as unknown as NativeEditOperation["region"] | undefined;
|
|
}
|
|
|
|
function toNativeEditOperation(
|
|
operation: ChunkEditOperation,
|
|
defaultRegion: NativeChunkRegion | undefined,
|
|
encoding: NativeChunkRegionEncoding,
|
|
): NativeEditOperation {
|
|
const { selector, crc, region } = parseChunkEditSelector(operation.sel);
|
|
const nativeRegion = toNativeEditRegion(operation.sel === undefined ? (region ?? defaultRegion) : region, encoding);
|
|
switch (operation.op) {
|
|
case "put":
|
|
return {
|
|
op: ChunkEditOp.Put,
|
|
sel: selector,
|
|
crc,
|
|
region: nativeRegion,
|
|
content: operation.content,
|
|
};
|
|
case "replace":
|
|
return {
|
|
op: ChunkEditOp.Replace,
|
|
sel: selector,
|
|
crc,
|
|
region: nativeRegion,
|
|
find: operation.find,
|
|
content: operation.content,
|
|
};
|
|
case "before":
|
|
return { op: ChunkEditOp.Before, sel: selector, crc, region: nativeRegion, content: operation.content };
|
|
case "after":
|
|
return { op: ChunkEditOp.After, sel: selector, crc, region: nativeRegion, content: operation.content };
|
|
case "prepend":
|
|
return { op: ChunkEditOp.Prepend, sel: selector, crc, region: nativeRegion, content: operation.content };
|
|
case "append":
|
|
return { op: ChunkEditOp.Append, sel: selector, crc, region: nativeRegion, content: operation.content };
|
|
case "delete":
|
|
return { op: ChunkEditOp.Delete, sel: selector, crc, region: nativeRegion };
|
|
default: {
|
|
const exhaustive: never = operation;
|
|
return exhaustive;
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildNativeChunkEditRequest(
|
|
params: { defaultSelector?: string; defaultCrc?: string; operations: ChunkEditOperation[] },
|
|
encoding: NativeChunkRegionEncoding,
|
|
): Pick<Parameters<ChunkState["applyEdits"]>[0], "operations" | "defaultSelector" | "defaultCrc"> {
|
|
const parsedDefaultSelector = parseChunkEditSelector(params.defaultSelector);
|
|
const operations = params.operations.map(operation =>
|
|
toNativeEditOperation(operation, parsedDefaultSelector.region, encoding),
|
|
);
|
|
return {
|
|
operations,
|
|
defaultSelector: parsedDefaultSelector.selector,
|
|
defaultCrc: params.defaultCrc ?? parsedDefaultSelector.crc,
|
|
};
|
|
}
|
|
|
|
function isChunkRegionEncodingError(error: unknown): error is Error {
|
|
return (
|
|
error instanceof Error &&
|
|
/value `"(body|head|~|\^)"` does not match any variant of enum `ChunkRegion`/.test(error.message)
|
|
);
|
|
}
|
|
|
|
export function applyChunkEdits(params: {
|
|
source: string;
|
|
language?: string;
|
|
cwd: string;
|
|
filePath: string;
|
|
operations: ChunkEditOperation[];
|
|
defaultSelector?: string;
|
|
defaultCrc?: string;
|
|
anchorStyle?: ChunkAnchorStyle;
|
|
}): ChunkEditResult {
|
|
const normalizedSource = normalizeChunkSource(params.source);
|
|
const applyNativeEdits = (encoding: NativeChunkRegionEncoding): ChunkEditResult => {
|
|
const request = buildNativeChunkEditRequest(params, encoding);
|
|
const state = ChunkState.parse(normalizedSource, normalizeLanguage(params.language));
|
|
return buildChunkEditResult(
|
|
state.applyEdits({
|
|
operations: request.operations,
|
|
normalizeIndent: resolveChunkAutoIndent(),
|
|
defaultSelector: request.defaultSelector,
|
|
defaultCrc: request.defaultCrc,
|
|
anchorStyle: params.anchorStyle,
|
|
cwd: params.cwd,
|
|
filePath: params.filePath,
|
|
}),
|
|
);
|
|
};
|
|
|
|
try {
|
|
return applyNativeEdits("named");
|
|
} catch (error) {
|
|
if (isChunkRegionEncodingError(error)) {
|
|
try {
|
|
return applyNativeEdits("symbolic");
|
|
} catch (fallbackError) {
|
|
if (fallbackError instanceof Error) {
|
|
throw new Error(normalizeChunkRegionSyntax(fallbackError.message));
|
|
}
|
|
throw fallbackError;
|
|
}
|
|
}
|
|
if (error instanceof Error) {
|
|
throw new Error(normalizeChunkRegionSyntax(error.message));
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function getChunkInfoForFile(
|
|
filePath: string,
|
|
language: string | undefined,
|
|
chunkPath: string,
|
|
): Promise<ChunkInfo | undefined> {
|
|
const { state } = await loadChunkStateForFile(filePath, language);
|
|
return state.chunk(chunkPath) ?? undefined;
|
|
}
|
|
|
|
export function missingChunkReadTarget(selector: string): ChunkReadTarget {
|
|
return { status: ChunkReadStatus.NotFound, selector };
|
|
}
|
|
|
|
export const chunkToolEditSchema = Type.Object(
|
|
{
|
|
path: Type.String({
|
|
description: "File path with chunk selector. Examples: 'src/app.ts:fn_foo#ABCD~', 'src/app.ts:class_Bar'.",
|
|
}),
|
|
write: Type.Optional(
|
|
Type.Union([Type.String(), Type.Null()], {
|
|
description: "Write complete new content to the targeted region. Use null to delete the chunk.",
|
|
}),
|
|
),
|
|
replace: Type.Optional(
|
|
Type.Object(
|
|
{
|
|
old: Type.String({ description: "Literal substring to find. Must match exactly once." }),
|
|
new: Type.String({ description: "Replacement text." }),
|
|
},
|
|
{ description: "Find and replace a substring within the chunk." },
|
|
),
|
|
),
|
|
insert: Type.Optional(
|
|
Type.Object(
|
|
{
|
|
loc: StringEnum(["append", "prepend"] as const),
|
|
body: Type.String({ description: "Content to insert." }),
|
|
},
|
|
{ description: "Insert content relative to the chunk." },
|
|
),
|
|
),
|
|
},
|
|
{ additionalProperties: false },
|
|
);
|
|
export const chunkEditParamsSchema = Type.Object(
|
|
{
|
|
edits: Type.Array(chunkToolEditSchema, {
|
|
description: "Chunk edits",
|
|
minItems: 1,
|
|
}),
|
|
},
|
|
{ additionalProperties: false },
|
|
);
|
|
|
|
export type ChunkToolEdit = Static<typeof chunkToolEditSchema>;
|
|
export type ChunkParams = Static<typeof chunkEditParamsSchema>;
|
|
|
|
export interface ExecuteChunkSingleOptions {
|
|
session: ToolSession;
|
|
path: string;
|
|
edits: ChunkToolEdit[];
|
|
signal?: AbortSignal;
|
|
batchRequest?: LspBatchRequest;
|
|
writethrough: WritethroughCallback;
|
|
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
|
}
|
|
|
|
export function isChunkParams(params: unknown): params is ChunkParams {
|
|
if (
|
|
typeof params !== "object" ||
|
|
params === null ||
|
|
!("edits" in params) ||
|
|
!Array.isArray(params.edits) ||
|
|
params.edits.length === 0
|
|
) {
|
|
return false;
|
|
}
|
|
const first = params.edits[0];
|
|
if (typeof first !== "object" || first === null || !("path" in first)) return false;
|
|
return "write" in first || "replace" in first || "insert" in first;
|
|
}
|
|
|
|
function normalizeChunkEditOperations(edits: ChunkToolEdit[]): {
|
|
operations: ChunkEditOperation[];
|
|
warnings: string[];
|
|
} {
|
|
const warnings: string[] = [];
|
|
const operations = edits.map((edit, index): ChunkEditOperation => {
|
|
const { selector } = parseChunkEditPath(edit.path);
|
|
// When multiple ops are present (model confusion), pick the most substantive one.
|
|
// insert with real body > replace with real old/new > write string > delete
|
|
const hasInsert = edit.insert != null && typeof edit.insert.body === "string" && edit.insert.body.length > 0;
|
|
const hasReplace =
|
|
edit.replace != null &&
|
|
((typeof edit.replace.old === "string" && edit.replace.old.length > 0) ||
|
|
(typeof edit.replace.new === "string" && edit.replace.new.length > 0));
|
|
const hasWrite = typeof edit.write === "string";
|
|
const opCount = [hasInsert, hasReplace, hasWrite].filter(Boolean).length;
|
|
if (opCount > 1) {
|
|
const chosen = hasInsert ? "insert" : hasReplace ? "replace" : "write";
|
|
const present = [hasInsert && "insert", hasReplace && "replace", hasWrite && "write"]
|
|
.filter(Boolean)
|
|
.join(", ");
|
|
warnings.push(
|
|
`Edit ${index + 1}: multiple operation fields set (${present}). Each edit entry must have exactly ONE of write/replace/insert — not multiple. Used "${chosen}", ignored the rest.`,
|
|
);
|
|
}
|
|
if (hasInsert) {
|
|
const op = edit.insert!.loc === "prepend" ? "before" : "after";
|
|
return { op, sel: selector, content: edit.insert!.body };
|
|
}
|
|
if (hasReplace) {
|
|
return { op: "replace", sel: selector, content: edit.replace!.new, find: edit.replace!.old };
|
|
}
|
|
if (hasWrite) {
|
|
return { op: "put", sel: selector, content: edit.write };
|
|
}
|
|
// write: null or no op specified → delete
|
|
return { op: "delete", sel: selector };
|
|
});
|
|
return { operations, warnings };
|
|
}
|
|
|
|
async function writeChunkResult(params: {
|
|
result: ChunkEditResult;
|
|
resolvedPath: string;
|
|
sourceFile: BunFile;
|
|
sourceText: string;
|
|
sourceExists: boolean;
|
|
signal?: AbortSignal;
|
|
batchRequest?: LspBatchRequest;
|
|
writethrough: WritethroughCallback;
|
|
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
|
}): Promise<AgentToolResult<EditToolDetails, typeof chunkEditParamsSchema>> {
|
|
const {
|
|
result,
|
|
resolvedPath,
|
|
sourceFile,
|
|
sourceText,
|
|
sourceExists,
|
|
signal,
|
|
batchRequest,
|
|
writethrough,
|
|
beginDeferredDiagnosticsForPath,
|
|
} = params;
|
|
|
|
const { bom, text } = stripBom(sourceText);
|
|
const originalEnding = detectLineEnding(text);
|
|
const finalContent = bom + restoreLineEndings(result.diffSourceAfter, originalEnding);
|
|
const diagnostics = await writethrough(resolvedPath, finalContent, signal, sourceFile, batchRequest, dst =>
|
|
dst === resolvedPath ? beginDeferredDiagnosticsForPath(resolvedPath) : undefined,
|
|
);
|
|
invalidateFsScanAfterWrite(resolvedPath);
|
|
|
|
const diffResult = generateUnifiedDiffString(result.diffSourceBefore, result.diffSourceAfter);
|
|
const warningsBlock = result.warnings.length > 0 ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
|
|
const meta = outputMeta()
|
|
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
|
|
.get();
|
|
|
|
return {
|
|
content: [{ type: "text", text: `${result.responseText}${warningsBlock}` }],
|
|
details: {
|
|
diff: diffResult.diff,
|
|
firstChangedLine: diffResult.firstChangedLine,
|
|
diagnostics,
|
|
op: sourceExists ? "update" : "create",
|
|
meta,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function executeChunkSingle(
|
|
options: ExecuteChunkSingleOptions,
|
|
): Promise<AgentToolResult<EditToolDetails, typeof chunkEditParamsSchema>> {
|
|
const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
|
|
const { resolvedPath, sourceFile, sourceExists, rawContent, chunkLanguage } = await resolveChunkSourceContext(
|
|
session,
|
|
path,
|
|
);
|
|
const parentDir = nodePath.dirname(resolvedPath);
|
|
if (parentDir && parentDir !== ".") {
|
|
await fs.mkdir(parentDir, { recursive: true });
|
|
}
|
|
const { operations: normalizedOperations, warnings: normWarnings } = normalizeChunkEditOperations(edits);
|
|
|
|
if (!sourceExists && normalizedOperations.some(op => op.sel)) {
|
|
throw new Error(
|
|
`File does not exist: ${path}. Cannot resolve chunk selectors on a non-existent file. Use the write tool to create a new file, or check the path for typos.`,
|
|
);
|
|
}
|
|
|
|
const chunkResult = applyChunkEdits({
|
|
source: rawContent,
|
|
language: chunkLanguage,
|
|
cwd: session.cwd,
|
|
filePath: resolvedPath,
|
|
operations: normalizedOperations,
|
|
anchorStyle: resolveAnchorStyle(session.settings),
|
|
});
|
|
chunkResult.warnings.push(...normWarnings);
|
|
|
|
if (!chunkResult.changed) {
|
|
const warningsBlock = chunkResult.warnings.length > 0 ? `\n\nWarnings:\n${chunkResult.warnings.join("\n")}` : "";
|
|
return {
|
|
content: [{ type: "text", text: `[No changes needed — content already matches.]${warningsBlock}` }],
|
|
details: {
|
|
diff: "",
|
|
op: sourceExists ? "update" : "create",
|
|
meta: outputMeta().get(),
|
|
},
|
|
};
|
|
}
|
|
|
|
return writeChunkResult({
|
|
result: chunkResult,
|
|
resolvedPath,
|
|
sourceFile,
|
|
sourceText: rawContent,
|
|
sourceExists,
|
|
signal,
|
|
batchRequest,
|
|
writethrough,
|
|
beginDeferredDiagnosticsForPath,
|
|
});
|
|
}
|