From 9aada058ee2cd4ba183f53f613aa5da1830e2328 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 30 Jul 2026 06:13:58 +0200 Subject: [PATCH] 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. --- packages/coding-agent/CHANGELOG.md | 2 + .../coding-agent/src/edit/edit-clipboard.ts | 23 ++ .../coding-agent/src/edit/hashline/diff.ts | 69 +++- .../coding-agent/src/edit/hashline/execute.ts | 88 ++++- packages/coding-agent/src/edit/streaming.ts | 17 +- .../coding-agent/src/export/html/template.js | 2 +- .../src/modes/components/tool-execution.ts | 7 +- .../src/modes/controllers/event-controller.ts | 3 + .../src/modes/utils/ui-helpers.ts | 2 + .../coding-agent/src/session/agent-session.ts | 4 +- packages/coding-agent/src/tools/index.ts | 6 +- packages/coding-agent/test/edit-diff.test.ts | 69 +++- packages/hashline/CHANGELOG.md | 6 + packages/hashline/README.md | 3 + packages/hashline/src/apply.ts | 32 +- packages/hashline/src/block.ts | 47 ++- packages/hashline/src/clipboard.ts | 158 +++++++++ packages/hashline/src/format.ts | 12 + packages/hashline/src/grammar.lark | 8 +- packages/hashline/src/index.ts | 1 + packages/hashline/src/input.ts | 72 +++- packages/hashline/src/messages.ts | 149 +++++--- packages/hashline/src/parser.ts | 85 ++++- packages/hashline/src/patcher.ts | 85 ++++- packages/hashline/src/prompt.md | 21 +- packages/hashline/src/recovery.ts | 34 +- packages/hashline/src/tokenizer.ts | 119 ++++++- packages/hashline/src/types.ts | 60 +++- packages/hashline/test/clipboard.test.ts | 325 ++++++++++++++++++ 29 files changed, 1389 insertions(+), 120 deletions(-) create mode 100644 packages/coding-agent/src/edit/edit-clipboard.ts create mode 100644 packages/hashline/src/clipboard.ts create mode 100644 packages/hashline/test/clipboard.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b30b584a1..ac641fbed 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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. diff --git a/packages/coding-agent/src/edit/edit-clipboard.ts b/packages/coding-agent/src/edit/edit-clipboard.ts new file mode 100644 index 000000000..a25fc145b --- /dev/null +++ b/packages/coding-agent/src/edit/edit-clipboard.ts @@ -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; +} diff --git a/packages/coding-agent/src/edit/hashline/diff.ts b/packages/coding-agent/src/edit/hashline/diff.ts index d38e493eb..d3aa3eb97 100644 --- a/packages/coding-agent/src/edit/hashline/diff.ts +++ b/packages/coding-agent/src/edit/hashline/diff.ts @@ -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 { @@ -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 }); diff --git a/packages/coding-agent/src/edit/hashline/execute.ts b/packages/coding-agent/src/edit/hashline/execute.ts index fc3d4fc78..9e2e3ccca 100644 --- a/packages/coding-agent/src/edit/hashline/execute.ts +++ b/packages/coding-agent/src/edit/hashline/execute.ts @@ -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 = { + 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> { @@ -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({ diff --git a/packages/coding-agent/src/edit/streaming.ts b/packages/coding-agent/src/edit/streaming.ts index ebbd93299..36df33c21 100644 --- a/packages/coding-agent/src/edit/streaming.ts +++ b/packages/coding-agent/src/edit/streaming.ts @@ -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 = { // 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 = { 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) diff --git a/packages/coding-agent/src/export/html/template.js b/packages/coding-agent/src/export/html/template.js index d111cbb55..f3a4be6ab 100644 --- a/packages/coding-agent/src/export/html/template.js +++ b/packages/coding-agent/src/export/html/template.js @@ -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) { diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index 061f890ed..a0920407b 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -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, diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 62eb79b67..546b6057a 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -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"), diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 5482d6df4..608cc89ea 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -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"), diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 2011e7b61..3423ab9c6 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -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; diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 3155ae642..ee698df1f 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -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 diff --git a/packages/coding-agent/test/edit-diff.test.ts b/packages/coding-agent/test/edit-diff.test.ts index 10cbace63..76438bdd6 100644 --- a/packages/coding-agent/test/edit-diff.test.ts +++ b/packages/coding-agent/test/edit-diff.test.ts @@ -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"); diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index 94a51b69f..1d4d750cf 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -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. diff --git a/packages/hashline/README.md b/packages/hashline/README.md index 934030f6d..728105da0 100644 --- a/packages/hashline/README.md +++ b/packages/hashline/README.md @@ -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). diff --git a/packages/hashline/src/apply.ts b/packages/hashline/src/apply.ts index eff2175e9..b50de23af 100644 --- a/packages/hashline/src/apply.ts +++ b/packages/hashline/src/apply.ts @@ -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; diff --git a/packages/hashline/src/block.ts b/packages/hashline/src/block.ts index 4e6c95b42..4cee0d09d 100644 --- a/packages/hashline/src/block.ts +++ b/packages/hashline/src/block.ts @@ -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 diff --git a/packages/hashline/src/clipboard.ts b/packages/hashline/src/clipboard.ts new file mode 100644 index 000000000..72f004714 --- /dev/null +++ b/packages/hashline/src/clipboard.ts @@ -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; + +/** `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; + } + } +} diff --git a/packages/hashline/src/format.ts b/packages/hashline/src/format.ts index ff782ac68..6692e96a8 100644 --- a/packages/hashline/src/format.ts +++ b/packages/hashline/src/format.ts @@ -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`. */ diff --git a/packages/hashline/src/grammar.lark b/packages/hashline/src/grammar.lark index 41c7a2a32..a93bdf727 100644 --- a/packages/hashline/src/grammar.lark +++ b/packages/hashline/src/grammar.lark @@ -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 ":" diff --git a/packages/hashline/src/index.ts b/packages/hashline/src/index.ts index b7e61b17a..def2ee798 100644 --- a/packages/hashline/src/index.ts +++ b/packages/hashline/src/index.ts @@ -1,5 +1,6 @@ export * from "./apply"; export * from "./block"; +export * from "./clipboard"; export * from "./diff-preview"; export * from "./format"; export * from "./fs"; diff --git a/packages/hashline/src/input.ts b/packages/hashline/src/input.ts index e2b3f9455..455dd1a1b 100644 --- a/packages/hashline/src/input.ts +++ b/packages/hashline/src/input.ts @@ -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(); @@ -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(); + const byPath = new Map(); + 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 } : {}), })); } diff --git a/packages/hashline/src/messages.ts b/packages/hashline/src/messages.ts index d3a7e249a..687c96f5b 100644 --- a/packages/hashline/src/messages.ts +++ b/packages/hashline/src/messages.ts @@ -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 = { + 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 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.`; diff --git a/packages/hashline/src/parser.ts b/packages/hashline/src/parser.ts index 13751113b..624b72aa7 100644 --- a/packages/hashline/src/parser.ts +++ b/packages/hashline/src/parser.ts @@ -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: ` 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); diff --git a/packages/hashline/src/patcher.ts b/packages/hashline/src/patcher.ts index 162aee2ad..bb9ec3031 100644 --- a/packages/hashline/src/patcher.ts +++ b/packages/hashline/src/patcher.ts @@ -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 { + // 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 { + // 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 { + async prepare(section: PatchSection, clipboard?: Clipboard): Promise { 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; diff --git a/packages/hashline/src/prompt.md b/packages/hashline/src/prompt.md index 655401689..883716757 100644 --- a/packages/hashline/src/prompt.md +++ b/packages/hashline/src/prompt.md @@ -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. 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. @@ -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() {} diff --git a/packages/hashline/src/recovery.ts b/packages/hashline/src/recovery.ts index f6456b23f..a93e7f1ac 100644 --- a/packages/hashline/src/recovery.ts +++ b/packages/hashline/src/recovery.ts @@ -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); } } diff --git a/packages/hashline/src/tokenizer.ts b/packages/hashline/src/tokenizer.ts index ad6a8e6ac..a0533a8e8 100644 --- a/packages/hashline/src/tokenizer.ts +++ b/packages/hashline/src/tokenizer.ts @@ -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 }; diff --git a/packages/hashline/src/types.ts b/packages/hashline/src/types.ts index 72d7e66fe..8f1f00a98 100644 --- a/packages/hashline/src/types.ts +++ b/packages/hashline/src/types.ts @@ -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; +} diff --git a/packages/hashline/test/clipboard.test.ts b/packages/hashline/test/clipboard.test.ts new file mode 100644 index 000000000..2fce15816 --- /dev/null +++ b/packages/hashline/test/clipboard.test.ts @@ -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; +} { + const fs = new InMemoryFilesystem(files); + const snapshots = new InMemorySnapshotStore(); + const tags = new Map(); + 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"); + }); +});