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
@@ -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", "^");
}