Files
oh-my-pi/packages/coding-agent/src/edit/streaming.ts
T
can1357 4e32fd0060 feat(hashline)!: required + prefix on payload continuation lines
- Changed multiline payload syntax so continuation lines must start with `+`; that prefix is stripped before writing.
- Raw unprefixed lines after an op now throw an error instead of being silently accepted as payload.
- Removed `PAYLOAD_LINE_PREFIX_DEMOTED_WARNING` and the nested-replace demotion path; inner `N:` ops inside a pending `A-B:` now raise an overlap error.
- Raw blank lines between ops are ignored; use `+` alone for an empty payload line.
2026-05-27 00:37:18 +02:00

558 lines
19 KiB
TypeScript

/**
* 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 { sanitizeText } from "@oh-my-pi/pi-utils";
import {
ABORT_MARKER,
BEGIN_PATCH_MARKER,
computeHashlineDiff,
computeHashlineSectionDiff,
containsRecognizableHashlineOperations,
END_PATCH_MARKER,
type HashlineInputSection,
HashlineTokenizer,
splitHashlineInputs,
} from "../hashline";
import type { Theme } from "../modes/theme/theme";
import { replaceTabs, truncateToWidth } from "../tools/render-utils";
import { type EditMode, resolveEditMode } from "../utils/edit-mode";
import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
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;
hashlineAutoDropPureInsertDuplicates?: boolean;
/**
* True while the tool's arguments are still streaming in. Strategies that
* accept free-form text input (apply_patch, hashline) trim the trailing
* partial line so per-character growth of an in-flight `+added` line does
* not flicker in the preview.
*/
isStreaming?: 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;
}
const STREAMING_FALLBACK_LINES = 12;
const STREAMING_FALLBACK_WIDTH = 80;
// Streaming-preview classification reuses one tokenizer instance for the
// stateless predicates and `tokenize`/`tokenizeAll` helpers; instances are
// cheap, but keeping a single module-level reference matches the rest of
// the hashline package.
const HASHLINE_TOKENIZER = new HashlineTokenizer();
function trimHashlineStreamingSyntax(lines: string[]): string[] {
let index = lines.findIndex(line => line.trim().length > 0);
if (index === -1) return [];
if (HASHLINE_TOKENIZER.tokenize(lines[index]).kind === "envelope-begin") {
index++;
while (index < lines.length && lines[index].trim().length === 0) index++;
}
if (index < lines.length && HASHLINE_TOKENIZER.tokenize(lines[index]).kind === "header") {
index++;
}
return lines.slice(index).filter(line => !HASHLINE_TOKENIZER.isEnvelopeMarker(line));
}
function renderHashlineInputFallback(input: string, uiTheme: Theme): string {
const lines = trimHashlineStreamingSyntax(sanitizeText(input).split("\n"));
if (!lines.some(line => line.trim().length > 0)) return "";
const displayLines = lines.slice(-STREAMING_FALLBACK_LINES);
const hidden = lines.length - displayLines.length;
let text = "\n\n";
text += displayLines
.map(line => uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), STREAMING_FALLBACK_WIDTH)))
.join("\n");
if (hidden > 0) {
text += uiTheme.fg("dim", `\n… (streaming +${hidden} lines)`);
} else {
text += uiTheme.fg("dim", "\n(streaming)");
}
return text;
}
// -----------------------------------------------------------------------------
// 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 a spurious null-write error 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];
}
// -----------------------------------------------------------------------------
// Apply_patch remains multi-file because the Codex envelope carries paths per hunk.
// -----------------------------------------------------------------------------
function groupApplyPatchEntriesByPath(entries: readonly ApplyPatchEntry[]): Map<string, ApplyPatchEntry[]> {
const groups = new Map<string, ApplyPatchEntry[]>();
for (const entry of entries) {
let bucket = groups.get(entry.path);
if (!bucket) {
bucket = [];
groups.set(entry.path, bucket);
}
bucket.push(entry);
}
return groups;
}
// -----------------------------------------------------------------------------
// Strategies
// -----------------------------------------------------------------------------
interface ReplaceArgs {
path?: string;
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) {
if (!args.path) return null;
const first = args.edits?.[0];
if (!first || first.old_text === undefined || first.new_text === undefined) return null;
ctx.signal.throwIfAborted();
const result = await computeEditDiff(
args.path,
first.old_text,
first.new_text,
ctx.cwd,
ctx.allowFuzzy ?? true,
first.all,
ctx.fuzzyThreshold,
);
ctx.signal.throwIfAborted();
return [toPerFilePreview(args.path, result)];
},
renderStreamingFallback() {
return "";
},
};
interface PatchArgs {
path?: string;
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) {
if (!args.path) return null;
const first = args.edits?.[0];
if (!first) return null;
ctx.signal.throwIfAborted();
const result = await computePatchDiff(
{ path: args.path, op: first.op ?? "update", rename: first.rename, diff: first.diff },
ctx.cwd,
{ fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
);
ctx.signal.throwIfAborted();
return [toPerFilePreview(args.path, result)];
},
renderStreamingFallback() {
return "";
},
};
interface HashlineArgs {
input?: string;
path?: string;
__partialJson?: string;
}
/**
* While streaming a free-form text payload (apply_patch envelope, hashline
* input), trim the trailing partial line so per-character growth of an
* in-flight `+added` line does not cause the diff preview to flicker. The
* full line will show on the next streaming tick once its `\n` arrives.
* Returns `text` unchanged when not streaming or when no newline is present.
*/
function trimTrailingPartialLine(text: string, isStreaming: boolean | undefined): string {
if (!isStreaming) return text;
const idx = text.lastIndexOf("\n");
if (idx === -1) return "";
return text.slice(0, idx + 1);
}
/**
* Build a per-file diff preview directly from a partial `apply_patch`
* envelope by emitting its body lines in *input order*. This bypasses the
* file-state re-diff (`computePatchDiff` → `Diff.structuredPatch`) whose
* coalescing reorders the model's `-old +new -old +new` stream into
* `-old -old +new +new` and visibly shifts existing `+added` lines
* downward each time a new `-` arrives. The preview therefore grows
* monotonically at the bottom while streaming and only becomes a real
* unified diff once the args are complete.
*/
function buildApplyPatchNaturalOrderPreviews(input: string): PerFileDiffPreview[] | null {
const lines = input.split("\n");
const groups = new Map<string, string[]>();
let currentPath: string | undefined;
const ensure = (path: string): string[] => {
let bucket = groups.get(path);
if (!bucket) {
bucket = [];
groups.set(path, bucket);
}
return bucket;
};
for (const raw of lines) {
const trimmedEnd = raw.trimEnd();
if (trimmedEnd === BEGIN_PATCH_MARKER || trimmedEnd === END_PATCH_MARKER || trimmedEnd === ABORT_MARKER) {
continue;
}
if (trimmedEnd.startsWith("*** Add File: ")) {
currentPath = trimmedEnd.slice("*** Add File: ".length);
ensure(currentPath);
continue;
}
if (trimmedEnd.startsWith("*** Delete File: ")) {
currentPath = trimmedEnd.slice("*** Delete File: ".length);
ensure(currentPath);
continue;
}
if (trimmedEnd.startsWith("*** Update File: ")) {
currentPath = trimmedEnd.slice("*** Update File: ".length);
ensure(currentPath);
continue;
}
if (trimmedEnd.startsWith("*** Move to:") || trimmedEnd.startsWith("*** End of File")) {
continue;
}
if (!currentPath) continue;
// Diff body: keep `-/+/space`-prefixed lines and `@@` hunk headers in
// input order. parseDiffLine accepts the no-line-number legacy form so
// the renderer styles them as additions/removals/context naturally.
if (raw.startsWith("+") || raw.startsWith("-") || raw.startsWith(" ") || raw.startsWith("@@")) {
ensure(currentPath).push(raw);
}
}
if (groups.size === 0) return null;
const previews: PerFileDiffPreview[] = [];
for (const [path, body] of groups) {
if (body.length === 0) continue;
previews.push({ path, diff: body.join("\n") });
}
return previews.length > 0 ? previews : null;
}
/**
* Hashline equivalent: emit each payload line as a `+added` line in the
* order the model typed it. We deliberately omit op headers and removal
* targets from the streaming preview because their content lives in the file
* and would require a costly re-apply per tick; the complete unified diff is
* shown once streaming finishes.
*/
function buildHashlineNaturalOrderPreviews(
input: string,
defaultPath: string | undefined,
): PerFileDiffPreview[] | null {
const groups = new Map<string, string[]>();
let currentPath = defaultPath ?? "";
const ensure = (sectionPath: string): string[] => {
let bucket = groups.get(sectionPath);
if (!bucket) {
bucket = [];
groups.set(sectionPath, bucket);
}
return bucket;
};
// Per-call instance: the streaming preview re-runs each tick with the
// cumulative input, and we need the line counter to start at 1. A
// dedicated tokenizer keeps the shared HASHLINE_TOKENIZER above free
// for stateless predicate use elsewhere in this module.
const streamer = new HashlineTokenizer();
for (const token of streamer.tokenizeAll(input)) {
switch (token.kind) {
case "envelope-begin":
case "envelope-end":
case "abort":
case "op-delete":
continue;
case "blank":
case "raw":
continue;
case "header":
currentPath = token.path;
if (currentPath) ensure(currentPath);
continue;
case "op-insert":
case "op-replace":
// Inline body on the op line itself (`N↓payload`, `A-B:payload`) is
// payload content that just happens to share a line with the op
// header — render it the same as a standalone payload token so
// the very first character the model types after the sigil shows
// up in the streaming preview. Without this, the preview is
// empty until a newline arrives, and the renderer falls back to
// raw input ("A-B: bla bla bla") instead of "+ bla bla bla".
if (!currentPath || token.inlineBody === undefined) continue;
ensure(currentPath).push(`+${token.inlineBody}`);
continue;
case "payload":
if (!currentPath) continue;
ensure(currentPath).push(`+${token.text}`);
continue;
}
}
if (groups.size === 0) return null;
const previews: PerFileDiffPreview[] = [];
for (const [sectionPath, body] of groups) {
if (body.length === 0) continue;
previews.push({ path: sectionPath, diff: body.join("\n") });
}
return previews.length > 0 ? previews : null;
}
const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
extractCompleteEdits(args) {
return args;
},
async computeDiffPreview(args, ctx) {
if (typeof args.input !== "string" || args.input.length === 0) return null;
const input = trimTrailingPartialLine(args.input, ctx.isStreaming);
if (input.length === 0) return null;
if (ctx.isStreaming) {
// Skip the costly per-tick re-apply and avoid `Diff.structuredPatch`
// reordering by showing payload lines in input order.
return buildHashlineNaturalOrderPreviews(input, args.path);
}
ctx.signal.throwIfAborted();
let sections: HashlineInputSection[];
try {
sections = splitHashlineInputs(input, { cwd: ctx.cwd, path: args.path });
} catch {
// Single-section fallback keeps the original error rendering for the
// "haven't typed `¶ PATH` yet" case.
const result = await computeHashlineDiff({ input, path: args.path }, ctx.cwd, {
autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
});
ctx.signal.throwIfAborted();
if ("error" in result && !args.path) return [{ path: "", error: result.error }];
return [toPerFilePreview(args.path ?? "", result)];
}
if (sections.length === 0) return null;
// While the trailing section is still being typed (no operations yet)
// skip it so its empty/parse-error result doesn't replace previews of
// already-completed sections with an opaque header.
const lastIndex = sections.length - 1;
const trailingIncomplete =
sections.length > 1 && !containsRecognizableHashlineOperations(sections[lastIndex].diff);
const sectionsToProcess = trailingIncomplete ? sections.slice(0, -1) : sections;
const trailingProcessedIndex = sectionsToProcess.length - 1;
const previews: PerFileDiffPreview[] = [];
for (let i = 0; i < sectionsToProcess.length; i++) {
ctx.signal.throwIfAborted();
const section = sectionsToProcess[i];
const result = await computeHashlineSectionDiff(section, ctx.cwd, {
autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
});
ctx.signal.throwIfAborted();
// In a multi-section preview, ignore parse/apply errors from the
// last section: it's still streaming and the partial op may not
// parse yet. Earlier sections are stable and stay rendered.
if (sectionsToProcess.length > 1 && i === trailingProcessedIndex && "error" in result) {
continue;
}
previews.push(toPerFilePreview(section.path, result));
}
return previews.length > 0 ? previews : null;
},
renderStreamingFallback(args, uiTheme) {
return typeof args.input === "string" ? renderHashlineInputFallback(args.input, uiTheme) : "";
},
};
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;
const input = trimTrailingPartialLine(args.input, ctx.isStreaming);
if (input.length === 0) return null;
if (ctx.isStreaming) {
// Render the envelope's diff body in input order so newly streamed
// `+added` lines append at the bottom instead of being shuffled
// upward as later `-removed` lines arrive and reorder the unified
// diff that `Diff.structuredPatch` would otherwise produce.
return buildApplyPatchNaturalOrderPreviews(input);
}
let entries: ApplyPatchEntry[];
try {
entries = expandApplyPatchToEntries({ input });
} catch {
try {
entries = expandApplyPatchToPreviewEntries({ input });
} catch (err) {
return [{ path: "", error: err instanceof Error ? err.message : String(err) }];
}
}
const groups = groupApplyPatchEntriesByPath(entries);
if (groups.size === 0) return null;
const previews: PerFileDiffPreview[] = [];
for (const [path, fileEntries] of groups) {
const first = fileEntries[0];
if (!first) continue;
ctx.signal.throwIfAborted();
const result = await computePatchDiff(
{ path, op: first.op ?? "update", rename: first.rename, diff: first.diff },
ctx.cwd,
{ fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
);
ctx.signal.throwIfAborted();
previews.push(toPerFilePreview(path, result));
}
return previews.length > 0 ? previews : 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>,
apply_patch: applyPatchStrategy as EditStreamingStrategy<unknown>,
};
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 };
}