feat(hashline): implemented clipboard operations in hashline engine

- Implemented clipboard register management, parsing, and execution rules for CUT, COPY, and PASTE operations in the hashline engine.
- Added session-persistent clipboard state and integration across agent session execution, diff previews, and streaming tools.
- Added comprehensive validation, error messages, recovery handling, and test coverage for clipboard and block operations.
This commit is contained in:
can1357
2026-07-30 06:13:58 +02:00
parent 52bd191b34
commit 9aada058ee
29 changed files with 1389 additions and 120 deletions
+2
View File
@@ -5,6 +5,7 @@
### Added
- Added server-name autocomplete for `/mcp` commands (`enable`, `disable`, `test`, `remove`, `reconnect`, `reauth`, `unauth`) using configured and runtime-discovered MCP servers.
- Added `CUT`/`COPY`/`PASTE` ops to the hashline edit tool for moving code without retyping it: `CUT N.=M` / `COPY N.=M` (and `.BLK` block forms) capture lines into a clipboard register, `PASTE.PRE|POST N` / `PASTE.HEAD|TAIL` / `PASTE.BLK.POST N` insert them. The register flows across sections within a patch (cross-file moves) and persists across edit calls per session; streaming and permission diff previews render pasted content, and copy-only sections report "copied to clipboard" instead of tripping the no-change guard.
- Added `--from-claude` and `--from-codex` session imports (including compaction state for Codex), also available from `/resume @claude` and `/resume @codex`.
- Added interactive Exa API-key onboarding through `/login exa`, opening the official key dashboard and saving pasted keys for authenticated web search while preserving `EXA_API_KEY` and explicit-selection public MCP fallback behavior ([#1798](https://github.com/can1357/oh-my-pi/issues/1798)).
- Added `ExtensionContext.getAsyncJobSnapshot()` so extensions can read the owning session's async-job state without relying on process-global job-manager identity
@@ -23,6 +24,7 @@
### Fixed
- Fixed Anthropic prompt-cache cold misses on session resume with multiple OAuth accounts: the account that served a session is now recorded in the session file (as a `credential_pin` sha-256 of the account + org/project scope, so exports carry no plaintext identity) and re-pinned on resume with the session's effective last-use time, so a fresh process no longer re-ranks accounts by usage headroom — which systematically routed away from the just-used account and cold-missed the entire account-scoped cache prefix. Sticky routing was previously stored only in the auth store's KV cache, which is in-memory when a remote auth broker is configured.
- Fixed Anthropic prompt-cache cold misses on session resume with multiple OAuth accounts: the account that served a session is now recorded in the session file (as a PII-free `credential_pin` hash) and re-pinned on resume, so a fresh process no longer re-ranks accounts by usage headroom — which systematically routed away from the just-used account and cold-missed the entire account-scoped cache prefix. Sticky routing was previously stored only in the auth store's KV cache, which is in-memory when a remote auth broker is configured.
- Fixed concurrent `createAgentSession` calls with the default agent id failing initialization with `Agent "Main" was replaced during session initialization` — each in-process embedder (e.g. the edit benchmark runner) can now pass a private registry via the newly exported `AgentRegistry`, keeping every top-level session's "Main" out of the process-global roster race.
- Fixed task tool blocks duplicating their per-agent progress rows into terminal scrollback on every update: live task frames now pin the transcript live region so mid-run rows are never recorded as frozen snapshots, and a detached background task freezes its progress the moment any of its rows commit to scrollback instead of mutating committed history.
- Fixed Codex reset fireworks comparing different quota tiers or plans, preventing false celebrations when usage reports switch between Spark and base weekly limits.
@@ -0,0 +1,23 @@
/**
* Session-bound edit clipboard register.
*
* `CUT`/`COPY`/`PASTE` hashline ops share one {@link Clipboard} per session so
* content captured by one edit call can be pasted by a later one (and across
* files within one call). The executor works on a fork per batch and commits
* it back only after every write lands — see `executeHashlineSingle`.
*/
import type { Clipboard } from "@oh-my-pi/hashline";
interface EditClipboardOwner {
editClipboard?: Clipboard;
}
/**
* Look up (or lazily create) the clipboard register attached to a session.
* Storage lives on `session.editClipboard` so it ages out exactly with the
* session itself.
*/
export function getEditClipboard(session: EditClipboardOwner): Clipboard {
session.editClipboard ??= {};
return session.editClipboard;
}
+59 -10
View File
@@ -13,6 +13,7 @@ import * as path from "node:path";
import {
type ApplyResult,
applyEdits,
type Clipboard,
type Cursor,
computeFileHash,
type Edit,
@@ -26,8 +27,10 @@ import {
parsePatchStreaming,
Recovery,
resolveBlockEdits,
resolveClipboardEdits,
type SnapshotStore,
stripBom,
validateClipboardSequence,
} from "@oh-my-pi/hashline";
import { resolveToCwd } from "../../tools/path-utils";
import { generateDiffString } from "../diff";
@@ -48,6 +51,15 @@ export interface HashlineDiffOptions {
* authoring input; the final apply path still validates through Patcher.
*/
skipHashValidation?: boolean;
/**
* Clipboard register shared across the sections of one patch preview.
* `CUT`/`COPY` in an earlier section feeds `PASTE` in a later one, so the
* preview a user approves shows the pasted content exactly like apply
* will. Callers previewing a multi-section patch MUST thread one register
* through the sections in patch order; omitted, each section gets a
* private register (same-file cut/paste still previews correctly).
*/
clipboard?: Clipboard;
}
async function readSectionText(absolutePath: string, sectionPath: string): Promise<string> {
@@ -144,6 +156,7 @@ function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
return edits.some(edit => {
if (edit.kind === "delete") return true;
if (edit.kind === "block") return true;
if (edit.kind === "copy") return true;
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
});
}
@@ -204,14 +217,24 @@ function applyPreviewEdits(args: {
const liveMatches = expected !== undefined && computeFileHash(normalized) === expected;
const edits = parsePreviewEdits(section, options.streaming);
const resolved = resolvePreviewEdits({ section, absolutePath, normalized, snapshots, expected, liveMatches, edits });
if (options.skipHashValidation || expected === undefined || liveMatches) return applyEdits(normalized, resolved);
if (!hasAnchorScopedEdit(resolved)) return applyEdits(normalized, resolved);
const clipboard = options.clipboard ?? {};
// Mirror the Patcher: surface clipboard sequencing mistakes with their
// targeted message before the recovery path below swallows them. Streaming
// previews stay lenient — a mid-typed op transiently violating sequencing
// must not flash an error frame.
if (!options.streaming) validateClipboardSequence(resolved, clipboard);
const applyOptions = { clipboard, ...(options.streaming ? { onEmptyPaste: "drop" as const } : {}) };
if (options.skipHashValidation || expected === undefined || liveMatches) {
return applyEdits(normalized, resolved, applyOptions);
}
if (!hasAnchorScopedEdit(resolved)) return applyEdits(normalized, resolved, applyOptions);
const recovered = new Recovery(snapshots).tryRecover({
path: absolutePath,
currentText: normalized,
fileHash: expected,
edits: resolved,
clipboard,
});
if (recovered) return recovered;
throw createMismatchError(section, absolutePath, normalized, snapshots, expected);
@@ -250,18 +273,40 @@ function insertCursorLine(cursor: Cursor, fileLineCount: number): number {
function buildStreamingSectionDiff(
section: PatchSection,
normalized: string,
clipboard: Clipboard,
): { diff: string; firstChangedLine: number | undefined } | { error: string } {
const { edits, fileOp } = parsePatchStreaming(section.diff);
const resolved = resolveBlockEdits(edits, normalized, section.path, nativeBlockResolver, { onUnresolved: "drop" });
const blockResolved = resolveBlockEdits(edits, normalized, section.path, nativeBlockResolver, {
onUnresolved: "drop",
});
const fileLines = normalized.split("\n");
// Expand clipboard ops so a `PASTE` previews the moved rows. Resolve into
// a scratch register and commit it back only on success: a mid-typed
// capture (out-of-range while the range digits are still streaming,
// transient sequencing violations) throws AFTER earlier captures may have
// landed, and leaking those into the shared register would let a later
// section preview a paste that never happened. On failure, fall back to
// filtering the clipboard edits out of this section's frame.
const scratch: Clipboard = { ...clipboard };
let resolved: readonly Edit[];
try {
resolved = resolveClipboardEdits(blockResolved, fileLines, scratch, { onEmptyPaste: "drop" });
if (scratch.lines === undefined) delete clipboard.lines;
else clipboard.lines = scratch.lines;
if (scratch.pendingCut === undefined) delete clipboard.pendingCut;
else clipboard.pendingCut = scratch.pendingCut;
} catch {
resolved = blockResolved.filter(edit => edit.kind !== "copy" && edit.kind !== "paste");
}
if (resolved.length === 0) {
// A whole-file op (REM / MV) carries no line edits: the change is the
// delete/move itself, conveyed by the result header, so emit an empty
// diff rather than a misleading "No changes" error.
if (fileOp) return { diff: "", firstChangedLine: undefined };
// diff rather than a misleading "No changes" error. A copy-only section
// likewise changes nothing itself — it feeds a later `PASTE`.
if (fileOp || section.isClipboardSource) return { diff: "", firstChangedLine: undefined };
return { error: `No changes would be made to ${section.path}.` };
}
const fileLines = normalized.split("\n");
const rows: string[] = [];
let firstChangedLine: number | undefined;
@@ -299,7 +344,10 @@ function buildStreamingSectionDiff(
}
}
if (rows.length === 0) return { error: `No changes would be made to ${section.path}.` };
if (rows.length === 0) {
if (section.isClipboardSource) return { diff: "", firstChangedLine: undefined };
return { error: `No changes would be made to ${section.path}.` };
}
return { diff: rows.join("\n"), firstChangedLine };
}
@@ -323,12 +371,13 @@ export async function computeHashlineSectionDiff(
// diff: feed the in-flight ops through the natural-order builder so the
// streamed cursor stays pinned to the bottom. The args-complete pass
// (`streaming` unset) falls through to the real Myers diff below.
if (options.streaming) return buildStreamingSectionDiff(section, normalized);
if (options.streaming) return buildStreamingSectionDiff(section, normalized, options.clipboard ?? {});
const result = applyPreviewEdits({ section, absolutePath, normalized, snapshots, options });
if (normalized === result.text) {
// REM/MV-only sections change no text; the header conveys the
// delete/move, so don't surface a "No changes" error.
if (section.fileOp) return { diff: "", firstChangedLine: undefined };
// delete/move, so don't surface a "No changes" error. A copy-only
// section likewise changes nothing itself — it feeds a later `PASTE`.
if (section.fileOp || section.isClipboardSource) return { diff: "", firstChangedLine: undefined };
return { error: `No changes would be made to ${section.path}.` };
}
return generateDiffString(normalized, result.text, undefined, { path: section.path });
@@ -13,11 +13,15 @@
import {
type BlockResolution,
buildCompactDiffPreview,
type Clipboard,
commitClipboard,
forkClipboard,
MismatchError as HashlineMismatchError,
Patch,
Patcher,
type PatchSectionResult,
type PreparedSection,
pendingCutWarning,
} from "@oh-my-pi/hashline";
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
import type { FileDiagnosticsResult, WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
@@ -25,6 +29,7 @@ import type { ToolSession } from "../../tools";
import { outputMeta } from "../../tools/output-meta";
import { ToolError } from "../../tools/tool-errors";
import { generateDiffString } from "../diff";
import { getEditClipboard } from "../edit-clipboard";
import { getFileSnapshotStore } from "../file-snapshot-store";
import type { EditToolDetails, EditToolPerFileResult, LspBatchRequest } from "../renderer";
import { pruneOversizedEditSnapshots } from "../snapshot-details";
@@ -98,12 +103,26 @@ interface RenderedSection {
perFileResult: EditToolPerFileResult;
}
const BLOCK_OP_LABELS: Record<BlockResolution["op"], string> = {
replace: "SWAP.BLK",
delete: "DEL.BLK",
insert_after: "INS.BLK.POST",
copy: "COPY.BLK",
cut: "CUT.BLK",
paste_after: "PASTE.BLK.POST",
};
function formatBlockResolution(resolution: BlockResolution): string {
const op = resolution.op === "delete" ? "DEL.BLK" : resolution.op === "insert_after" ? "INS.BLK.POST" : "SWAP.BLK";
const op = BLOCK_OP_LABELS[resolution.op];
const lines = resolution.end - resolution.start + 1;
const span =
resolution.start === resolution.end ? `line ${resolution.start}` : `lines ${resolution.start}-${resolution.end}`;
const suffix = resolution.op === "insert_after" ? `; body lands after line ${resolution.end}` : "";
const suffix =
resolution.op === "insert_after"
? `; body lands after line ${resolution.end}`
: resolution.op === "paste_after"
? `; clipboard lands after line ${resolution.end}`
: "";
return `${op} ${resolution.anchorLine} → resolved ${span} (${lines} line${lines === 1 ? "" : "s"})${suffix}`;
}
@@ -194,6 +213,25 @@ function renderSection(
};
}
/**
* Render a copy-only (`COPY`/`COPY.BLK`) section: it filled the clipboard for
* a later `PASTE` and intentionally left its own file unchanged, so the
* standard "no changes" diagnostics do not apply. The fresh header still
* re-mints the snapshot tag for follow-up edits.
*/
function clipboardSourceResult(result: PatchSectionResult): RenderedSection {
const warningsBlock = result.warnings.length > 0 ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
return {
toolResult: {
content: [
{ type: "text", text: `${result.header}\nCopied lines to the clipboard; file unchanged.${warningsBlock}` },
],
details: { diff: "", op: "update", meta: outputMeta().get() },
},
perFileResult: { path: result.path, diff: "", op: "update" },
};
}
export async function executeHashlineSingle(
options: ExecuteHashlineSingleOptions,
): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
@@ -213,13 +251,25 @@ export async function executeHashlineSingle(
const enforceSeenLines = options.session.settings.get("edit.enforceSeenLines");
const patcher = new Patcher({ fs, snapshots, blockResolver: nativeBlockResolver, enforceSeenLines });
// The clipboard register is session-persistent: `CUT` in one edit call can
// `PASTE` in a later one. Each batch works on a fork and publishes it back
// only after every write lands, so a failed batch never poisons the
// register (a retried `CUT` would otherwise trip the overwrite guard).
const sessionClipboard = getEditClipboard(options.session);
const clipboard = forkClipboard(sessionClipboard);
// Single-section fast path: prepare, commit, render.
const inputHash = hashPatchInput(options.input);
if (patch.sections.length === 1) {
fs.setBatchRequest(narrowBatchRequest(options.batchRequest, true));
const prepared = await patcher.prepare(patch.sections[0]);
const prepared = await patcher.prepare(patch.sections[0], clipboard);
const sectionResult = await patcher.commit(prepared);
commitClipboard(clipboard, sessionClipboard);
if (clipboard.pendingCut !== undefined) sectionResult.warnings.push(pendingCutWarning(clipboard.pendingCut));
if (sectionResult.op === "noop") {
if (prepared.section.isClipboardSource) {
return clipboardSourceResult(sectionResult).toolResult;
}
const { count, escalate } = recordNoopEdit(options.session, sectionResult.canonicalPath, inputHash);
if (escalate) {
throw new ToolError(noChangeLoopDiagnostic(sectionResult.path, count));
@@ -231,12 +281,23 @@ export async function executeHashlineSingle(
}
// Multi-section: prepare every section up front so we fail fast before
// any write hits the filesystem.
// any write hits the filesystem. One clipboard register spans the batch,
// so `CUT`/`COPY` in one section feeds `PASTE` in a later one.
const prepared: PreparedSection[] = [];
for (const section of patch.sections) prepared.push(await patcher.prepare(section));
// Register state after each section's prepare. Commits are non-atomic: a
// mid-batch write failure leaves earlier sections on disk, so the session
// register must reflect exactly the landed prefix — content a landed CUT
// deleted would otherwise be lost.
const sectionStates: Clipboard[] = [];
for (const section of patch.sections) {
prepared.push(await patcher.prepare(section, clipboard));
sectionStates.push(forkClipboard(clipboard));
}
assertUniqueCanonicalPaths(prepared);
for (const entry of prepared) {
if (entry.isNoop) {
// A copy-only section legitimately changes nothing: it exists to fill
// the clipboard for a `PASTE` in a later section.
if (entry.isNoop && !entry.section.isClipboardSource) {
const { count, escalate } = recordNoopEdit(options.session, entry.canonicalPath, inputHash);
throw escalate
? new ToolError(noChangeLoopDiagnostic(entry.section.path, count))
@@ -251,7 +312,12 @@ export async function executeHashlineSingle(
const isLast = i === prepared.length - 1;
fs.setBatchRequest(narrowBatchRequest(options.batchRequest, isLast));
const sectionResult = await patcher.commit(prepared[i]);
commitClipboard(sectionStates[i], sessionClipboard);
if (sectionResult.op === "noop") {
if (prepared[i].section.isClipboardSource) {
rendered.push(clipboardSourceResult(sectionResult));
continue;
}
const { count, escalate } = recordNoopEdit(options.session, sectionResult.canonicalPath, inputHash);
throw escalate
? new ToolError(noChangeLoopDiagnostic(sectionResult.path, count))
@@ -260,14 +326,18 @@ export async function executeHashlineSingle(
resetNoopEdit(options.session, sectionResult.canonicalPath);
rendered.push(renderSection(sectionResult, fs.consumeDiagnostics(sectionResult.path), prepared[i].section.path));
}
// Every write landed; carry any still-pending cut forward as a warning.
const pendingCutNote =
clipboard.pendingCut === undefined ? "" : `\n\nWarnings:\n${pendingCutWarning(clipboard.pendingCut)}`;
return {
content: [
{
type: "text",
text: rendered
.map(r => r.toolResult.content.map(part => (part.type === "text" ? part.text : "")).join("\n"))
.join("\n\n"),
text:
rendered
.map(r => r.toolResult.content.map(part => (part.type === "text" ? part.text : "")).join("\n"))
.join("\n\n") + pendingCutNote,
},
],
details: pruneOversizedEditSnapshots({
+16 -1
View File
@@ -16,8 +16,10 @@
import {
ABORT_MARKER,
BEGIN_PATCH_MARKER,
type Clipboard,
containsRecognizableHashlineOperations,
END_PATCH_MARKER,
forkClipboard,
type PatchSection as HashlineInputSection,
Patch as HashlinePatch,
type SnapshotStore,
@@ -50,6 +52,13 @@ export interface StreamingDiffContext {
* not flicker in the preview.
*/
isStreaming?: boolean;
/**
* Session-persistent clipboard register (`CUT`/`COPY`/`PASTE`). Previews
* fork it per frame — never mutating it — so a `PASTE` of content cut in
* an earlier edit call (or an earlier section of this patch) renders the
* real rows.
*/
clipboard?: Clipboard;
}
/**
@@ -542,7 +551,9 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
// to parse; suppress until the next chunk arrives. Once args are
// complete, surface the error so the model sees what went wrong.
if (ctx.isStreaming) return null;
const result = await computeHashlineDiff({ input }, ctx.cwd, ctx.snapshots);
const result = await computeHashlineDiff({ input }, ctx.cwd, ctx.snapshots, {
clipboard: forkClipboard(ctx.clipboard),
});
ctx.signal.throwIfAborted();
return [toPerFilePreview("", result)];
}
@@ -558,12 +569,16 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
const trailingProcessedIndex = sectionsToProcess.length - 1;
const previews: PerFileDiffPreview[] = [];
// Fork the session register per preview frame: sections feed each other
// in patch order, but a preview must never mutate the live register.
const clipboard = forkClipboard(ctx.clipboard);
for (let i = 0; i < sectionsToProcess.length; i++) {
ctx.signal.throwIfAborted();
const section = sectionsToProcess[i];
const result = await computeHashlineSectionDiff(section, ctx.cwd, ctx.snapshots, {
streaming: ctx.isStreaming,
skipHashValidation: ctx.isStreaming === true,
clipboard,
});
ctx.signal.throwIfAborted();
// Ignore parse/apply errors from the trailing (actively-typed)
@@ -395,7 +395,7 @@
}
// Apply filter mode
const isSettingsEntry = ['label', 'custom', 'model_change', 'thinking_level_change', 'mode_change', 'ttsr_injection', 'session_init'].includes(entry.type);
const isSettingsEntry = ['label', 'custom', 'model_change', 'thinking_level_change', 'mode_change', 'ttsr_injection', 'session_init', 'credential_pin'].includes(entry.type);
let passesFilter = true;
switch (filterMode) {
@@ -1,4 +1,4 @@
import type { SnapshotStore } from "@oh-my-pi/hashline";
import type { Clipboard, SnapshotStore } from "@oh-my-pi/hashline";
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import {
Box,
@@ -222,6 +222,8 @@ export interface ToolExecutionUi {
export interface ToolExecutionOptions {
snapshots?: SnapshotStore;
/** Session-persistent edit clipboard register, forked per preview frame. */
clipboard?: Clipboard;
showImages?: boolean; // default: true (only used if terminal supports images)
editFuzzyThreshold?: number;
editAllowFuzzy?: boolean;
@@ -285,6 +287,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
#editFuzzyThreshold: number | undefined;
#editAllowFuzzy: boolean | undefined;
#snapshots?: SnapshotStore;
#clipboard?: Clipboard;
#isPartial = true;
#resultVersion = 0;
#lastDisplayKey: string | undefined;
@@ -393,6 +396,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
this.#editFuzzyThreshold = options.editFuzzyThreshold;
this.#editAllowFuzzy = options.editAllowFuzzy;
this.#snapshots = options.snapshots;
this.#clipboard = options.clipboard;
this.#liveRegion = options.liveRegion;
this.#tool = tool;
this.#ui = ui;
@@ -550,6 +554,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
cwd: this.#cwd,
signal: controller.signal,
snapshots: this.#snapshots!,
clipboard: this.#clipboard,
fuzzyThreshold: this.#editFuzzyThreshold,
allowFuzzy: this.#editAllowFuzzy,
isStreaming,
@@ -6,6 +6,7 @@ import { logger, prompt, sanitizeText } from "@oh-my-pi/pi-utils";
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
import { extractTextContent } from "../../commit/utils";
import { settings } from "../../config/settings";
import { getEditClipboard } from "../../edit/edit-clipboard";
import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
import { AssistantMessageComponent } from "../../modes/components/assistant-message";
import { detectCacheInvalidation } from "../../modes/components/cache-invalidation-marker";
@@ -810,6 +811,7 @@ export class EventController {
renderArgs,
{
snapshots: getFileSnapshotStore(this.ctx.viewSession),
clipboard: getEditClipboard(this.ctx.viewSession),
showImages: settings.get("terminal.showImages"),
editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
editAllowFuzzy: settings.get("edit.fuzzyMatch"),
@@ -1033,6 +1035,7 @@ export class EventController {
event.args,
{
snapshots: getFileSnapshotStore(this.ctx.viewSession),
clipboard: getEditClipboard(this.ctx.viewSession),
showImages: settings.get("terminal.showImages"),
editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
editAllowFuzzy: settings.get("edit.fuzzyMatch"),
@@ -5,6 +5,7 @@ import { type Component, Spacer, Text, TruncatedText } from "@oh-my-pi/pi-tui";
import type { AdvisorMessageDetails } from "../../advisor";
import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol";
import { settings } from "../../config/settings";
import { getEditClipboard } from "../../edit/edit-clipboard";
import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
import { createAdvisorMessageCard } from "../../modes/components/advisor-message";
import { AssistantMessageComponent } from "../../modes/components/assistant-message";
@@ -497,6 +498,7 @@ export class UiHelpers {
renderArgs,
{
snapshots: getFileSnapshotStore(this.ctx.viewSession),
clipboard: getEditClipboard(this.ctx.viewSession),
showImages: settings.get("terminal.showImages"),
editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
editAllowFuzzy: settings.get("edit.fuzzyMatch"),
@@ -19,7 +19,7 @@ import * as path from "node:path";
import { scheduler } from "node:timers/promises";
import { isPromise } from "node:util/types";
import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
import type { Clipboard, InMemorySnapshotStore } from "@oh-my-pi/hashline";
import {
type AfterToolCallContext,
type AfterToolCallResult,
@@ -410,6 +410,8 @@ export class AgentSession {
getXdevToolEntries: () => Array<{ name: string; summary: string }>;
readonly yieldQueue: YieldQueue;
fileSnapshotStore?: InMemorySnapshotStore;
/** Per-session `CUT`/`COPY`/`PASTE` clipboard register shared across edit calls. */
editClipboard?: Clipboard;
#powerAssertion: MacOSPowerAssertion | undefined;
+5 -1
View File
@@ -1,4 +1,4 @@
import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
import type { Clipboard, InMemorySnapshotStore } from "@oh-my-pi/hashline";
import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
import type { FetchImpl, ImageContent, Model, ServiceTierByFamily, ToolChoice } from "@oh-my-pi/pi-ai";
import { logger } from "@oh-my-pi/pi-utils";
@@ -351,6 +351,10 @@ export interface ToolSession {
* file changed out-of-band. Lazily initialized by `getFileSnapshotStore`. */
fileSnapshotStore?: InMemorySnapshotStore;
/** Per-session `CUT`/`COPY`/`PASTE` clipboard register shared across edit
* calls. Lazily initialized by `getEditClipboard`. */
editClipboard?: Clipboard;
/** Per-session log of unresolved git merge conflict regions surfaced by
* `read`. Each entry gets a stable id N referenced by `write conflict://N`
* to splice the recorded region with replacement content. Lazily initialized
+68 -1
View File
@@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { formatHashlineHeader, InMemorySnapshotStore, missingSnapshotTagMessage } from "@oh-my-pi/hashline";
import {
forkClipboard,
formatHashlineHeader,
InMemorySnapshotStore,
missingSnapshotTagMessage,
} from "@oh-my-pi/hashline";
import {
adjustIndentation,
computeEditDiff,
@@ -268,6 +273,68 @@ describe("computeHashlineDiff", () => {
}
});
test("previews a same-file CUT + PASTE as the moved content", async () => {
const sourcePath = path.join(tempDir, "source.txt");
const text = "l1\nl2\nl3\n";
await Bun.write(sourcePath, text);
const snapshotStore = new InMemorySnapshotStore();
const tag = snapshotStore.record(sourcePath, text);
const result = await computeHashlineDiff(
{ input: `${formatHashlineHeader(sourcePath, tag)}\nCUT 1.=1\nPASTE.TAIL` },
tempDir,
snapshotStore,
);
expect("diff" in result).toBe(true);
if ("diff" in result) {
expect(result.diff).toContain("-1|l1");
expect(result.diff).toContain("+3|l1");
}
});
test("previews a PASTE from a forked session register without touching the source", async () => {
const sourcePath = path.join(tempDir, "source.txt");
const text = "l1\n";
await Bun.write(sourcePath, text);
const snapshotStore = new InMemorySnapshotStore();
const tag = snapshotStore.record(sourcePath, text);
// Mirror the streaming-strategy contract: the session register is forked
// once per preview frame; sections then thread the fork in patch order.
const sessionRegister = { lines: ["carried"], pendingCut: "CUT 5" };
const result = await computeHashlineDiff(
{ input: `${formatHashlineHeader(sourcePath, tag)}\nPASTE.TAIL` },
tempDir,
snapshotStore,
{ clipboard: forkClipboard(sessionRegister) },
);
expect("diff" in result).toBe(true);
if ("diff" in result) {
expect(result.diff).toContain("|carried");
}
expect(sessionRegister.pendingCut).toBe("CUT 5");
});
test("streams paste rows in the natural-order preview", async () => {
const sourcePath = path.join(tempDir, "source.txt");
const text = "l1\nl2\nl3\n";
await Bun.write(sourcePath, text);
const snapshotStore = new InMemorySnapshotStore();
const tag = snapshotStore.record(sourcePath, text);
const result = await computeHashlineDiff(
{ input: `${formatHashlineHeader(sourcePath, tag)}\nCUT 2.=2\nPASTE.HEAD` },
tempDir,
snapshotStore,
{ streaming: true, skipHashValidation: true },
);
expect("diff" in result).toBe(true);
if ("diff" in result) {
expect(result.diff).toContain("-2|l2");
expect(result.diff).toContain("+1|l2");
}
});
test("rejects a tagless head/tail insert in the preview path, matching apply", async () => {
const relativePath = "source.txt";
await Bun.write(path.join(tempDir, relativePath), "first\n");
+6
View File
@@ -2,6 +2,12 @@
## [Unreleased]
### Added
- Added clipboard ops: `CUT N.=M` / `COPY N.=M` capture lines into a register (`CUT` also deletes them), `CUT.BLK N` / `COPY.BLK N` capture tree-sitter blocks, and `PASTE.PRE|POST N` / `PASTE.HEAD|TAIL` / `PASTE.BLK.POST N` insert the captured lines without retyping. The register flows top-to-bottom across sections, so content moves between files in one patch; `PASTE` does not consume it and the last capture wins.
- Added `PatcherOptions.clipboard` for a host-owned register that persists across `Patcher.apply` batches. Batches work on a fork (`forkClipboard`) published per landed section (`commitClipboard`), so failed batches never poison the register and a mid-batch write failure still preserves content already cut from disk; un-pasted `CUT` content becomes a carry-forward warning instead of an error.
- Added clipboard safety guards: a `PASTE` with an empty register, a capture overwriting un-pasted `CUT` content, a batch-local `CUT` that is never pasted, and clipboard ops in same-path sections interleaved across another file's section are all rejected with targeted diagnostics. `CUT`/`COPY` ranges participate in overlap validation, the seen-lines guard, and drift recovery (every captured line must remap).
### Fixed
- Prevented CPU and memory exhaustion in streaming previews by rejecting line anchors above Number.MAX_SAFE_INTEGER and ranges spanning more than 100,000 lines.
+3
View File
@@ -52,6 +52,9 @@ Inside a section:
- `DEL A.=B` / `DEL.BLK A` — delete concrete lines or a resolved block.
- `INS.PRE A:` / `INS.POST A:` / `INS.HEAD:` / `INS.TAIL:` — insert following body rows.
- `INS.BLK.POST A:` — insert following body rows after the resolved block's last line.
- `CUT A.=B` / `COPY A.=B` — capture lines into the clipboard register (`CUT` also deletes them).
- `CUT.BLK A` / `COPY.BLK A` — capture the resolved block (`CUT.BLK` also deletes it).
- `PASTE.PRE A` / `PASTE.POST A` / `PASTE.HEAD` / `PASTE.TAIL` / `PASTE.BLK.POST A` — insert the clipboard; no body rows. The register flows top-to-bottom across sections (cross-file moves) and can be host-persisted across batches via `PatcherOptions.clipboard`.
- `REM` — delete the whole file named by the section header.
- `MV DEST` — move/rename the section file to `DEST` (optionally after line edits).
- `+TEXT` — literal body row (use `+` alone for a blank line).
+27 -5
View File
@@ -7,6 +7,8 @@
* which absorbs common model mistakes where a payload restates unchanged range
* boundaries or duplicates/drops structural closers.
*/
import { resolveClipboardEdits } from "./clipboard";
import {
afterInsertLandingShiftWarning,
ambiguousBoundaryEchoMessage,
@@ -15,7 +17,7 @@ import {
UNRESOLVED_BLOCK_INTERNAL,
} from "./messages";
import { cloneCursor } from "./tokenizer";
import type { Anchor, ApplyResult, Cursor, Edit } from "./types";
import type { Anchor, ApplyResult, Clipboard, Cursor, Edit } from "./types";
type LineOrigin = "original" | "insert" | "replacement";
@@ -1221,24 +1223,44 @@ function repairAfterInsertLandings(
return { edits: out ?? edits, warnings };
}
/** Optional knobs for {@link applyEdits}. */
export interface ApplyEditsOptions {
/**
* Clipboard register filled by `copy` edits and read by `paste` edits.
* Thread one register through every section of a batch to move content
* across files; when omitted, the call gets a private register (same-file
* cut/paste still works).
*/
clipboard?: Clipboard;
/** `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). */
onEmptyPaste?: "throw" | "drop";
}
/**
* Apply a parsed list of edits to a text body. Pure function — no I/O.
*
* Returns the post-edit text and the first changed line number (1-indexed).
* Throws if an anchor is out of bounds.
*/
export function applyEdits(text: string, edits: readonly Edit[]): ApplyResult {
export function applyEdits(text: string, edits: readonly Edit[], options: ApplyEditsOptions = {}): ApplyResult {
if (edits.length === 0) return { text, firstChangedLine: undefined };
const fileLines = text.split("\n");
// Clipboard pre-pass: capture `copy` ranges from the original lines and
// expand `paste` edits into plain inserts, in authored order.
const concrete = resolveClipboardEdits(edits, fileLines, options.clipboard ?? {}, {
...(options.onEmptyPaste === undefined ? {} : { onEmptyPaste: options.onEmptyPaste }),
});
// Block edits are deferred until `resolveBlockEdits` expands them into
// concrete inserts + deletes. Reaching the applier with one still present
// is an internal wiring bug, not authored-input error.
for (const edit of edits) {
for (const edit of concrete) {
if (edit.kind === "block") throw new Error(UNRESOLVED_BLOCK_INTERNAL);
}
const appliedEdits = edits as readonly AppliedEdit[];
const appliedEdits = concrete as readonly AppliedEdit[];
const fileLines = text.split("\n");
const lineOrigins: LineOrigin[] = fileLines.map(() => "original");
let firstChangedLine: number | undefined;
+45 -2
View File
@@ -16,10 +16,13 @@ import { STRUCTURAL_CLOSER_RE } from "./apply";
import {
BLOCK_RESOLVER_UNAVAILABLE,
type BlockDiagnosticSuggestions,
type BlockOp,
blockSingleLineMessage,
blockUnresolvedMessage,
insertAfterBlockCloserLoweredWarning,
insertAfterBlockUnresolvedLoweredWarning,
pasteAfterBlockCloserLoweredWarning,
pasteAfterBlockUnresolvedLoweredWarning,
} from "./messages";
import type { BlockResolution, BlockResolver, BlockSpan, Cursor, Edit } from "./types";
@@ -124,7 +127,7 @@ export function resolveBlockEdits(
resolved.push(edit);
continue;
}
const op = edit.mode === "insert_after" ? "insert_after" : edit.payloads.length === 0 ? "delete" : "replace";
const op: BlockOp = edit.mode ?? (edit.payloads.length === 0 ? "delete" : "replace");
const span = resolver ? resolver({ path, text, line: edit.anchor.line }) : null;
if (span === null) {
// `insert_after_block N:` never fails the patch — lower it to plain
@@ -135,9 +138,19 @@ export function resolveBlockEdits(
// - otherwise (unsupported language, blank line, unparsable block,
// or no resolver wired): "after the block at N" degrades to
// "after line N" — warn to verify the landing line.
if (op === "insert_after") {
if (op === "insert_after" || op === "paste_after") {
const anchorText = text.split("\n")[edit.anchor.line - 1];
const isCloser = anchorText !== undefined && STRUCTURAL_CLOSER_RE.test(anchorText);
if (op === "paste_after") {
options.onWarning?.(
isCloser
? pasteAfterBlockCloserLoweredWarning(edit.anchor.line)
: pasteAfterBlockUnresolvedLoweredWarning(edit.anchor.line),
);
const cursor: Cursor = { kind: "after_anchor", anchor: { line: edit.anchor.line } };
resolved.push({ kind: "paste", cursor, lineNum: edit.lineNum, index: synthIndex++ });
continue;
}
options.onWarning?.(
isCloser
? insertAfterBlockCloserLoweredWarning(edit.anchor.line)
@@ -183,6 +196,36 @@ export function resolveBlockEdits(
end: span.end,
op,
});
if (op === "paste_after") {
// Mirror the block-lowered insert: paste after the block's last
// line, tagging `blockStart` so landing correction can slide a body
// claiming a depth inside the block back across its trailing closers.
resolved.push({
kind: "paste",
cursor: { kind: "after_anchor", anchor: { line: span.end } },
lineNum: edit.lineNum,
index: synthIndex++,
blockStart: span.start,
});
continue;
}
if (op === "copy" || op === "cut") {
// Capture the resolved span; a cut also deletes it line-by-line,
// exactly like the parser's lowering of `CUT N.=M`.
resolved.push({
kind: "copy",
range: { start: { line: span.start }, end: { line: span.end } },
cut: op === "cut",
lineNum: edit.lineNum,
index: synthIndex++,
});
if (op === "cut") {
for (let line = span.start; line <= span.end; line++) {
resolved.push({ kind: "delete", anchor: { line }, lineNum: edit.lineNum, index: synthIndex++ });
}
}
continue;
}
if (op === "insert_after") {
// Mirror the parser's `insert after N:` lowering: one `after_anchor`
// insert per payload row, anchored on the block's last line. The
+158
View File
@@ -0,0 +1,158 @@
/**
* Clipboard register support for `CUT` / `COPY` / `PASTE` ops.
*
* `CUT N.=M` / `COPY N.=M` (and their `.BLK` block-resolved forms) capture the
* range's current lines into a {@link Clipboard} register — cut additionally
* lowers to ordinary per-line deletes at parse/resolve time. `PASTE.PRE|POST N`,
* `PASTE.HEAD|TAIL`, and `PASTE.BLK.POST N` insert the captured lines at a
* cursor. The register is threaded through a whole patch application in source
* order — across sections — so content can move between files without being
* retyped: the last capture wins, and a `PASTE` does not consume the register,
* so the same content can land in several places.
*
* {@link resolveClipboardEdits} is the applier's pre-pass: it runs inside
* {@link applyEdits} against the exact text the edits apply to (after block
* resolution and recovery remapping), captures `copy` edits, and expands
* `paste` edits into plain inserts.
*/
import { HL_COPY_KEYWORD, HL_CUT_KEYWORD, HL_RANGE_SEP } from "./format";
import { EMPTY_PASTE, overwrittenCutMessage, unusedCutMessage } from "./messages";
import { cloneCursor } from "./tokenizer";
import type { Clipboard, Edit } from "./types";
type CopyEdit = Extract<Edit, { kind: "copy" }>;
/** `CUT 5.=10` / `COPY 5` display form for diagnostics. */
function describeCopyEdit(edit: CopyEdit): string {
const { start, end } = edit.range;
const range = start.line === end.line ? `${start.line}` : `${start.line}${HL_RANGE_SEP}${end.line}`;
return `${edit.cut ? HL_CUT_KEYWORD : HL_COPY_KEYWORD} ${range}`;
}
/** True when at least one edit reads or writes the clipboard register (including unresolved `.BLK` forms). */
export function hasClipboardEdit(edits: readonly Edit[]): boolean {
return edits.some(
edit =>
edit.kind === "copy" ||
edit.kind === "paste" ||
(edit.kind === "block" && (edit.mode === "copy" || edit.mode === "cut" || edit.mode === "paste_after")),
);
}
/** Optional knobs for {@link resolveClipboardEdits}. */
export interface ResolveClipboardEditsOptions {
/** `PASTE` with an empty register: `throw` (default) or `drop` (streaming previews). */
onEmptyPaste?: "throw" | "drop";
}
/**
* Expand clipboard edits against `fileLines` (the text the edit batch applies
* to): `copy` edits capture their range into `clipboard` and emit nothing;
* `paste` edits become one plain insert per captured line. Non-clipboard edits
* pass through untouched, and the fast path returns the input unchanged when
* there is nothing to resolve. Edits are processed in array order — the
* authored patch order — so a `PASTE` uses the most recent capture above it.
*
* Throws on an out-of-range capture, a capture that would overwrite un-pasted
* `CUT` content, and (unless `onEmptyPaste: "drop"`) a `PASTE` with an empty
* register.
*/
export function resolveClipboardEdits(
edits: readonly Edit[],
fileLines: readonly string[],
clipboard: Clipboard,
options: ResolveClipboardEditsOptions = {},
): readonly Edit[] {
if (!hasClipboardEdit(edits)) return edits;
const onEmptyPaste = options.onEmptyPaste ?? "throw";
const resolved: Edit[] = [];
let synthIndex = 0;
for (const edit of edits) {
if (edit.kind === "copy") {
const { start, end } = edit.range;
if (start.line < 1 || end.line > fileLines.length) {
throw new Error(
`line ${edit.lineNum}: \`${describeCopyEdit(edit)}\` is out of range (file has ${fileLines.length} lines).`,
);
}
if (clipboard.pendingCut !== undefined) {
throw new Error(overwrittenCutMessage(clipboard.pendingCut, edit.lineNum));
}
clipboard.lines = fileLines.slice(start.line - 1, end.line);
if (edit.cut) clipboard.pendingCut = describeCopyEdit(edit);
else delete clipboard.pendingCut;
continue;
}
if (edit.kind === "paste") {
const lines = clipboard.lines;
if (lines === undefined) {
if (onEmptyPaste === "drop") continue;
throw new Error(`line ${edit.lineNum}: ${EMPTY_PASTE}`);
}
delete clipboard.pendingCut;
for (const text of lines) {
resolved.push({
kind: "insert",
cursor: cloneCursor(edit.cursor),
text,
lineNum: edit.lineNum,
index: synthIndex++,
...(edit.blockStart === undefined ? {} : { blockStart: edit.blockStart }),
});
}
continue;
}
resolved.push(edit);
}
return resolved;
}
/**
* Shallow working copy of a register, for transactional batches: resolve the
* whole batch against the fork, then {@link commitClipboard} it back only
* after the batch's writes actually land. A failed batch leaves the source
* register untouched, so retries are never blocked by phantom captures and a
* failed `PASTE` never clears a real pending cut.
*/
export function forkClipboard(source?: Clipboard): Clipboard {
return source === undefined ? {} : { ...source };
}
/** Publish a fork's state back to its source register. */
export function commitClipboard(fork: Clipboard, target: Clipboard): void {
if (fork.lines === undefined) delete target.lines;
else target.lines = fork.lines;
if (fork.pendingCut === undefined) delete target.pendingCut;
else target.pendingCut = fork.pendingCut;
}
/**
* Throw when the register still holds un-pasted `CUT` content. Called at the
* end of a batch by whoever created the register: a cut that never landed
* means the patch deleted lines it meant to move (the classic truncated-patch
* failure), so it is rejected rather than silently applied as a delete.
*/
export function assertClipboardConsumed(clipboard: Clipboard): void {
if (clipboard.pendingCut !== undefined) throw new Error(unusedCutMessage(clipboard.pendingCut));
}
/**
* Validate clipboard sequencing (a `PASTE` before any capture, a capture over
* un-pasted `CUT` content) without mutating `clipboard` or touching file text.
* The patcher runs this before its recovery path, where {@link applyEdits}
* failures are swallowed and re-surfaced as tag-mismatch errors — sequencing
* mistakes must keep their targeted message instead.
*/
export function validateClipboardSequence(edits: readonly Edit[], clipboard: Clipboard): void {
let hasLines = clipboard.lines !== undefined;
let pendingCut = clipboard.pendingCut;
for (const edit of edits) {
if (edit.kind === "copy") {
if (pendingCut !== undefined) throw new Error(overwrittenCutMessage(pendingCut, edit.lineNum));
hasLines = true;
pendingCut = edit.cut ? describeCopyEdit(edit) : undefined;
} else if (edit.kind === "paste") {
if (!hasLines) throw new Error(`line ${edit.lineNum}: ${EMPTY_PASTE}`);
pendingCut = undefined;
}
}
}
+12
View File
@@ -33,6 +33,18 @@ export const HL_REPLACE_BLOCK_KEYWORD = "SWAP.BLK";
export const HL_DELETE_BLOCK_KEYWORD = "DEL.BLK";
/** Hunk-header keyword: `INS.BLK.POST N:` inserts after the last line of the tree-sitter block at N. */
export const HL_INSERT_AFTER_BLOCK_KEYWORD = "INS.BLK.POST";
/** Hunk-header keyword: `COPY N.=M` captures lines into the clipboard register. */
export const HL_COPY_KEYWORD = "COPY";
/** Hunk-header keyword: `CUT N.=M` captures lines into the clipboard register and deletes them. */
export const HL_CUT_KEYWORD = "CUT";
/** Hunk-header keyword: `COPY.BLK N` captures the tree-sitter block at N into the clipboard. */
export const HL_COPY_BLOCK_KEYWORD = "COPY.BLK";
/** Hunk-header keyword: `CUT.BLK N` captures the tree-sitter block at N and deletes its span. */
export const HL_CUT_BLOCK_KEYWORD = "CUT.BLK";
/** Hunk-header keyword prefix: `PASTE.PRE|POST N` / `PASTE.HEAD|TAIL` inserts the clipboard. */
export const HL_PASTE_KEYWORD = "PASTE";
/** Hunk-header keyword: `PASTE.BLK.POST N` inserts the clipboard after the tree-sitter block at N. */
export const HL_PASTE_AFTER_BLOCK_KEYWORD = "PASTE.BLK.POST";
/** File-level keyword: `REM` deletes the whole file named by the section header. */
export const HL_REM_KEYWORD = "REM";
/** File-level keyword: `MV DEST` renames/moves the section file to `DEST`. */
+7 -1
View File
@@ -7,13 +7,19 @@ file_header: "[" filename "#" file_hash "]" LF
file_hash: /[0-9A-F]{4}/
filename: /[^#\r\n]+/
hunk: replace_hunk | replace_block_hunk | insert_hunk | insert_block_hunk | delete_hunk | delete_block_hunk | remove_hunk | move_hunk
hunk: replace_hunk | replace_block_hunk | insert_hunk | insert_block_hunk | delete_hunk | delete_block_hunk | copy_hunk | copy_block_hunk | paste_hunk | paste_block_hunk | remove_hunk | move_hunk
replace_hunk: replace_anchor LF emit_op*
replace_block_hunk: replace_block_anchor LF emit_op+
insert_hunk: insert_anchor LF emit_op+
insert_block_hunk: insert_block_anchor LF emit_op+
delete_hunk: "DEL " header_range LF
delete_block_hunk: "DEL.BLK " LID LF
copy_hunk: copy_verb " " header_range LF
copy_block_hunk: copy_verb ".BLK " LID LF
copy_verb: "CUT" | "COPY"
paste_hunk: "PASTE." paste_pos LF
paste_block_hunk: "PASTE.BLK.POST " LID LF
paste_pos: "PRE " LID | "POST " LID | "HEAD" | "TAIL"
remove_hunk: "REM" LF
move_hunk: "MV " filename LF emit_op*
replace_anchor: "SWAP " header_range ":"
+1
View File
@@ -1,5 +1,6 @@
export * from "./apply";
export * from "./block";
export * from "./clipboard";
export * from "./diff-preview";
export * from "./format";
export * from "./fs";
+65 -7
View File
@@ -10,10 +10,12 @@
import * as path from "node:path";
import { applyEdits } from "./apply";
import { resolveBlockEdits } from "./block";
import { assertClipboardConsumed, hasClipboardEdit } from "./clipboard";
import { HL_FILE_HASH_EXAMPLES, HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "./format";
import { CLIPBOARD_INTERLEAVED_SECTIONS } from "./messages";
import { parsePatch, parsePatchStreaming } from "./parser";
import { Tokenizer } from "./tokenizer";
import type { ApplyResult, BlockResolver, Edit, FileOp, SplitOptions } from "./types";
import type { ApplyResult, BlockResolver, Clipboard, Edit, FileOp, SplitOptions } from "./types";
// Pure classification — single shared tokenizer is safe.
const TOKENIZER = new Tokenizer();
@@ -97,6 +99,14 @@ interface RawSection {
path: string;
fileHash?: string;
diff: string;
/**
* True when this section coalesced same-path sections that were NOT
* adjacent in the authored input (another file's section sat between
* them). Merging moves the later ops up to the first occurrence, which
* would silently reorder the clipboard register sequence — so clipboard
* ops are rejected in such sections.
*/
interleaved?: boolean;
}
/**
@@ -239,10 +249,13 @@ export class PatchSection {
readonly diff: string;
#parsed: { edits: Edit[]; fileOp?: FileOp; warnings: string[] } | undefined;
#interleavedMerge: boolean;
constructor(raw: RawSection) {
this.path = raw.path;
this.fileHash = raw.fileHash;
this.diff = raw.diff;
this.#interleavedMerge = raw.interleaved === true;
}
/**
@@ -253,6 +266,12 @@ export class PatchSection {
parse(): { edits: Edit[]; fileOp?: FileOp; warnings: readonly string[] } {
this.#parsed ??= parsePatch(this.diff);
const parsed = this.#parsed;
// Same-path sections merge into their first occurrence; when that merge
// crossed another file's section, the authored top-to-bottom register
// order is gone, so clipboard ops cannot apply deterministically.
if (this.#interleavedMerge && hasClipboardEdit(parsed.edits)) {
throw new Error(CLIPBOARD_INTERLEAVED_SECTIONS);
}
const fileOp =
parsed.fileOp === undefined
? undefined
@@ -289,10 +308,28 @@ export class PatchSection {
if (edit.kind === "delete") return true;
// A `replace_block N:` edit is anchored to concrete content on line N.
if (edit.kind === "block") return true;
// A `COPY`/`CUT` range reads concrete content.
if (edit.kind === "copy") return true;
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
});
}
/**
* True when this section is a pure clipboard source: at least one edit,
* and every edit is a non-cut `COPY`/`COPY.BLK`. Only such a section may
* legitimately change nothing in its own file (it exists to fill the
* clipboard for a later `PASTE`), so only it is exempt from no-change
* validation — a section mixing COPY with mutating edits that all no-op'd
* still trips the guard.
*/
get isClipboardSource(): boolean {
const edits = this.edits;
return (
edits.length > 0 &&
edits.every(edit => (edit.kind === "copy" && !edit.cut) || (edit.kind === "block" && edit.mode === "copy"))
);
}
/** Anchor lines touched by this section, sorted ascending and deduplicated. */
collectAnchorLines(): readonly number[] {
const lines = new Set<number>();
@@ -305,6 +342,10 @@ export class PatchSection {
lines.add(edit.anchor.line);
continue;
}
if (edit.kind === "copy") {
for (let line = edit.range.start.line; line <= edit.range.end.line; line++) lines.add(line);
continue;
}
if (edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor") {
lines.add(edit.cursor.anchor.line);
}
@@ -321,15 +362,22 @@ export class PatchSection {
*
* `blockResolver` resolves any `replace_block N:` edits against `text`; an
* unresolvable block throws (this is the final, authoritative preview path).
*
* `clipboard` is the register shared by `CUT`/`COPY`/`PASTE` ops. Pass one
* when applying several sections of a batch so content can move across
* files; when omitted, a private register is used and un-pasted `CUT`
* content is rejected at the end of the call.
*/
applyTo(text: string, blockResolver?: BlockResolver): ApplyResult {
applyTo(text: string, blockResolver?: BlockResolver, clipboard?: Clipboard): ApplyResult {
const { edits, warnings } = this.parse();
const resolveWarnings: string[] = [];
const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, {
onUnresolved: "throw",
onWarning: warning => resolveWarnings.push(warning),
});
const result = applyEdits(text, resolved);
const register = clipboard ?? {};
const result = applyEdits(text, resolved, { clipboard: register });
if (clipboard === undefined) assertClipboardConsumed(register);
// Preserve parse warnings so consumers don't need to call `parse()`
// separately.
const merged = [...warnings, ...resolveWarnings, ...(result.warnings ?? [])];
@@ -347,16 +395,17 @@ export class PatchSection {
*
* `blockResolver` resolves any `replace_block N:` edits against `text`; an
* unresolvable block is silently dropped so a half-written file does not
* throw mid-stream.
* throw mid-stream. A `PASTE` with an empty register is dropped for the
* same reason, and un-pasted `CUT` content is never rejected here.
*/
applyPartialTo(text: string, blockResolver?: BlockResolver): ApplyResult {
applyPartialTo(text: string, blockResolver?: BlockResolver, clipboard?: Clipboard): ApplyResult {
const { edits, warnings } = parsePatchStreaming(this.diff);
const resolveWarnings: string[] = [];
const resolved = resolveBlockEdits(edits, text, this.path, blockResolver, {
onUnresolved: "drop",
onWarning: warning => resolveWarnings.push(warning),
});
const result = applyEdits(text, resolved);
const result = applyEdits(text, resolved, { clipboard: clipboard ?? {}, onEmptyPaste: "drop" });
const merged = [...warnings, ...resolveWarnings, ...(result.warnings ?? [])];
return merged.length > 0
? { ...result, warnings: merged }
@@ -374,6 +423,7 @@ export class PatchSection {
path,
...(this.fileHash !== undefined ? { fileHash: this.fileHash } : {}),
diff: this.diff,
...(this.#interleavedMerge ? { interleaved: true } : {}),
});
next.#parsed = this.#parsed;
return next;
@@ -432,7 +482,8 @@ export class Patch {
* fails. Path order is preserved by first occurrence.
*/
function mergeSamePathSections(sections: RawSection[]): RawSection[] {
const byPath = new Map<string, { fileHash?: string; diffs: string[] }>();
const byPath = new Map<string, { fileHash?: string; diffs: string[]; interleaved: boolean }>();
let previousPath: string | undefined;
for (const section of sections) {
const existing = byPath.get(section.path);
if (existing) {
@@ -446,17 +497,24 @@ function mergeSamePathSections(sections: RawSection[]): RawSection[] {
);
}
if (existing.fileHash === undefined && section.fileHash !== undefined) existing.fileHash = section.fileHash;
// Merging across another file's section moves these ops up to the
// first occurrence; flag it so clipboard ops can refuse the reorder.
if (previousPath !== section.path) existing.interleaved = true;
existing.diffs.push(section.diff);
previousPath = section.path;
continue;
}
byPath.set(section.path, {
...(section.fileHash !== undefined ? { fileHash: section.fileHash } : {}),
diffs: [section.diff],
interleaved: false,
});
previousPath = section.path;
}
return Array.from(byPath, ([sectionPath, entry]) => ({
path: sectionPath,
...(entry.fileHash !== undefined ? { fileHash: entry.fileHash } : {}),
diff: entry.diffs.join("\n"),
...(entry.interleaved ? { interleaved: true } : {}),
}));
}
+109 -40
View File
@@ -31,7 +31,21 @@ export function formatAnchoredContext(anchorLines: readonly number[], fileLines:
return rows;
}
/** Concrete range operation rejected because its absolute end precedes its start. */
export type AbsoluteRangeOp = "replace" | "delete";
export type AbsoluteRangeOp = "replace" | "delete" | "copy" | "cut";
/** Header forms per concrete-range op, used to compose retry suggestions. */
const RANGE_OP_FORMS: Record<AbsoluteRangeOp, { keyword: string; colon: string; blockKeyword: string }> = {
replace: { keyword: "SWAP", colon: ":", blockKeyword: "SWAP.BLK" },
delete: { keyword: "DEL", colon: "", blockKeyword: "DEL.BLK" },
copy: { keyword: "COPY", colon: "", blockKeyword: "COPY.BLK" },
cut: { keyword: "CUT", colon: "", blockKeyword: "CUT.BLK" },
};
/** `OP.BLK N`-style header for a concrete-range op (`SWAP.BLK 5:` / `CUT.BLK 5`). */
function blockFormAt(op: AbsoluteRangeOp, line: number): string {
const forms = RANGE_OP_FORMS[op];
return `${forms.blockKeyword} ${line}${forms.colon}`;
}
/** Explain absolute range endpoints and provide safe, non-applying retry forms. */
export function invalidAbsoluteRangeMessage(
@@ -41,15 +55,14 @@ export function invalidAbsoluteRangeMessage(
op: AbsoluteRangeOp,
block?: BlockSpan,
): string {
const single = op === "replace" ? `SWAP ${start}${HL_RANGE_SEP}${start}:` : `DEL ${start}`;
const forms = RANGE_OP_FORMS[op];
const single = op === "replace" ? `SWAP ${start}${HL_RANGE_SEP}${start}:` : `${forms.keyword} ${start}`;
const countedEnd = start + end - 1;
const counted =
Number.isSafeInteger(countedEnd) && countedEnd >= start
? op === "replace"
? `SWAP ${start}${HL_RANGE_SEP}${countedEnd}:`
: `DEL ${start}${HL_RANGE_SEP}${countedEnd}`
? `${forms.keyword} ${start}${HL_RANGE_SEP}${countedEnd}${forms.colon}`
: null;
const blockForm = op === "replace" ? `SWAP.BLK ${start}:` : `DEL.BLK ${start}`;
const blockForm = blockFormAt(op, start);
let message =
`line ${patchLine}: Invalid absolute range: start ${start}, end ${end}. ` +
`The value after \`${HL_RANGE_SEP}\` is an absolute source line, not a line count or replacement length. ` +
@@ -122,17 +135,18 @@ export interface BlockDiagnosticSuggestions {
*/
export function blockUnresolvedMessage(
line: number,
op: "replace" | "delete" = "replace",
op: AbsoluteRangeOp = "replace",
fileLines?: readonly string[],
suggestions: BlockDiagnosticSuggestions = {},
): string {
const phrase = op === "delete" ? `DEL.BLK ${line}` : `SWAP.BLK ${line}:`;
const fallback = op === "delete" ? `DEL ${line}${HL_RANGE_SEP}M` : `SWAP ${line}${HL_RANGE_SEP}M:`;
const forms = RANGE_OP_FORMS[op];
const phrase = blockFormAt(op, line);
const fallback = `${forms.keyword} ${line}${HL_RANGE_SEP}M${forms.colon}`;
const anchorText = fileLines?.[line - 1];
const nextBlock = suggestions.nextBlock;
let message: string;
if (anchorText !== undefined && anchorText.trim().length === 0 && nextBlock) {
const retry = op === "delete" ? `DEL.BLK ${nextBlock.start}` : `SWAP.BLK ${nextBlock.start}:`;
const retry = blockFormAt(op, nextBlock.start);
message =
`Line ${line} is blank; no syntactic block can begin there. ` +
`The next multi-line block begins at line ${nextBlock.start} and ends at line ${nextBlock.end}. ` +
@@ -144,7 +158,7 @@ export function blockUnresolvedMessage(
}
const enclosingBlock = suggestions.enclosingBlock;
if (enclosingBlock) {
const retry = op === "delete" ? `DEL.BLK ${enclosingBlock.start}` : `SWAP.BLK ${enclosingBlock.start}:`;
const retry = blockFormAt(op, enclosingBlock.start);
message +=
` The nearest enclosing multi-line block begins at line ${enclosingBlock.start} ` +
`and ends at line ${enclosingBlock.end}; use \`${retry}\` to target it.`;
@@ -158,24 +172,44 @@ export function blockUnresolvedMessage(
/** Block-anchored edit reached a path with no {@link BlockResolver} wired in — a host-configuration bug. */
export const BLOCK_RESOLVER_UNAVAILABLE =
"`SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` are not available here (no block resolver configured). Use a concrete line range.";
"Block ops (`SWAP.BLK`, `DEL.BLK`, `INS.BLK.POST`, `CUT.BLK`, `COPY.BLK`, `PASTE.BLK.POST`) are not available here (no block resolver configured). Use a concrete line range.";
/**
* `insert_after_block N:` anchored on a closing-delimiter line, lowered to
* plain `insert after N:` — the closer ends a block, and inserting after it
* is exactly what the plain form does.
* An after-block op anchored on a closing-delimiter line, lowered to its
* plain after-line form — the closer ends a block, and inserting after it is
* exactly what the plain form does.
*/
export function insertAfterBlockCloserLoweredWarning(line: number): string {
return `\`INS.BLK.POST ${line}:\` anchors on a closing delimiter, so it was applied as plain \`INS.POST ${line}:\`. Anchor on the line that OPENS the construct.`;
function closerLoweredWarning(blockForm: string, plainForm: string): string {
return `\`${blockForm}\` anchors on a closing delimiter, so it was applied as plain \`${plainForm}\`. Anchor on the line that OPENS the construct.`;
}
/**
* `insert_after_block N:` anchor unresolvable (unsupported language, blank
* line, parse error, or no resolver), lowered to plain `insert after N:` —
* applying with a warning beats failing the patch.
* An after-block op whose anchor was unresolvable (unsupported language,
* blank line, parse error, or no resolver), lowered to its plain after-line
* form — applying with a warning beats failing the patch.
*/
function unresolvedLoweredWarning(blockForm: string, line: number, plainForm: string): string {
return `\`${blockForm}\` could not resolve a syntactic block on line ${line}, so it was applied as plain \`${plainForm}\`. Verify the landing line; anchor on a line that OPENS a construct.`;
}
/** `INS.BLK.POST N:` anchored on a closing-delimiter line; applied as `INS.POST N:`. */
export function insertAfterBlockCloserLoweredWarning(line: number): string {
return closerLoweredWarning(`INS.BLK.POST ${line}:`, `INS.POST ${line}:`);
}
/** `INS.BLK.POST N:` anchor unresolvable; applied as `INS.POST N:`. */
export function insertAfterBlockUnresolvedLoweredWarning(line: number): string {
return `\`INS.BLK.POST ${line}:\` could not resolve a syntactic block on line ${line}, so it was applied as plain \`INS.POST ${line}:\`. Verify the landing line; anchor on a line that OPENS a construct.`;
return unresolvedLoweredWarning(`INS.BLK.POST ${line}:`, line, `INS.POST ${line}:`);
}
/** `PASTE.BLK.POST N` anchored on a closing-delimiter line; applied as `PASTE.POST N`. */
export function pasteAfterBlockCloserLoweredWarning(line: number): string {
return closerLoweredWarning(`PASTE.BLK.POST ${line}`, `PASTE.POST ${line}`);
}
/** `PASTE.BLK.POST N` anchor unresolvable; applied as `PASTE.POST N`. */
export function pasteAfterBlockUnresolvedLoweredWarning(line: number): string {
return unresolvedLoweredWarning(`PASTE.BLK.POST ${line}`, line, `PASTE.POST ${line}`);
}
/**
* A one-sided boundary echo whose payload is too short to be the widened
@@ -248,6 +282,42 @@ export const MOVE_TAKES_NO_BODY =
/** `delete_block N` hunk received a body row. */
export const DELETE_BLOCK_TAKES_NO_BODY = "`DEL.BLK N` does not take body rows. Remove the body, or use `SWAP.BLK N:`.";
/** `COPY N.=M` hunk received a body row. */
export const COPY_TAKES_NO_BODY = `\`COPY N${HL_RANGE_SEP}M\` captures lines into the clipboard and takes no body rows.`;
/** `CUT N.=M` hunk received a body row. */
export const CUT_TAKES_NO_BODY = `\`CUT N${HL_RANGE_SEP}M\` captures + deletes lines and takes no body rows. To replace lines with new content, use \`SWAP N${HL_RANGE_SEP}M:\`.`;
/** `PASTE` hunk received a body row. */
export const PASTE_TAKES_NO_BODY =
"`PASTE` inserts the clipboard content and takes no `+` body rows. To insert literal text, use `INS`.";
/** `PASTE` ran with an empty clipboard register. */
export const EMPTY_PASTE = `\`PASTE\` found nothing in the clipboard. Ops run top-to-bottom across the whole patch (sections included): put \`CUT N${HL_RANGE_SEP}M\` / \`COPY N${HL_RANGE_SEP}M\` (or \`CUT.BLK N\` / \`COPY.BLK N\`) above the \`PASTE\`.`;
/** A `CUT` filled the register and the whole patch finished without a `PASTE`. */
export function unusedCutMessage(cutForm: string): string {
return `\`${cutForm}\` deleted lines that were never pasted — no \`PASTE\` follows it in the patch. Add a \`PASTE.PRE|POST N\` / \`PASTE.HEAD|TAIL\` op, or use \`DEL\` to delete without moving.`;
}
/** A later CUT/COPY overwrote a register whose CUT content was never pasted. */
export function overwrittenCutMessage(cutForm: string, lineNum: number): string {
return `line ${lineNum}: the clipboard still holds \`${cutForm}\` content that was never pasted. \`PASTE\` it before capturing again, or use \`DEL\` if those lines should just be deleted.`;
}
/** Batch ended with un-pasted `CUT` content in a session-persistent register. */
export function pendingCutWarning(cutForm: string): string {
return `\`${cutForm}\` content is in the clipboard but was not pasted by this edit. It stays available — \`PASTE\` it in a follow-up edit; capturing again before pasting it will be rejected.`;
}
/**
* Clipboard ops inside a same-path section that was merged across another
* file's section. Same-path sections coalesce into their first occurrence, so
* an interleaved layout would silently reorder the register sequence.
*/
export const CLIPBOARD_INTERLEAVED_SECTIONS =
"`CUT`/`COPY`/`PASTE` cannot be used in a file whose sections are interleaved with another file's: same-path sections merge into the first occurrence, which would reorder the clipboard sequence. Keep each file's ops under ONE `[path#TAG]` header, with every capture above its `PASTE`.";
/** Insert hunk with no body. */
export const EMPTY_INSERT = "`INS` needs at least one `+TEXT` body row.";
@@ -413,33 +483,32 @@ export function unseenLinesMessage(
}
/** Op kind of a deferred block edit, for {@link blockSingleLineMessage}. */
export type BlockOp = "replace" | "delete" | "insert_after";
export type BlockOp = "replace" | "delete" | "insert_after" | "copy" | "cut" | "paste_after";
/** Display forms per deferred-block op: block keyword, trailing colon, and single-line plain form. */
const BLOCK_OP_FORMS: Record<BlockOp, { keyword: string; colon: string; plain: (line: number) => string }> = {
replace: { keyword: "SWAP.BLK", colon: ":", plain: line => `SWAP ${line}${HL_RANGE_SEP}${line}:` },
delete: { keyword: "DEL.BLK", colon: "", plain: line => `DEL ${line}` },
insert_after: { keyword: "INS.BLK.POST", colon: ":", plain: line => `INS.POST ${line}:` },
copy: { keyword: "COPY.BLK", colon: "", plain: line => `COPY ${line}` },
cut: { keyword: "CUT.BLK", colon: "", plain: line => `CUT ${line}` },
paste_after: { keyword: "PASTE.BLK.POST", colon: "", plain: line => `PASTE.POST ${line}` },
};
/**
* A `replace_block`/`delete_block`/`insert_after_block` anchor resolved to a
* single line — almost always a bare statement the model mis-anchored, not a
* multi-line construct. The plain op is unambiguous for one line; the block
* form only earns its keep when it spares counting a closing line you cannot
* see. Reject and point at both fixes.
* single line: line N is a bare statement, not the opening line of a
* multi-line construct. The plain op is exact for one line, so reject and
* point at it (with the enclosing block as an alternative when known).
*/
export function blockSingleLineMessage(line: number, op: BlockOp, enclosingBlock?: BlockSpan): string {
const blockForm = op === "insert_after" ? "INS.BLK.POST" : op === "delete" ? "DEL.BLK" : "SWAP.BLK";
const plainForm =
op === "insert_after"
? `INS.POST ${line}:`
: op === "delete"
? `DEL ${line}`
: `SWAP ${line}${HL_RANGE_SEP}${line}:`;
const forms = BLOCK_OP_FORMS[op];
const plainForm = forms.plain(line);
let message =
`\`${blockForm} ${line}\` resolved a single-line block — line ${line} is a bare statement, not the opening line ` +
`\`${forms.keyword} ${line}\` resolved a single-line block — line ${line} is a bare statement, not the opening line ` +
`of a multi-line construct. For only this statement use \`${plainForm}\`.`;
if (enclosingBlock) {
const enclosingForm =
op === "insert_after"
? `INS.BLK.POST ${enclosingBlock.start}:`
: op === "delete"
? `DEL.BLK ${enclosingBlock.start}`
: `SWAP.BLK ${enclosingBlock.start}:`;
const enclosingForm = `${forms.keyword} ${enclosingBlock.start}${forms.colon}`;
message +=
` The nearest enclosing multi-line block begins at line ${enclosingBlock.start} ` +
`and ends at line ${enclosingBlock.end}; use \`${enclosingForm}\` to target it.`;
+76 -9
View File
@@ -7,6 +7,8 @@ import { HL_PAYLOAD_REPLACE, HL_RANGE_SEP } from "./format";
import {
type AbsoluteRangeOp,
BARE_BODY_AUTO_PIPED_WARNING,
COPY_TAKES_NO_BODY,
CUT_TAKES_NO_BODY,
DELETE_BLOCK_TAKES_NO_BODY,
DELETE_TAKES_NO_BODY,
EMPTY_BLOCK,
@@ -15,6 +17,7 @@ import {
MINUS_BULLET_AUTO_PIPED_WARNING,
MINUS_ROW_REJECTED,
MOVE_TAKES_NO_BODY,
PASTE_TAKES_NO_BODY,
REM_TAKES_NO_BODY,
} from "./messages";
import { stripOneLeadingHashlinePrefix } from "./prefixes";
@@ -75,6 +78,27 @@ function isSkippableCommentLine(line: string): boolean {
return line.trimStart().startsWith("#");
}
/**
* Body-row rejection message for targets that take no `+TEXT` rows, or `null`
* for targets whose header is followed by a body.
*/
function bodylessTargetMessage(target: BlockTarget): string | null {
switch (target.kind) {
case "delete":
return DELETE_TAKES_NO_BODY;
case "delete_block":
return DELETE_BLOCK_TAKES_NO_BODY;
case "copy":
case "copy_block":
return target.cut ? CUT_TAKES_NO_BODY : COPY_TAKES_NO_BODY;
case "paste":
case "paste_after_block":
return PASTE_TAKES_NO_BODY;
default:
return null;
}
}
/**
* Stripped remainder of a bare `N: <value>` row that is a lone quoted or
* numeric literal (optionally comma-terminated) — the shape of a numeric-keyed
@@ -121,6 +145,12 @@ function detectApplyPatchContamination(text: string, _hasPending: boolean): stri
if (/^DEL\s+[1-9]\d*(?:\s*(?:\.\.|\.=|-|…|\s)\s*[1-9]\d*)?\s*:/.test(trimmed)) {
return `\`DEL N${HL_RANGE_SEP}M\` has no colon and no body. Remove the colon and body rows.`;
}
// Bare `PASTE` (optionally `PASTE 5` / `PASTE:`) — the op requires an
// explicit position suffix; a bare form would otherwise surface as a
// confusing body-row rejection under the preceding hunk.
if (/^PASTE(?:\s+[1-9]\d*)?\s*:?\s*$/.test(trimmed)) {
return "`PASTE` needs a position: use `PASTE.PRE N` / `PASTE.POST N` / `PASTE.HEAD` / `PASTE.TAIL` / `PASTE.BLK.POST N`.";
}
if (/^[1-9]\d*\s*$/.test(trimmed)) {
return `hunk headers need a verb. Use \`SWAP ${trimmed}${HL_RANGE_SEP}${trimmed}:\` to replace, or \`DEL ${trimmed}\` to delete.`;
}
@@ -210,6 +240,9 @@ export class Executor {
if (token.target.kind === "replace" || token.target.kind === "delete") {
validateRange(token.target.range, token.lineNum, token.target.kind);
}
if (token.target.kind === "copy") {
validateRange(token.target.range, token.lineNum, token.target.cut ? "cut" : "copy");
}
if (token.target.kind === "rem") {
this.#flushPending();
this.#setFileOp({ kind: "rem" }, token.lineNum);
@@ -241,8 +274,7 @@ export class Executor {
endStreaming(): { edits: Edit[]; fileOp?: FileOp; warnings: string[] } {
this.#consumePendingSkippableComments();
if (this.#pending && this.#pending.payloads.length > 0) this.#flushPending();
else if (this.#pending?.target.kind === "delete" || this.#pending?.target.kind === "delete_block")
this.#flushPending();
else if (this.#pending && bodylessTargetMessage(this.#pending.target) !== null) this.#flushPending();
else this.#pending = undefined;
this.#validateFileOp();
this.#validateNoOverlappingDeletes();
@@ -312,8 +344,8 @@ export class Executor {
`Got ${JSON.stringify(`${HL_PAYLOAD_REPLACE}${text}`)}.`,
);
}
if (pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
if (pending.target.kind === "delete_block") throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
const noBodyOnLiteral = bodylessTargetMessage(pending.target);
if (noBodyOnLiteral !== null) throw new Error(`line ${lineNum}: ${noBodyOnLiteral}`);
this.#commitDeferredBlanks(pending);
pending.payloads.push({ kind: "literal", text, lineNum });
}
@@ -327,9 +359,8 @@ export class Executor {
this.#handleBlank(text, lineNum);
return;
}
if (this.#pending.target.kind === "delete") throw new Error(`line ${lineNum}: ${DELETE_TAKES_NO_BODY}`);
if (this.#pending.target.kind === "delete_block")
throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
const noBodyOnRaw = bodylessTargetMessage(this.#pending.target);
if (noBodyOnRaw !== null) throw new Error(`line ${lineNum}: ${noBodyOnRaw}`);
const row: PayloadRow = { kind: "literal", text, lineNum, bare: true };
// `-` rows are held and judged at flush time by #resolveMinusRows,
// once the whole body is visible.
@@ -364,7 +395,7 @@ export class Executor {
#handleBlank(text: string, lineNum: number): void {
const pending = this.#pending;
if (!pending) return;
if (pending.target.kind === "delete" || pending.target.kind === "delete_block") return;
if (bodylessTargetMessage(pending.target) !== null) return;
if (pending.payloads.length === 0) return;
pending.deferredBlanks.push({ kind: "literal", text, lineNum, bare: true });
}
@@ -455,7 +486,27 @@ export class Executor {
for (let line = range.start.line; line <= range.end.line; line++) this.#pushDelete({ line }, lineNum);
}
#pushBlock(anchor: Anchor, payloads: readonly PayloadRow[], lineNum: number, mode?: "insert_after"): void {
#pushCopy(range: ParsedRange, cut: boolean, lineNum: number): void {
this.#edits.push({
kind: "copy",
range: { start: { ...range.start }, end: { ...range.end } },
cut,
lineNum,
index: this.#editIndex++,
});
// A CUT deletes its range exactly like `DEL N.=M`; the copy edit above
// only captures. Keeping the deletes as ordinary per-line edits lets
// overlap validation, recovery remapping, and the applier treat them
// identically to a plain delete.
if (cut) this.#pushDeleteRange(range, lineNum);
}
#pushBlock(
anchor: Anchor,
payloads: readonly PayloadRow[],
lineNum: number,
mode?: "insert_after" | "copy" | "cut" | "paste_after",
): void {
this.#edits.push({
kind: "block",
anchor: { ...anchor },
@@ -486,6 +537,22 @@ export class Executor {
this.#pushBlock(target.anchor, [], lineNum);
return;
}
if (target.kind === "copy") {
this.#pushCopy(target.range, target.cut, lineNum);
return;
}
if (target.kind === "copy_block") {
this.#pushBlock(target.anchor, [], lineNum, target.cut ? "cut" : "copy");
return;
}
if (target.kind === "paste") {
this.#edits.push({ kind: "paste", cursor: cloneCursor(target.cursor), lineNum, index: this.#editIndex++ });
return;
}
if (target.kind === "paste_after_block") {
this.#pushBlock(target.anchor, [], lineNum, "paste_after");
return;
}
if (target.kind === "block") {
if (payloads.length === 0) throw new Error(`line ${lineNum}: ${EMPTY_BLOCK}`);
this.#pushBlock(target.anchor, payloads, lineNum);
+74 -11
View File
@@ -25,6 +25,7 @@
import * as path from "node:path";
import { applyEdits } from "./apply";
import { hasBlockEdit, resolveBlockEdits } from "./block";
import { assertClipboardConsumed, commitClipboard, forkClipboard, validateClipboardSequence } from "./clipboard";
import { computeFileHash, formatHashlineHeader } from "./format";
import type { Filesystem, WriteResult } from "./fs";
import { isNotFound } from "./fs";
@@ -33,6 +34,7 @@ import {
HEADTAIL_DRIFT_WARNING,
missingSnapshotTagMessage,
pathRecoveredFromTagMessage,
pendingCutWarning,
type RevealedLine,
unseenLinesMessage,
writeDriftWarning,
@@ -42,7 +44,7 @@ import { detectLineEnding, type LineEnding, normalizeToLF, restoreLineEndings, s
import { InvalidAbsoluteRangeError } from "./parser";
import { Recovery, type RecoveryResult } from "./recovery";
import type { Snapshot, SnapshotStore } from "./snapshots";
import type { ApplyResult, BlockResolution, BlockResolver, BlockSpan, Edit, FileOp } from "./types";
import type { ApplyResult, BlockResolution, BlockResolver, BlockSpan, Clipboard, Edit, FileOp } from "./types";
/**
* Upper bound on the number of unseen anchor lines whose actual file content
@@ -81,6 +83,15 @@ export interface PatcherOptions {
* validate on content hash alone and any anchor into the tagged content applies.
*/
enforceSeenLines?: boolean;
/**
* Host-owned clipboard register shared across batches, so `CUT` content
* can be `PASTE`d by a later {@link Patcher.apply} call. Each batch works
* on a fork and publishes it back only after every write lands; un-pasted
* `CUT` content at batch end becomes a warning instead of an error.
* Omitted, every batch gets its own register and un-pasted `CUT` content
* is rejected when the batch ends.
*/
clipboard?: Clipboard;
}
/** Per-section result returned by {@link Patcher.apply} / {@link Patcher.commit}. */
@@ -158,6 +169,8 @@ function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
if (edit.kind === "delete") return true;
// A `replace_block N:` edit anchors to concrete content on line N.
if (edit.kind === "block") return true;
// A `COPY`/`CUT` range reads concrete content.
if (edit.kind === "copy") return true;
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor";
});
}
@@ -211,6 +224,7 @@ export class Patcher {
readonly snapshots: SnapshotStore;
readonly recovery: Recovery;
readonly blockResolver: BlockResolver | undefined;
readonly clipboard: Clipboard | undefined;
readonly #enforceSeenLines: boolean;
constructor(options: PatcherOptions) {
@@ -221,6 +235,7 @@ export class Patcher {
this.snapshots = options.snapshots;
this.recovery = new Recovery(options.snapshots);
this.blockResolver = options.blockResolver;
this.clipboard = options.clipboard;
this.#enforceSeenLines = options.enforceSeenLines ?? true;
}
@@ -231,19 +246,44 @@ export class Patcher {
* {@link PatchSectionResult} per section in the original patch order.
*/
async apply(patch: Patch): Promise<PatcherApplyResult> {
// One register per batch: `CUT`/`COPY` in one section feeds `PASTE` in
// a later one, so content can move across files. A host-owned register
// (see PatcherOptions.clipboard) additionally persists across batches:
// work on a fork and publish it per landed section, so a failed batch
// never poisons the persistent register and a mid-batch failure still
// preserves content already cut from disk.
const clipboard = forkClipboard(this.clipboard);
// Single-section fast path.
if (patch.sections.length === 1) {
const prepared = await this.prepare(patch.sections[0]);
return { sections: [await this.commit(prepared)] };
const prepared = await this.prepare(patch.sections[0], clipboard);
if (this.clipboard === undefined) assertClipboardConsumed(clipboard);
const result = await this.commit(prepared);
if (this.clipboard !== undefined) {
commitClipboard(clipboard, this.clipboard);
if (clipboard.pendingCut !== undefined) result.warnings.push(pendingCutWarning(clipboard.pendingCut));
}
return { sections: [result] };
}
// Prepare every section first so any failure (stale hash, missing
// file, parse error, in-memory no-op) surfaces before any write.
const prepared: PreparedSection[] = [];
for (const section of patch.sections) prepared.push(await this.prepare(section));
// Register state after each section's prepare. Commits are non-atomic:
// when a later write fails, the sections before it are already on disk,
// so the host register must reflect exactly the landed prefix — content
// a landed CUT deleted would otherwise be lost.
const sectionStates: Clipboard[] = [];
for (const section of patch.sections) {
prepared.push(await this.prepare(section, clipboard));
sectionStates.push(forkClipboard(clipboard));
}
if (this.clipboard === undefined) assertClipboardConsumed(clipboard);
assertUniqueCanonicalPaths(prepared);
for (const entry of prepared) {
if (entry.isNoop) {
// A copy-only section legitimately changes nothing: it exists to
// fill the clipboard for a later `PASTE`.
if (entry.isNoop && !entry.section.isClipboardSource) {
throw new Error(`Edits to ${entry.section.path} resulted in no changes being made.`);
}
}
@@ -266,6 +306,10 @@ export class Patcher {
{ cause: error },
);
}
if (this.clipboard !== undefined) commitClipboard(sectionStates[index], this.clipboard);
}
if (this.clipboard !== undefined && clipboard.pendingCut !== undefined) {
results[results.length - 1]?.warnings.push(pendingCutWarning(clipboard.pendingCut));
}
return { sections: results };
}
@@ -275,11 +319,14 @@ export class Patcher {
* No writes hit the filesystem. Use for CI checks and dry runs.
*/
async preflight(patch: Patch): Promise<void> {
// Dry run: fork the register and never publish it back.
const clipboard = forkClipboard(this.clipboard);
const prepared: PreparedSection[] = [];
for (const section of patch.sections) prepared.push(await this.prepare(section));
for (const section of patch.sections) prepared.push(await this.prepare(section, clipboard));
if (this.clipboard === undefined) assertClipboardConsumed(clipboard);
assertUniqueCanonicalPaths(prepared);
for (const entry of prepared) {
if (entry.isNoop) {
if (entry.isNoop && !entry.section.isClipboardSource) {
throw new Error(`Edits to ${entry.section.path} resulted in no changes being made.`);
}
}
@@ -310,10 +357,15 @@ export class Patcher {
* {@link PreparedSection} which can be fed to {@link commit} to land
* the result on the filesystem.
*
* `clipboard` is the register shared by `CUT`/`COPY`/`PASTE` ops. Pass the
* batch's register when preparing several sections so content can move
* across files; when omitted, a private register is used and un-pasted
* `CUT` content is rejected before this method returns.
*
* Throws on parse error, missing-file-for-anchored-edit, or unrecovered
* tag mismatch ({@link MismatchError}).
*/
async prepare(section: PatchSection): Promise<PreparedSection> {
async prepare(section: PatchSection, clipboard?: Clipboard): Promise<PreparedSection> {
const parsed = await this.#parseWithRangeDiagnostics(section);
const parseWarnings = [...parsed.warnings];
const fileOp = parsed.fileOp;
@@ -360,6 +412,7 @@ export class Patcher {
const lineEnding = detectLineEnding(text);
const normalized = normalizeToLF(text);
const register = clipboard ?? {};
const applyResult =
fileOp?.kind === "rem"
? this.#applyWithRecovery({
@@ -368,6 +421,7 @@ export class Patcher {
exists: read.exists,
normalized,
edits: [],
clipboard: register,
})
: this.#applyWithRecovery({
section: target,
@@ -375,7 +429,9 @@ export class Patcher {
exists: read.exists,
normalized,
edits: parsed.edits,
clipboard: register,
});
if (clipboard === undefined) assertClipboardConsumed(register);
return new PreparedSection(
target,
@@ -639,8 +695,9 @@ export class Patcher {
exists: boolean;
normalized: string;
edits: readonly Edit[];
clipboard: Clipboard;
}): ApplyResult {
const { section, canonicalPath, exists, normalized, edits } = args;
const { section, canonicalPath, exists, normalized, edits, clipboard } = args;
const expected = exists ? section.fileHash : undefined;
// The 4-hex tag is content-derived: when the live text hashes to it,
// trust the match and apply directly. `storedSnapshotForTag` feeds the
@@ -673,6 +730,11 @@ export class Patcher {
onWarning: warning => resolveWarnings.push(warning),
});
}
// Surface clipboard sequencing mistakes (`PASTE` before any capture,
// capturing over un-pasted `CUT` content) with their targeted message
// before the recovery path below, which swallows apply failures and
// re-surfaces them as tag-mismatch errors.
validateClipboardSequence(resolved, clipboard);
const withResolveWarnings = (result: ApplyResult): ApplyResult =>
resolveWarnings.length === 0
? result
@@ -689,7 +751,7 @@ export class Patcher {
if (expected !== undefined && this.#enforceSeenLines) {
this.#assertSeenLines(section, expected, matchedSnapshot);
}
const result = applyEdits(normalized, resolved);
const result = applyEdits(normalized, resolved, { clipboard });
return withResolveWarnings(blockResolutions.length > 0 ? { ...result, blockResolutions } : result);
}
// Head/tail-only inserts are position-stable: "start"/"end" cannot move
@@ -697,7 +759,7 @@ export class Patcher {
// content and warn instead of hard-failing — unlike an anchored
// mismatch, which cannot be safely relocated and must reject.
if (!hasAnchorScopedEdit(resolved)) {
const result = applyEdits(normalized, resolved);
const result = applyEdits(normalized, resolved, { clipboard });
return withResolveWarnings({ ...result, warnings: [HEADTAIL_DRIFT_WARNING, ...(result.warnings ?? [])] });
}
// File drifted: map every anchor from the tagged snapshot to unchanged
@@ -707,6 +769,7 @@ export class Patcher {
currentText: normalized,
fileHash: expected,
edits: resolved,
clipboard,
});
if (recovered) return withResolveWarnings(recoveryToApplyResult(recovered));
const hashRecognized = this.snapshots.byHash(canonicalPath, expected) !== null;
+20 -1
View File
@@ -1,4 +1,4 @@
Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `DEL` has no body.
Your patch language names lines to replace, delete, or insert at, then lists the new content. Rule of thumb: a header ending in `:` is followed by `+` body rows; `DEL`, `CUT`/`COPY`, and `PASTE` have no body.
<headers>
Every file section starts with `[PATH#TAG]`. `TAG` = 4-hex snapshot tag from your latest `read`/`search`, REQUIRED on every section — no hashless form. Create new files with `write`; hashline only edits existing files.
@@ -13,6 +13,9 @@ Every file section starts with `[PATH#TAG]`. `TAG` = 4-hex snapshot tag from you
`INS.POST N:` — insert the body rows immediately after line N.
`INS.BLK.POST N:` — insert the body rows after the END of the block that BEGINS on line N — outside it, at sibling depth. To append inside a block, use `INS.POST`.
`INS.HEAD:` / `INS.TAIL:` — insert the body rows at the very start / end of the file.
`CUT N.=M` / `COPY N.=M` — capture original lines N.=M into the clipboard (CUT also deletes them, like `DEL`). No body.
`CUT.BLK N` / `COPY.BLK N` — capture the whole syntactic block that BEGINS on line N.
`PASTE.PRE N` / `PASTE.POST N` / `PASTE.HEAD` / `PASTE.TAIL` / `PASTE.BLK.POST N` — insert the clipboard at the position. No body — the clipboard IS the body.
`REM` — delete the whole file named by the section header. No body, no line ops.
`MV DEST` — move/rename the section file to `DEST` (a path, quoted when it contains spaces). Line edits above `MV` land on the source first, then the final content is written at `DEST`.
Single line: `SWAP N.=N:` / `DEL N`. The range is the ORIGINAL lines you touch; body length is irrelevant (replacing 1 line with 10 is still `SWAP N.=N:`).
@@ -39,6 +42,8 @@ Body rows appear only under a `:` header. Every body row is `+TEXT` — add a li
- Markdown: a heading line IS a block opener — `SWAP.BLK`/`DEL.BLK`/`INS.BLK.POST` on a `##`/`###` heading resolves its WHOLE section (heading through every nested deeper heading, up to the next same-or-higher heading). So `DEL.BLK` drops the section, `SWAP.BLK` rewrites it, `INS.BLK.POST` lands after it (end the inserted body with a blank line to keep the next heading separated).
- Non-adjacent changes = separate hunks; untouched lines stay out of every range.
- Pure additions use `INS.PRE` / `INS.POST` / `INS.HEAD` / `INS.TAIL`, never a widened `SWAP` — retyped keepers are exactly what gets dropped. (A multi-line `SWAP` whose body restates the line just past the range is auto-dropped as an off-by-one keeper with a warning — issue the payload for the range only; never lean on the repair.)
- Move code with `CUT`+`PASTE`, never by retyping it in a body. The clipboard runs top-to-bottom through the whole patch — across `[file]` sections, so content moves between files — and persists across edit calls. The last `CUT`/`COPY` wins; `PASTE` may repeat (it does not consume). Content pastes verbatim, indentation included — re-indenting a move needs `SWAP`.
- `CUT` content must land: un-pasted `CUT` content is rejected or carried to the next call with a warning, and a new `CUT`/`COPY` before pasting it is an error.
- NEVER format/restyle code with this tool; run the project formatter instead.
</rules>
@@ -111,6 +116,16 @@ INS.POST 2:
+ - nested task
```
Move the `greet` function after line 4 without retyping it, then copy line 4 into a sibling file — the clipboard flows top-to-bottom across sections:
```
[greet.py#A1B2]
CUT.BLK 1
PASTE.POST 4
COPY 4.=4
[other.py#3C4D]
PASTE.HEAD
```
Replace the whole `greet` function block — `SWAP.BLK 1:` resolves lines 1–3 (the `def` header through `print(msg)`); line 4 is a separate statement and stays:
```
[greet.py#A1B2]
@@ -162,6 +177,10 @@ INS.BLK.POST 3:
# RIGHT
INS.POST 3:
+after()
# WRONG — body rows under PASTE; the clipboard is the body. RIGHT: capture first, then a bodyless `PASTE.POST 20` (no colon, no rows).
PASTE.POST 20:
+function f() {}
</anti-patterns>
<critical>
+30 -4
View File
@@ -10,13 +10,15 @@ import { diffLineRuns } from "@oh-my-pi/pi-natives";
import { applyEdits } from "./apply";
import { RECOVERY_EXTERNAL_WARNING, RECOVERY_LINE_REMAP_WARNING, RECOVERY_SESSION_CHAIN_WARNING } from "./messages";
import type { SnapshotStore } from "./snapshots";
import type { Anchor, ApplyResult, Edit } from "./types";
import type { Anchor, ApplyResult, Clipboard, Edit } from "./types";
export interface RecoveryArgs {
path: string;
currentText: string;
fileHash: string;
edits: readonly Edit[];
/** Shared clipboard register for `copy`/`paste` edits, threaded into the replay apply. */
clipboard?: Clipboard;
}
export interface RecoveryResult {
@@ -41,6 +43,13 @@ function getEditAnchors(edit: Edit): Anchor[] {
// Recovery only ever receives already-resolved edits (no `block`); this arm
// exists for type-exhaustiveness over the full `Edit` union.
if (edit.kind === "block") return [edit.anchor];
if (edit.kind === "copy") {
// Every line of the captured range is an anchor: capturing a range
// whose interior changed would paste content the author never saw.
const anchors: Anchor[] = [];
for (let line = edit.range.start.line; line <= edit.range.end.line; line++) anchors.push({ line });
return anchors;
}
return edit.cursor.kind === "before_anchor" || edit.cursor.kind === "after_anchor" ? [edit.cursor.anchor] : [];
}
@@ -213,6 +222,22 @@ function remapEditsToCurrent(previousText: string, currentText: string, edits: r
remapped.push({ ...edit, anchor });
continue;
}
if (edit.kind === "copy") {
// Map every line of the captured range, not just its endpoints: an
// unmapped interior line means the content drifted and the capture
// would carry text the author never saw. The uniform-offset rule
// below keeps the mapped range contiguous.
const start = mapLine(edit.range.start.line);
if (start === null) return null;
let end = start;
for (let line = edit.range.start.line + 1; line <= edit.range.end.line; line++) {
const mapped = mapLine(line);
if (mapped === null) return null;
end = mapped;
}
remapped.push({ ...edit, range: { start: { line: start }, end: { line: end } } });
continue;
}
let blockStart = edit.blockStart;
if (blockStart !== undefined) {
@@ -243,12 +268,13 @@ function replayRemappedAnchorsOnCurrent(
currentText: string,
edits: readonly Edit[],
recoveryWarning: string,
clipboard: Clipboard | undefined,
): RecoveryResult | null {
const remapped = remapEditsToCurrent(previousText, currentText, edits);
if (remapped === null) return null;
let applied: ApplyResult;
try {
applied = applyEdits(currentText, remapped.edits);
applied = applyEdits(currentText, remapped.edits, clipboard === undefined ? {} : { clipboard });
} catch {
return null;
}
@@ -276,13 +302,13 @@ export class Recovery {
* caller should then surface a {@link MismatchError}.
*/
tryRecover(args: RecoveryArgs): RecoveryResult | null {
const { path, currentText, fileHash, edits } = args;
const { path, currentText, fileHash, edits, clipboard } = args;
// When retained texts collide on the 16-bit tag, use the latest one.
// Recovery still requires its anchors and context to map unambiguously.
const snapshot = this.store.byHash(path, fileHash);
if (!snapshot) return null;
const recoveryWarning =
this.store.head(path) === snapshot ? RECOVERY_EXTERNAL_WARNING : RECOVERY_SESSION_CHAIN_WARNING;
return replayRemappedAnchorsOnCurrent(snapshot.text, currentText, edits, recoveryWarning);
return replayRemappedAnchorsOnCurrent(snapshot.text, currentText, edits, recoveryWarning, clipboard);
}
}
+108 -11
View File
@@ -10,6 +10,10 @@
*/
import {
describeAnchorExamples,
HL_COPY_BLOCK_KEYWORD,
HL_COPY_KEYWORD,
HL_CUT_BLOCK_KEYWORD,
HL_CUT_KEYWORD,
HL_DELETE_BLOCK_KEYWORD,
HL_DELETE_KEYWORD,
HL_FILE_HASH_LENGTH,
@@ -24,6 +28,8 @@ import {
HL_INSERT_KEYWORD,
HL_INSERT_TAIL,
HL_MOVE_KEYWORD,
HL_PASTE_AFTER_BLOCK_KEYWORD,
HL_PASTE_KEYWORD,
HL_PAYLOAD_REPLACE,
HL_REM_KEYWORD,
HL_REPLACE_BLOCK_KEYWORD,
@@ -220,6 +226,10 @@ export type BlockTarget =
| { kind: "insert_before"; anchor: Anchor }
| { kind: "insert_after"; anchor: Anchor }
| { kind: "insert_after_block"; anchor: Anchor }
| { kind: "copy"; range: ParsedRange; cut: boolean }
| { kind: "copy_block"; anchor: Anchor; cut: boolean }
| { kind: "paste"; cursor: Cursor }
| { kind: "paste_after_block"; anchor: Anchor }
| { kind: "rem" }
| { kind: "move"; dest: string }
| { kind: "bof" }
@@ -270,30 +280,52 @@ function consumeReplaceColon(line: string, index: number, end: number): number {
return skipWhitespace(line, afterEquals + 1, end);
}
function scanInsertTarget(line: string, index: number, end: number): TargetScan | null {
interface PositionScan {
cursor: Cursor;
nextIndex: number;
}
/** Scan the `.PRE N` / `.POST N` / `.HEAD` / `.TAIL` positional suffix shared by `INS` and `PASTE`. */
function scanPositionalSuffix(line: string, index: number, end: number): PositionScan | null {
if (index >= end || line.charCodeAt(index) !== CHAR_DOT) return null;
const cursor = skipWhitespace(line, index + 1, end);
const beforeEnd = scanKeyword(line, cursor, end, HL_INSERT_BEFORE);
const probe = skipWhitespace(line, index + 1, end);
const beforeEnd = scanKeyword(line, probe, end, HL_INSERT_BEFORE);
if (beforeEnd !== null) {
const anchor = scanLineNumber(line, skipWhitespace(line, beforeEnd, end), end);
if (anchor === null) return null;
const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
return { target: { kind: "insert_before", anchor: { line: anchor.line } }, nextIndex };
return { cursor: { kind: "before_anchor", anchor: { line: anchor.line } }, nextIndex };
}
const afterEnd = scanKeyword(line, cursor, end, HL_INSERT_AFTER);
const afterEnd = scanKeyword(line, probe, end, HL_INSERT_AFTER);
if (afterEnd !== null) {
const anchor = scanLineNumber(line, skipWhitespace(line, afterEnd, end), end);
if (anchor === null) return null;
const nextIndex = consumeOptionalColon(line, anchor.nextIndex, end);
return { target: { kind: "insert_after", anchor: { line: anchor.line } }, nextIndex };
return { cursor: { kind: "after_anchor", anchor: { line: anchor.line } }, nextIndex };
}
const headEnd = scanKeyword(line, cursor, end, HL_INSERT_HEAD);
if (headEnd !== null) return { target: { kind: "bof" }, nextIndex: consumeOptionalColon(line, headEnd, end) };
const tailEnd = scanKeyword(line, cursor, end, HL_INSERT_TAIL);
if (tailEnd !== null) return { target: { kind: "eof" }, nextIndex: consumeOptionalColon(line, tailEnd, end) };
const headEnd = scanKeyword(line, probe, end, HL_INSERT_HEAD);
if (headEnd !== null) return { cursor: { kind: "bof" }, nextIndex: consumeOptionalColon(line, headEnd, end) };
const tailEnd = scanKeyword(line, probe, end, HL_INSERT_TAIL);
if (tailEnd !== null) return { cursor: { kind: "eof" }, nextIndex: consumeOptionalColon(line, tailEnd, end) };
return null;
}
function scanInsertTarget(line: string, index: number, end: number): TargetScan | null {
const scan = scanPositionalSuffix(line, index, end);
if (scan === null) return null;
const { cursor, nextIndex } = scan;
switch (cursor.kind) {
case "before_anchor":
return { target: { kind: "insert_before", anchor: cursor.anchor }, nextIndex };
case "after_anchor":
return { target: { kind: "insert_after", anchor: cursor.anchor }, nextIndex };
case "bof":
return { target: { kind: "bof" }, nextIndex };
case "eof":
return { target: { kind: "eof" }, nextIndex };
}
}
function unquotePath(pathText: string): string {
if (pathText.length < 2) return pathText;
const first = pathText[0];
@@ -394,6 +426,68 @@ function scanHunkAnchor(line: string, start: number, end: number): TargetScan |
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
};
}
// `PASTE.BLK.POST N` — insert the clipboard after the tree-sitter block
// at N. Like all clipboard ops, takes no body rows.
const pasteAfterBlockEnd = scanKeyword(line, cursor, end, HL_PASTE_AFTER_BLOCK_KEYWORD);
if (pasteAfterBlockEnd !== null) {
const anchor = scanLineNumber(line, skipWhitespace(line, pasteAfterBlockEnd, end), end);
if (anchor === null) return null;
return {
target: { kind: "paste_after_block", anchor: { line: anchor.line } },
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
};
}
// `PASTE.PRE|POST N` / `PASTE.HEAD|TAIL` — insert the clipboard at the position.
const pasteEnd = scanKeyword(line, cursor, end, HL_PASTE_KEYWORD);
if (pasteEnd !== null) {
const scan = scanPositionalSuffix(line, pasteEnd, end);
if (scan === null) return null;
return { target: { kind: "paste", cursor: scan.cursor }, nextIndex: scan.nextIndex };
}
// `CUT.BLK N` / `COPY.BLK N` — capture the tree-sitter block at N into the
// clipboard (cut also deletes its span). Scanned before the plain forms so
// the `.BLK` suffix is not consumed as a malformed range.
const cutBlockEnd = scanKeyword(line, cursor, end, HL_CUT_BLOCK_KEYWORD);
if (cutBlockEnd !== null) {
const anchor = scanLineNumber(line, skipWhitespace(line, cutBlockEnd, end), end);
if (anchor === null) return null;
return {
target: { kind: "copy_block", anchor: { line: anchor.line }, cut: true },
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
};
}
const copyBlockEnd = scanKeyword(line, cursor, end, HL_COPY_BLOCK_KEYWORD);
if (copyBlockEnd !== null) {
const anchor = scanLineNumber(line, skipWhitespace(line, copyBlockEnd, end), end);
if (anchor === null) return null;
return {
target: { kind: "copy_block", anchor: { line: anchor.line }, cut: false },
nextIndex: consumeOptionalColon(line, anchor.nextIndex, end),
};
}
// `CUT N.=M` / `COPY N.=M` — capture concrete lines (cut also deletes
// them). A trailing colon is tolerated and ignored; body rows are rejected
// by the parser.
const cutEnd = scanKeyword(line, cursor, end, HL_CUT_KEYWORD);
if (cutEnd !== null) {
const range = scanHeaderRange(line, cutEnd, end, true);
if (range === null) return null;
const next = skipStrayDot(line, range.nextIndex, end);
return {
target: { kind: "copy", range: range.range, cut: true },
nextIndex: consumeOptionalColon(line, next, end),
};
}
const copyEnd = scanKeyword(line, cursor, end, HL_COPY_KEYWORD);
if (copyEnd !== null) {
const range = scanHeaderRange(line, copyEnd, end, true);
if (range === null) return null;
const next = skipStrayDot(line, range.nextIndex, end);
return {
target: { kind: "copy", range: range.range, cut: false },
nextIndex: consumeOptionalColon(line, next, end),
};
}
const insertEnd = scanKeyword(line, cursor, end, HL_INSERT_KEYWORD);
if (insertEnd !== null) return scanInsertTarget(line, insertEnd, end);
return null;
@@ -491,7 +585,10 @@ function classifyLine(line: string, lineNum: number): Token {
line.startsWith(HL_DELETE_KEYWORD, lead) ||
line.startsWith(HL_INSERT_KEYWORD, lead) ||
line.startsWith(HL_REM_KEYWORD, lead) ||
line.startsWith(HL_MOVE_KEYWORD, lead);
line.startsWith(HL_MOVE_KEYWORD, lead) ||
line.startsWith(HL_CUT_KEYWORD, lead) ||
line.startsWith(HL_COPY_KEYWORD, lead) ||
line.startsWith(HL_PASTE_KEYWORD, lead);
if (isHunkLead) {
const hunk = tryParseHunkHeader(line);
if (hunk !== null) return { kind: "op-block", lineNum, target: hunk.target };
+56 -4
View File
@@ -40,6 +40,37 @@ export type Edit =
blockStart?: number;
}
| { kind: "delete"; anchor: Anchor; lineNum: number; index: number; oldAssertion?: string }
| {
/**
* Clipboard capture (`COPY N.=M` / `CUT N.=M`, or the resolved form of
* `COPY.BLK N` / `CUT.BLK N`). Captures the range's current lines into
* the {@link Clipboard} register during the applier's clipboard
* pre-pass. Emits no text change itself — a `CUT` additionally lowers
* to one `delete` per range line at parse/resolve time, exactly like
* `DEL N.=M`.
*/
kind: "copy";
range: ParsedRange;
/** True for `CUT` (capture + delete), false for `COPY` (capture only). */
cut: boolean;
lineNum: number;
index: number;
}
| {
/**
* Clipboard insertion (`PASTE.PRE N` / `PASTE.POST N` / `PASTE.HEAD` /
* `PASTE.TAIL`, or the resolved form of `PASTE.BLK.POST N`). Expanded
* by the clipboard pre-pass into one plain insert per captured line.
* `blockStart` mirrors the insert variant's field for block-lowered
* pastes so landing correction can slide the body across trailing
* closer lines.
*/
kind: "paste";
cursor: Cursor;
lineNum: number;
index: number;
blockStart?: number;
}
| {
/**
* Deferred block edit (`replace_block N:` / `delete_block N` /
@@ -50,13 +81,15 @@ export type Edit =
* the same `replacement` inserts + deletes that `replace start.=end:`
* produces; an empty `payloads` (from `delete_block`) becomes a pure
* range deletion; `mode: "insert_after"` becomes plain `after_anchor`
* inserts at the block's last line. `applyEdits` never sees this
* variant.
* inserts at the block's last line; `mode: "copy"`/`"cut"` becomes a
* `copy` edit over the resolved span (plus per-line deletes for cut);
* `mode: "paste_after"` becomes a `paste` edit anchored after the
* block's last line. `applyEdits` never sees this variant.
*/
kind: "block";
anchor: Anchor;
payloads: string[];
mode?: "insert_after";
mode?: "insert_after" | "copy" | "cut" | "paste_after";
lineNum: number;
index: number;
};
@@ -149,7 +182,7 @@ export interface BlockResolution {
/** Last line of the resolved span (1-indexed, inclusive). */
end: number;
/** Which block op produced this resolution. */
op: "replace" | "delete" | "insert_after";
op: "replace" | "delete" | "insert_after" | "copy" | "cut" | "paste_after";
}
/** Request handed to a {@link BlockResolver} to resolve one `replace_block N:` anchor. */
@@ -170,3 +203,22 @@ export interface BlockResolverRequest {
* the contract; the host injects a tree-sitter-backed implementation.
*/
export type BlockResolver = (request: BlockResolverRequest) => BlockSpan | null;
/**
* Mutable clipboard register threaded through one patch application. Filled
* by `CUT`/`COPY` edits, read by `PASTE` edits, in patch source order —
* across sections, so content can move between files. Create one per batch
* (`{}`) and hand it to every {@link Patcher.prepare} / `applyTo` call in
* that batch; callers that omit it get a private per-call register.
*/
export interface Clipboard {
/** Lines captured by the most recent `CUT`/`COPY`, or unset. */
lines?: readonly string[];
/**
* Human-readable form of the `CUT` that filled the register (e.g.
* `CUT 5.=10`), cleared by the first `PASTE`. A register that still has a
* pending cut at batch end (or when overwritten) is an authoring error:
* lines were deleted but never landed anywhere.
*/
pendingCut?: string;
}
+325
View File
@@ -0,0 +1,325 @@
import { describe, expect, it } from "bun:test";
import {
applyEdits,
type BlockResolver,
type BlockSpan,
type Clipboard,
computeFileHash,
InMemoryFilesystem,
InMemorySnapshotStore,
Patch,
Patcher,
parsePatch,
parsePatchStreaming,
resolveBlockEdits,
} from "@oh-my-pi/hashline";
const PATH = "x.ts";
// Deterministic stub: the block beginning on line N spans [N, N+1].
const stubResolver: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line + 1 });
function taggedPatcher(files: Array<[string, string]>): {
fs: InMemoryFilesystem;
snapshots: InMemorySnapshotStore;
patcher: Patcher;
tags: Map<string, string>;
} {
const fs = new InMemoryFilesystem(files);
const snapshots = new InMemorySnapshotStore();
const tags = new Map<string, string>();
for (const [path, text] of files) tags.set(path, snapshots.record(path, text));
return { fs, snapshots, patcher: new Patcher({ fs, snapshots }), tags };
}
describe("clipboard parsing", () => {
it("lowers `CUT N.=M` to a capture plus per-line deletes, `COPY N.=M` to a capture only", () => {
const cut = parsePatch("CUT 2.=3").edits;
expect(cut.map(edit => edit.kind)).toEqual(["copy", "delete", "delete"]);
expect(cut[0]).toMatchObject({ kind: "copy", cut: true, range: { start: { line: 2 }, end: { line: 3 } } });
const copy = parsePatch("COPY 2.=3").edits;
expect(copy.map(edit => edit.kind)).toEqual(["copy"]);
expect(copy[0]).toMatchObject({ kind: "copy", cut: false });
});
it("parses every PASTE position, tolerating a trailing colon", () => {
const cursors = parsePatch("COPY 1\nPASTE.PRE 2\nPASTE.POST 3:\nPASTE.HEAD\nPASTE.TAIL:").edits.flatMap(edit =>
edit.kind === "paste" ? [edit.cursor] : [],
);
expect(cursors).toEqual([
{ kind: "before_anchor", anchor: { line: 2 } },
{ kind: "after_anchor", anchor: { line: 3 } },
{ kind: "bof" },
{ kind: "eof" },
]);
});
it("rejects bare `PASTE` without a position", () => {
expect(() => parsePatch("COPY 1\nPASTE")).toThrow(/`PASTE` needs a position/);
});
it("rejects body rows under clipboard ops", () => {
expect(() => parsePatch("CUT 1.=2\n+x")).toThrow(/`CUT N.=M` captures \+ deletes/);
expect(() => parsePatch("COPY 1.=2\n+x")).toThrow(/`COPY N.=M` captures lines/);
expect(() => parsePatch("COPY 1\nPASTE.POST 1\n+x")).toThrow(/`PASTE` inserts the clipboard content/);
});
it("rejects a CUT range overlapping another hunk's range", () => {
expect(() => parsePatch("CUT 2.=4\nSWAP 3.=3:\n+x")).toThrow(/already targeted by another hunk/);
});
it("reports inverted CUT/COPY ranges with op-specific retry forms", () => {
expect(() => parsePatch("CUT 5.=2")).toThrow(/`CUT 5`.*`CUT 5.=6`/);
});
it("flushes a trailing bodyless clipboard op in streaming mode", () => {
const { edits } = parsePatchStreaming("COPY 1\nPASTE.TAIL");
expect(edits.map(edit => edit.kind)).toEqual(["copy", "paste"]);
});
});
describe("clipboard apply semantics", () => {
it("moves a range within a file (CUT + PASTE)", () => {
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 2.=3\nPASTE.POST 5`);
expect(section.applyTo("l1\nl2\nl3\nl4\nl5\n").text).toBe("l1\nl4\nl5\nl2\nl3\n");
});
it("duplicates COPY content to several places; PASTE does not consume", () => {
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCOPY 2.=2\nPASTE.HEAD\nPASTE.TAIL`);
expect(section.applyTo("l1\nl2\nl3\n").text).toBe("l2\nl1\nl2\nl3\nl2\n");
});
it("swaps two regions with sequential CUT/PASTE pairs on original coordinates", () => {
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 1.=2\nPASTE.POST 4\nCUT 3.=4\nPASTE.PRE 1`);
expect(section.applyTo("a1\na2\nb1\nb2").text).toBe("b1\nb2\na1\na2");
});
it("rejects PASTE with an empty register", () => {
const section = Patch.parseSingle(`[${PATH}#1A2B]\nPASTE.POST 1`);
expect(() => section.applyTo("l1\nl2\n")).toThrow(/found nothing in the clipboard/);
});
it("drops an empty-register PASTE on the streaming-tolerant path", () => {
const section = Patch.parseSingle(`[${PATH}#1A2B]\nPASTE.POST 1`);
expect(section.applyPartialTo("l1\nl2\n").text).toBe("l1\nl2\n");
});
it("rejects a CUT that is never pasted when the call owns the register", () => {
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 2.=2`);
expect(() => section.applyTo("l1\nl2\nl3\n")).toThrow(/never pasted/);
});
it("rejects capturing over un-pasted CUT content", () => {
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT 1.=1\nCUT 3.=3\nPASTE.TAIL`);
expect(() => section.applyTo("l1\nl2\nl3\n")).toThrow(/still holds `CUT 1` content/);
});
it("rejects an out-of-range capture", () => {
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCOPY 8.=9\nPASTE.HEAD`);
expect(() => section.applyTo("l1\nl2\n")).toThrow(/out of range \(file has 3 lines\)/);
});
it("threads a caller-owned register across applyEdits calls", () => {
const clipboard: Clipboard = {};
const cut = parsePatch("CUT 1.=1").edits;
const paste = parsePatch("PASTE.TAIL").edits;
expect(applyEdits("a\nb", cut, { clipboard }).text).toBe("b");
expect(applyEdits("x\ny", paste, { clipboard }).text).toBe("x\ny\na");
});
});
describe("clipboard block ops", () => {
it("expands CUT.BLK to a span capture plus per-line deletes", () => {
const edits = parsePatch("CUT.BLK 2\nPASTE.TAIL").edits;
const resolved = resolveBlockEdits(edits, "l1\nl2\nl3\nl4", PATH, stubResolver);
expect(resolved.map(edit => edit.kind)).toEqual(["copy", "delete", "delete", "paste"]);
expect(resolved[0]).toMatchObject({ kind: "copy", cut: true, range: { start: { line: 2 }, end: { line: 3 } } });
});
it("moves a block after another block via PASTE.BLK.POST", () => {
const section = Patch.parseSingle(`[${PATH}#1A2B]\nCUT.BLK 1\nPASTE.BLK.POST 3`);
// stub blocks: [1,2] and [3,4].
expect(section.applyTo("a1\na2\nb1\nb2\nrest", stubResolver).text).toBe("b1\nb2\na1\na2\nrest");
});
it("echoes clipboard block resolutions with their op", () => {
const seen: string[] = [];
resolveBlockEdits(parsePatch("COPY.BLK 2\nPASTE.TAIL").edits, "l1\nl2\nl3", PATH, stubResolver, {
onResolved: resolution => seen.push(resolution.op),
});
expect(seen).toEqual(["copy"]);
});
it("rejects a single-line CUT.BLK resolution with the plain-op retry", () => {
const single: BlockResolver = ({ line }): BlockSpan => ({ start: line, end: line });
const edits = parsePatch("CUT.BLK 2\nPASTE.TAIL").edits;
expect(() => resolveBlockEdits(edits, "a\nb\nc", PATH, single)).toThrow(/use `CUT 2`/);
});
it("lowers an unresolvable PASTE.BLK.POST to a plain paste with a warning", () => {
const warnings: string[] = [];
const resolved = resolveBlockEdits(parsePatch("COPY 1\nPASTE.BLK.POST 2").edits, "a\nb\nc", PATH, () => null, {
onWarning: warning => warnings.push(warning),
});
expect(resolved.map(edit => edit.kind)).toEqual(["copy", "paste"]);
expect(warnings.some(warning => warning.includes("`PASTE.BLK.POST 2`"))).toBe(true);
});
});
describe("clipboard across sections and batches", () => {
it("moves lines between files within one patch", async () => {
const a = "keep\nmove1\nmove2\n";
const b = "b1\n";
const { fs, patcher, tags } = taggedPatcher([
["a.ts", a],
["b.ts", b],
]);
const patch = Patch.parse(`[a.ts#${tags.get("a.ts")}]\nCUT 2.=3\n[b.ts#${tags.get("b.ts")}]\nPASTE.TAIL`);
await patcher.apply(patch);
expect(fs.get("a.ts")).toBe("keep\n");
expect(fs.get("b.ts")).toBe("b1\nmove1\nmove2\n");
});
it("allows a copy-only section to be a no-op clipboard source", async () => {
const a = "shared\n";
const b = "b1\n";
const { fs, patcher, tags } = taggedPatcher([
["a.ts", a],
["b.ts", b],
]);
const result = await patcher.apply(
Patch.parse(`[a.ts#${tags.get("a.ts")}]\nCOPY 1.=1\n[b.ts#${tags.get("b.ts")}]\nPASTE.HEAD`),
);
expect(result.sections[0]?.op).toBe("noop");
expect(fs.get("a.ts")).toBe(a);
expect(fs.get("b.ts")).toBe("shared\nb1\n");
});
it("still rejects a no-op section that mixes COPY with mutating edits", async () => {
const a = "l1\nl2\n";
const b = "b1\n";
const { patcher, tags } = taggedPatcher([
["a.ts", a],
["b.ts", b],
]);
// The SWAP restates the existing line, so the section changes nothing;
// the COPY beside it must not shield it from the no-change guard.
const patch = Patch.parse(
`[a.ts#${tags.get("a.ts")}]\nCOPY 1.=1\nSWAP 2.=2:\n+l2\n[b.ts#${tags.get("b.ts")}]\nPASTE.TAIL`,
);
await expect(patcher.apply(patch)).rejects.toThrow(/no changes/);
});
it("rejects the whole batch when a batch-local CUT is never pasted", async () => {
const a = "l1\nl2\n";
const { fs, patcher, tags } = taggedPatcher([["a.ts", a]]);
await expect(patcher.apply(Patch.parse(`[a.ts#${tags.get("a.ts")}]\nCUT 1.=1`))).rejects.toThrow(/never pasted/);
expect(fs.get("a.ts")).toBe(a);
});
it("persists a host-owned register across apply calls with a carry-forward warning", async () => {
const a = "l1\nl2\n";
const b = "b1\n";
const fs = new InMemoryFilesystem([
["a.ts", a],
["b.ts", b],
]);
const snapshots = new InMemorySnapshotStore();
const tagA = snapshots.record("a.ts", a);
const tagB = snapshots.record("b.ts", b);
const clipboard: Clipboard = {};
const patcher = new Patcher({ fs, snapshots, clipboard });
const first = await patcher.apply(Patch.parse(`[a.ts#${tagA}]\nCUT 1.=1`));
expect(fs.get("a.ts")).toBe("l2\n");
expect(first.sections[0]?.warnings.some(warning => warning.includes("in the clipboard"))).toBe(true);
expect(clipboard).toMatchObject({ lines: ["l1"], pendingCut: "CUT 1" });
await patcher.apply(Patch.parse(`[b.ts#${tagB}]\nPASTE.TAIL`));
expect(fs.get("b.ts")).toBe("b1\nl1\n");
expect(clipboard.pendingCut).toBeUndefined();
});
it("does not publish register changes from a failed batch", async () => {
const a = "l1\nl2\n";
const b = "b1\n";
const fs = new InMemoryFilesystem([
["a.ts", a],
["b.ts", b],
]);
const snapshots = new InMemorySnapshotStore();
const tagA = snapshots.record("a.ts", a);
snapshots.record("b.ts", b);
const clipboard: Clipboard = {};
const patcher = new Patcher({ fs, snapshots, clipboard });
// Second section carries a bogus tag, so the batch fails in prepare.
const patch = Patch.parse(`[a.ts#${tagA}]\nCUT 1.=1\n[b.ts#0000]\nSWAP 1.=1:\n+x`);
await expect(patcher.apply(patch)).rejects.toThrow();
expect(fs.get("a.ts")).toBe(a);
expect(clipboard.lines).toBeUndefined();
expect(clipboard.pendingCut).toBeUndefined();
});
it("recovers clipboard anchors when the file drifted by a uniform offset", async () => {
const original = "x1\nx2\nx3\n";
const live = "new\nx1\nx2\nx3\n";
const fs = new InMemoryFilesystem([["a.ts", live]]);
const snapshots = new InMemorySnapshotStore();
const tag = snapshots.record("a.ts", original);
const patcher = new Patcher({ fs, snapshots });
const result = await patcher.apply(Patch.parse(`[a.ts#${tag}]\nCUT 2.=2\nPASTE.TAIL`));
expect(fs.get("a.ts")).toBe("new\nx1\nx3\nx2\n");
expect(result.sections[0]?.warnings.length).toBeGreaterThan(0);
});
it("rejects clipboard ops in same-path sections interleaved across another file", () => {
const patch = Patch.parse(`[a.ts#1A2B]\nCUT 1.=1\n[b.ts#3C4D]\nSWAP 1.=1:\n+x\n[a.ts#1A2B]\nPASTE.TAIL`);
expect(() => patch.sections[0]?.edits).toThrow(/interleaved with another file/);
});
it("still merges interleaved same-path sections without clipboard ops", () => {
const patch = Patch.parse(`[a.ts#1A2B]\nDEL 1\n[b.ts#3C4D]\nDEL 1\n[a.ts#1A2B]\nDEL 3`);
expect(patch.sections).toHaveLength(2);
expect(patch.sections[0]?.edits.map(edit => edit.kind)).toEqual(["delete", "delete"]);
});
it("surfaces a targeted sequencing error instead of a mismatch on the drift path", async () => {
const original = "x1\nx2\nx3\n";
const live = "new\nx1\nx2\nx3\n";
const fs = new InMemoryFilesystem([["a.ts", live]]);
const snapshots = new InMemorySnapshotStore();
const tag = snapshots.record("a.ts", original);
const patcher = new Patcher({ fs, snapshots });
// PASTE with nothing captured, against a drifted tag: the recovery path
// swallows apply errors, so the sequencing check must fire first.
await expect(patcher.apply(Patch.parse(`[a.ts#${tag}]\nPASTE.POST 2\nCUT 2.=2`))).rejects.toThrow(
/found nothing in the clipboard/,
);
});
});
describe("clipboard header hash interplay", () => {
it("applies a paste-only section on the clean-tag fast path", async () => {
const text = "l1\nl2\n";
const fs = new InMemoryFilesystem([["a.ts", text]]);
const snapshots = new InMemorySnapshotStore();
expect(computeFileHash(text)).toBe(snapshots.record("a.ts", text));
const clipboard: Clipboard = { lines: ["from-before"] };
const patcher = new Patcher({ fs, snapshots, clipboard });
await patcher.apply(Patch.parse(`[a.ts#${computeFileHash(text)}]\nPASTE.POST 1`));
expect(fs.get("a.ts")).toBe("l1\nfrom-before\nl2\n");
});
});