feat(coding-agent): added mode-aware streaming preview for edit outputs

- Added mode-aware streaming mode resolution and strategy registration in tool execution flow.
- Changed edit rendering to propagate mode and file-scoped diff previews across edit/vim tool outputs.
- Added chunk-mode streaming helpers (`loadChunkSource`, `computeChunkDiff`, `dropIncompleteLastEdit`) with abort-aware error fallbacks.
- Added strategy-specific streaming preview support for replace, patch, hashline, apply_patch, and vim modes.
- Fixed streaming previews by dropping incomplete trailing edits and handling invalid chunk or aborted inputs.
- Expanded streaming and chunk-diff tests for partial JSON, open edits, empty paths, abort signals, and file loading checks.
This commit is contained in:
can1357
2026-04-24 08:24:30 +02:00
parent 781c2f40fa
commit 21d50d5703
8 changed files with 720 additions and 234 deletions
+1
View File
@@ -68,6 +68,7 @@ export * from "./modes/patch";
export * from "./modes/replace";
export * from "./normalize";
export * from "./renderer";
export * from "./streaming";
type TInput =
| typeof replaceEditSchema
@@ -161,6 +161,57 @@ async function resolveChunkSourceContext(session: ToolSession, path: string): Pr
};
}
/**
* Preview-safe loader: read raw source without plan-mode enforcement or
* editable-file guards. Used by streaming diff previews that must not throw
* side-effecting errors while args are still being streamed.
*/
export async function loadChunkSource(params: {
cwd: string;
path: string;
}): Promise<{ resolvedPath: string; rawContent: string; language: string | undefined; exists: boolean }> {
const resolvedPath = nodePath.isAbsolute(params.path) ? params.path : nodePath.resolve(params.cwd, params.path);
const sourceFile = Bun.file(resolvedPath);
const exists = await sourceFile.exists();
const rawContent = exists ? await sourceFile.text() : "";
return { resolvedPath, rawContent, language: getLanguageFromPath(resolvedPath), exists };
}
/**
* Compute a unified diff preview for a chunk edit without applying it.
* Used for streaming previews while args are still arriving. Returns
* `{ error }` on any failure so callers can decide whether to surface it.
*/
export async function computeChunkDiff(
input: { path: string; edits: ChunkToolEdit[] },
cwd: string,
options?: { anchorStyle?: ChunkAnchorStyle; signal?: AbortSignal },
): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
try {
options?.signal?.throwIfAborted?.();
const { filePath } = parseChunkEditPath(input.path);
if (!filePath) return { error: "chunk edit path is empty" };
const { resolvedPath, rawContent, language } = await loadChunkSource({ cwd, path: filePath });
options?.signal?.throwIfAborted?.();
const { operations } = normalizeChunkEditOperations(input.edits);
const result = applyChunkEdits({
source: rawContent,
language,
cwd,
filePath: resolvedPath,
operations,
anchorStyle: options?.anchorStyle,
});
options?.signal?.throwIfAborted?.();
if (!result.changed) {
return { diff: "", firstChangedLine: undefined };
}
return generateUnifiedDiffString(result.diffSourceBefore, result.diffSourceAfter);
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
function normalizeChunkRegionSyntax(text: string): string {
return text.replaceAll("@body", "~").replaceAll("@head", "^");
}
+56 -135
View File
@@ -24,12 +24,12 @@ import {
} from "../tools/render-utils";
import { type VimRenderArgs, vimToolRenderer } from "../tools/vim";
import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
import type { EditMode } from "../utils/edit-mode";
import type { VimToolDetails } from "../vim/types";
import type { DiffError, DiffResult } from "./diff";
import { expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
import { type ChunkToolEdit, parseChunkEditPath } from "./modes/chunk";
import type { HashlineToolEdit } from "./modes/hashline";
import type { Operation, PatchEditEntry } from "./modes/patch";
import type { PerFileDiffPreview } from "./streaming";
// ═══════════════════════════════════════════════════════════════════════════
// LSP Batching
@@ -133,8 +133,12 @@ function isVimToolDetails(details: unknown): details is VimToolDetails {
/** Extended context for edit tool rendering */
export interface EditRenderContext {
/** Edit mode resolved by the caller; lets the renderer dispatch without shape-sniffing */
editMode?: EditMode;
/** Pre-computed diff preview (computed before tool executes) */
editDiffPreview?: DiffResult | DiffError;
/** Multi-file streaming diff preview (chunk edits spanning several files) */
perFileDiffPreview?: PerFileDiffPreview[];
/** Function to render diff text with syntax highlighting */
renderDiff?: (diffText: string, options?: { filePath?: string }) => string;
}
@@ -142,14 +146,6 @@ export interface EditRenderContext {
const EDIT_STREAMING_PREVIEW_LINES = 12;
const CALL_TEXT_PREVIEW_LINES = 6;
const CALL_TEXT_PREVIEW_WIDTH = 80;
const STREAMING_EDIT_PREVIEW_WIDTH = 120;
const STREAMING_EDIT_PREVIEW_LIMIT = 4;
const STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT = 8;
interface FormattedStreamingEdit {
srcLabel: string;
dst: string;
}
/** Extract file path from an edit entry's path (handles chunk's file:selector format). */
function filePathFromEditEntry(p: string | undefined): string | undefined {
@@ -230,111 +226,6 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, labe
return text;
}
function isChunkStreamingEdit(edit: Partial<HashlineToolEdit | ChunkToolEdit>): edit is Partial<ChunkToolEdit> {
return (
typeof edit === "object" &&
edit !== null &&
"path" in edit &&
("write" in edit || "replace" in edit || "insert" in edit)
);
}
function getStreamingEditContent(content: unknown): string {
if (Array.isArray(content)) {
return content.join("\n");
}
return typeof content === "string" ? content : "";
}
function formatHashlineStreamingEdit(edit: Partial<HashlineToolEdit>): FormattedStreamingEdit {
if (typeof edit !== "object" || !edit) {
return { srcLabel: "\u2022 (incomplete edit)", dst: "" };
}
const contentLines = getStreamingEditContent(edit.content);
const loc = edit.loc;
if (loc === "append" || loc === "prepend") {
return { srcLabel: `\u2022 ${loc} (file-level)`, dst: contentLines };
}
if (typeof loc === "object" && loc) {
if ("range" in loc && typeof loc.range === "object" && loc.range) {
return { srcLabel: `\u2022 range ${loc.range.pos ?? "?"}\u2026${loc.range.end ?? "?"}`, dst: contentLines };
}
if ("line" in loc) {
return { srcLabel: `\u2022 line ${(loc as { line: string }).line}`, dst: contentLines };
}
if ("append" in loc) {
return { srcLabel: `\u2022 append ${(loc as { append: string }).append}`, dst: contentLines };
}
if ("prepend" in loc) {
return { srcLabel: `\u2022 prepend ${(loc as { prepend: string }).prepend}`, dst: contentLines };
}
}
return { srcLabel: "\u2022 (unknown edit)", dst: contentLines };
}
function formatChunkStreamingEdit(edit: Partial<ChunkToolEdit>): FormattedStreamingEdit {
if (typeof edit !== "object" || !edit) {
return { srcLabel: "\u2022 (incomplete edit)", dst: "" };
}
const target = edit.path ? (parseChunkEditPath(edit.path).selector ?? edit.path) : "?";
if (edit.write === null) {
return { srcLabel: `\u2022 remove ${target}`, dst: "" };
}
if (typeof edit.write === "string") {
return { srcLabel: `\u2022 replace ${target}`, dst: getStreamingEditContent(edit.write) };
}
if (typeof edit.replace === "object" && edit.replace) {
return { srcLabel: `\u2022 replace ${target}`, dst: getStreamingEditContent(edit.replace.new) };
}
if (typeof edit.insert === "object" && edit.insert) {
return { srcLabel: `\u2022 ${edit.insert.loc} ${target}`, dst: getStreamingEditContent(edit.insert.body) };
}
return { srcLabel: `\u2022 edit ${target}`, dst: "" };
}
function formatStreamingHashlineEdits(edits: Partial<HashlineToolEdit | ChunkToolEdit>[], uiTheme: Theme): string {
let text = "\n\n";
// Detect whether these are chunk edits (target field) or hashline edits (loc field)
const isChunk = edits.length > 0 && isChunkStreamingEdit(edits[0]);
const label = isChunk ? "chunk edit" : "hashline edit";
const formatEdit = isChunk ? formatChunkStreamingEdit : formatHashlineStreamingEdit;
text += uiTheme.fg("dim", `[${edits.length} ${label}${edits.length === 1 ? "" : "s"}]`);
text += "\n";
let shownEdits = 0;
let shownDstLines = 0;
for (const edit of edits) {
shownEdits++;
if (shownEdits > STREAMING_EDIT_PREVIEW_LIMIT) break;
const formatted = formatEdit(edit as never);
text += uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(formatted.srcLabel), STREAMING_EDIT_PREVIEW_WIDTH));
text += "\n";
if (formatted.dst === "") {
text += uiTheme.fg("dim", truncateToWidth(" (delete)", STREAMING_EDIT_PREVIEW_WIDTH));
text += "\n";
continue;
}
for (const dstLine of formatted.dst.split("\n")) {
shownDstLines++;
if (shownDstLines > STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT) break;
text += uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(`+ ${dstLine}`), STREAMING_EDIT_PREVIEW_WIDTH));
text += "\n";
}
if (shownDstLines > STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT) break;
}
if (edits.length > STREAMING_EDIT_PREVIEW_LIMIT) {
text += uiTheme.fg("dim", `\u2026 (${edits.length - STREAMING_EDIT_PREVIEW_LIMIT} more edits)`);
}
if (shownDstLines > STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT) {
text += uiTheme.fg("dim", `\n\u2026 (${shownDstLines - STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT} more dst lines)`);
}
return text.trimEnd();
}
function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string {
const icon = uiTheme.getLangIcon(language);
if (lineCount !== null) {
@@ -343,20 +234,38 @@ function formatMetadataLine(lineCount: number | null, language: string | undefin
return uiTheme.fg("dim", `${icon}`);
}
function getCallPreview(args: EditRenderArgs, rawPath: string, uiTheme: Theme): string {
function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: Theme): string {
const parts: string[] = [];
for (const preview of previews) {
if (!preview.diff && !preview.error) continue;
const header = uiTheme.fg("dim", `\n\n── ${shortenPath(preview.path)} ──`);
if (preview.error) {
parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error))}`);
continue;
}
if (preview.diff) {
parts.push(`${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, "preview")}`);
}
}
return parts.join("");
}
function getCallPreview(
args: EditRenderArgs,
rawPath: string,
uiTheme: Theme,
renderContext: EditRenderContext | undefined,
): string {
const multi = renderContext?.perFileDiffPreview;
if (multi && multi.length > 0 && multi.some(p => p.diff || p.error)) {
return formatMultiFileStreamingDiff(multi, uiTheme);
}
if (args.previewDiff) {
return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, "preview");
}
if (args.diff && args.op) {
return formatStreamingDiff(args.diff, rawPath, uiTheme);
}
if (args.edits && args.edits.length > 0) {
// Only show hashline/chunk streaming edits — replace/patch use previewDiff above
const first = args.edits[0];
if (first && typeof first === "object" && ("loc" in first || isChunkStreamingEdit(first))) {
return formatStreamingHashlineEdits(args.edits, uiTheme);
}
}
if (args.diff) {
return renderPlainTextPreview(args.diff, uiTheme);
}
@@ -446,31 +355,43 @@ function wrapEditRendererLine(line: string, width: number): string[] {
export const editToolRenderer = {
mergeCallAndResult: true,
renderCall(args: EditRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
if (isVimRenderArgs(args)) {
return vimToolRenderer.renderCall(args, options, uiTheme);
renderCall(
args: EditRenderArgs | VimRenderArgs,
options: RenderResultOptions & { renderContext?: EditRenderContext },
uiTheme: Theme,
): Component {
const renderContext = options.renderContext;
// Dispatch on the explicit editMode when available; fall back to the
// shape probe for legacy call sites that don't thread renderContext.
if (renderContext?.editMode === "vim" || isVimRenderArgs(args)) {
return vimToolRenderer.renderCall(args as VimRenderArgs, options, uiTheme);
}
const applyPatchSummary = getApplyPatchRenderSummary(args, options.isPartial);
const editArgs = args as EditRenderArgs;
const applyPatchSummary = getApplyPatchRenderSummary(editArgs, options.isPartial);
const firstApplyPatchEntry = applyPatchSummary?.entries[0];
// Extract path from first edit entry when top-level path is absent (new schema)
const firstEdit = Array.isArray(args.edits) && args.edits.length > 0 ? args.edits[0] : undefined;
const firstEdit = Array.isArray(editArgs.edits) && editArgs.edits.length > 0 ? editArgs.edits[0] : undefined;
const rawPath =
args.file_path || args.path || filePathFromEditEntry(firstEdit?.path) || firstApplyPatchEntry?.path || "";
const rename = args.rename || firstEdit?.rename || firstEdit?.move || firstApplyPatchEntry?.rename;
const op = args.op || firstEdit?.op || firstApplyPatchEntry?.op;
editArgs.file_path ||
editArgs.path ||
filePathFromEditEntry(firstEdit?.path) ||
firstApplyPatchEntry?.path ||
"";
const rename = editArgs.rename || firstEdit?.rename || firstEdit?.move || firstApplyPatchEntry?.rename;
const op = editArgs.op || firstEdit?.op || firstApplyPatchEntry?.op;
const { description } = formatEditDescription(rawPath, uiTheme, { rename });
const spinner =
options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
let text = `${formatTitle(getOperationTitle(op), uiTheme)} ${spinner ? `${spinner} ` : ""}${description}`;
// Show file count hint for multi-file edits
const fileCount = Array.isArray(args.edits)
? countEditFiles(args.edits)
const fileCount = Array.isArray(editArgs.edits)
? countEditFiles(editArgs.edits)
: (applyPatchSummary?.entries.length ?? 0);
if (fileCount > 1) {
text += uiTheme.fg("dim", ` (+${fileCount - 1} more)`);
}
text += getCallPreview(args, rawPath, uiTheme);
text += getCallPreview(editArgs, rawPath, uiTheme, renderContext);
if (applyPatchSummary?.error) {
text += `\n\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error), CALL_TEXT_PREVIEW_WIDTH))}`;
}
@@ -484,7 +405,7 @@ export const editToolRenderer = {
uiTheme: Theme,
args?: EditRenderArgs,
): Component {
if (isVimToolDetails(result.details)) {
if (options.renderContext?.editMode === "vim" || isVimToolDetails(result.details)) {
return vimToolRenderer.renderResult(
result as { content: Array<{ type: string; text?: string }>; details?: VimToolDetails; isError?: boolean },
options,
+350
View File
@@ -0,0 +1,350 @@
/**
* Streaming edit preview strategies.
*
* Each edit mode owns a strategy that knows how to:
* - collapse partial-JSON args to the subset safe to preview
* (`extractCompleteEdits`),
* - compute unified diff previews for the in-flight args
* (`computeDiffPreview`), and
* - render a text placeholder while no diff exists yet
* (`renderStreamingFallback`).
*
* The shared renderer / `ToolExecutionComponent` consult the strategy via
* the injected `editMode` rather than probing argument shape.
*/
import type { Theme } from "../modes/theme/theme";
import { type EditMode, resolveEditMode } from "../utils/edit-mode";
import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
import { expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
import { type ChunkToolEdit, computeChunkDiff, parseChunkEditPath } from "./modes/chunk";
import { computeHashlineDiff, type HashlineToolEdit } from "./modes/hashline";
import { computePatchDiff, type PatchEditEntry } from "./modes/patch";
import type { ReplaceEditEntry } from "./modes/replace";
export interface PerFileDiffPreview {
path: string;
diff?: string;
firstChangedLine?: number;
error?: string;
}
export interface StreamingDiffContext {
cwd: string;
signal: AbortSignal;
fuzzyThreshold?: number;
allowFuzzy?: boolean;
}
export interface EditStreamingStrategy<Args = unknown> {
/**
* Return the args restricted to edits that are "complete enough" to
* compute a diff against. Strategies drop the trailing incomplete entry
* when `partialJson` indicates its closing `}` hasn't arrived yet.
*/
extractCompleteEdits(args: Args, partialJson: string | undefined): Args;
/**
* Compute diff(s) for the given partial args. Returns `null` when args
* do not yet carry enough structure to compute anything.
*/
computeDiffPreview(args: Args, ctx: StreamingDiffContext): Promise<PerFileDiffPreview[] | null>;
/**
* Rendered inline while the diff hasn't been computed yet (or when the
* compute returned `null` because args are still too partial).
*/
renderStreamingFallback(args: Args, uiTheme: Theme): string;
}
// -----------------------------------------------------------------------------
// Partial-JSON handling
// -----------------------------------------------------------------------------
/**
* Given an edits array parsed from partial JSON, drop the last entry when the
* corresponding object in `partialJson` does not yet end with a closed `}`.
*
* This guards against `partial-json` silently coercing truncated tails like
* `"write":nu` / `"write":nul` into `{ write: null }`, which would make the
* last entry render as a spurious delete until the value finishes streaming.
*/
export function dropIncompleteLastEdit<T>(edits: readonly T[], partialJson: string | undefined, listKey: string): T[] {
if (!Array.isArray(edits) || edits.length === 0) return [...(edits ?? [])];
if (!partialJson) return [...edits];
const keyMarker = `"${listKey}"`;
const keyIdx = partialJson.indexOf(keyMarker);
if (keyIdx === -1) return [...edits];
// Find the `[` that opens the list value.
let i = partialJson.indexOf("[", keyIdx + keyMarker.length);
if (i === -1) return [...edits];
i++;
let depth = 0;
let inString = false;
let escaped = false;
let lastClose = -1;
for (; i < partialJson.length; i++) {
const ch = partialJson[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
if (inString) escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch === "{" || ch === "[") {
depth++;
} else if (ch === "}" || ch === "]") {
depth--;
if (ch === "}" && depth === 0) {
lastClose = i;
}
if (ch === "]" && depth === -1) {
// End of list reached.
break;
}
}
}
// If we're still inside the list and saw no closing `}` for the last entry,
// or there is trailing non-whitespace after the last `}` before the list
// ended (i.e. a new object has opened), drop the trailing entry.
const tail = lastClose === -1 ? partialJson.slice(i) : partialJson.slice(lastClose + 1);
const sawNewObjectAfterLastClose = /\{/.test(tail);
const listIsStillOpen = depth >= 0;
if (lastClose === -1 || (listIsStillOpen && sawNewObjectAfterLastClose)) {
return edits.slice(0, -1);
}
return [...edits];
}
// -----------------------------------------------------------------------------
// Strategies
// -----------------------------------------------------------------------------
interface ReplaceArgs {
edits?: ReplaceEditEntry[];
__partialJson?: string;
}
const replaceStrategy: EditStreamingStrategy<ReplaceArgs> = {
extractCompleteEdits(args, partialJson) {
if (!args?.edits) return args;
return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
},
async computeDiffPreview(args, ctx) {
const first = args.edits?.[0];
if (!first?.path || first.old_text === undefined || first.new_text === undefined) return null;
ctx.signal.throwIfAborted();
const result = await computeEditDiff(
first.path,
first.old_text,
first.new_text,
ctx.cwd,
ctx.allowFuzzy ?? true,
first.all,
ctx.fuzzyThreshold,
);
ctx.signal.throwIfAborted();
return [toPerFilePreview(first.path, result)];
},
renderStreamingFallback() {
return "";
},
};
interface PatchArgs {
edits?: PatchEditEntry[];
__partialJson?: string;
}
const patchStrategy: EditStreamingStrategy<PatchArgs> = {
extractCompleteEdits(args, partialJson) {
if (!args?.edits) return args;
return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
},
async computeDiffPreview(args, ctx) {
const first = args.edits?.[0];
if (!first?.path) return null;
ctx.signal.throwIfAborted();
const result = await computePatchDiff(
{ path: first.path, op: first.op ?? "update", rename: first.rename, diff: first.diff },
ctx.cwd,
{ fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
);
ctx.signal.throwIfAborted();
return [toPerFilePreview(first.path, result)];
},
renderStreamingFallback() {
return "";
},
};
interface HashlineArgs {
edits?: HashlineToolEdit[];
move?: string;
__partialJson?: string;
}
const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
extractCompleteEdits(args, partialJson) {
if (!args?.edits) return args;
return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
},
async computeDiffPreview(args, ctx) {
const first = args.edits?.[0] as (HashlineToolEdit & { path?: string }) | undefined;
if (!first?.path) return null;
const path = first.path;
const fileEdits = (args.edits ?? []).filter((e): e is HashlineToolEdit & { path: string } => {
return !!e && typeof e === "object" && (e as { path?: string }).path === path;
});
ctx.signal.throwIfAborted();
const result = await computeHashlineDiff({ path, edits: fileEdits, move: args.move }, ctx.cwd);
ctx.signal.throwIfAborted();
return [toPerFilePreview(path, result)];
},
renderStreamingFallback() {
return "";
},
};
interface ChunkArgs {
edits?: ChunkToolEdit[];
__partialJson?: string;
}
const chunkStrategy: EditStreamingStrategy<ChunkArgs> = {
extractCompleteEdits(args, partialJson) {
if (!args?.edits) return args;
let edits = dropIncompleteLastEdit(args.edits, partialJson, "edits");
// Extra guard: if partial JSON still contains `":nu` / `":nul` (partial
// `null` literals), `partial-json` may have already surfaced the last
// entry with `write === null`. When that entry's `}` hasn't closed
// yet, it has already been dropped above. But if dropping was not
// triggered (e.g. list still open and no new `{` after), also drop
// a trailing entry that is *only* `{ path }` because partial-json
// stripped the in-flight value entirely.
if (partialJson && edits.length > 0) {
const last = edits[edits.length - 1] as Partial<ChunkToolEdit> | undefined;
const endsInPartialNull = /:\s*nu?l?\s*$/.test(partialJson.trimEnd());
if (last && endsInPartialNull && last.write === null) {
edits = edits.slice(0, -1);
}
}
return { ...args, edits };
},
async computeDiffPreview(args, ctx) {
const edits = args.edits ?? [];
if (edits.length === 0) return null;
// Group edits by file path
const groups = new Map<string, ChunkToolEdit[]>();
const fileOrder: string[] = [];
for (const edit of edits) {
if (!edit?.path) continue;
const { filePath } = parseChunkEditPath(edit.path);
if (!filePath) continue;
let bucket = groups.get(filePath);
if (!bucket) {
bucket = [];
groups.set(filePath, bucket);
fileOrder.push(filePath);
}
bucket.push(edit);
}
if (fileOrder.length === 0) return null;
const MAX_FILES = 5;
const selected = fileOrder.slice(0, MAX_FILES);
const previews: PerFileDiffPreview[] = [];
for (const filePath of selected) {
ctx.signal.throwIfAborted();
const fileEdits = groups.get(filePath) ?? [];
const result = await computeChunkDiff({ path: filePath, edits: fileEdits }, ctx.cwd, { signal: ctx.signal });
previews.push(toPerFilePreview(filePath, result));
}
return previews;
},
renderStreamingFallback() {
return "";
},
};
interface ApplyPatchArgs {
input?: string;
}
const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
extractCompleteEdits(args) {
// Apply_patch payload is plain text, not an edits array. Nothing to trim.
return args;
},
async computeDiffPreview(args, ctx) {
if (typeof args.input !== "string" || args.input.length === 0) return null;
let entries: PatchEditEntry[];
try {
entries = expandApplyPatchToEntries({ input: args.input });
} catch {
try {
entries = expandApplyPatchToPreviewEntries({ input: args.input });
} catch (err) {
return [{ path: "", error: err instanceof Error ? err.message : String(err) }];
}
}
const first = entries[0];
if (!first?.path) return null;
ctx.signal.throwIfAborted();
const result = await computePatchDiff(
{ path: first.path, op: first.op ?? "update", rename: first.rename, diff: first.diff },
ctx.cwd,
{ fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
);
ctx.signal.throwIfAborted();
return [toPerFilePreview(first.path, result)];
},
renderStreamingFallback() {
return "";
},
};
// Vim streaming preview is handled by the existing vimToolRenderer inside
// edit/renderer.ts. The strategy here is a no-op so the registry is total.
const vimStrategy: EditStreamingStrategy<unknown> = {
extractCompleteEdits(args) {
return args;
},
async computeDiffPreview() {
return null;
},
renderStreamingFallback() {
return "";
},
};
export const EDIT_MODE_STRATEGIES: Record<EditMode, EditStreamingStrategy<unknown>> = {
replace: replaceStrategy as EditStreamingStrategy<unknown>,
patch: patchStrategy as EditStreamingStrategy<unknown>,
hashline: hashlineStrategy as EditStreamingStrategy<unknown>,
chunk: chunkStrategy as EditStreamingStrategy<unknown>,
apply_patch: applyPatchStrategy as EditStreamingStrategy<unknown>,
vim: vimStrategy,
};
export { resolveEditMode };
// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------
function toPerFilePreview(path: string, result: DiffResult | DiffError): PerFileDiffPreview {
if ("error" in result) {
return { path, error: result.error };
}
return { path, diff: result.diff, firstChangedLine: result.firstChangedLine };
}